diff --git a/.github/workflows/build_and_test_host.yml b/.github/workflows/build_and_test_host.yml index 3e114eaae..b5a2c2caf 100644 --- a/.github/workflows/build_and_test_host.yml +++ b/.github/workflows/build_and_test_host.yml @@ -53,6 +53,11 @@ jobs: - name: Bazel build targets run: | bazel build //... + - run: df -h + if: always() + - name: Bazel lint targets + run: | + bazel test --test_tag_filters=lint //... - name: Bazel test targets run: | bazel test ${{ matrix.targets }} --build_tests_only \ @@ -100,6 +105,9 @@ jobs: - name: Run Linux QEMU integration tests run: | bazel test --config=qemu-integration ${{ matrix.bazel_args }} //quality/... //tests/integration_test/... + - name: Run TC8 ITF SOME/IP Conformance Tests + run: | + bazel test --config=tc8-itf --test_output=all //tests/tc8_conformance/... - run: df -h if: always() @@ -129,3 +137,5 @@ jobs: - name: Run Linux perf tests (flamegraphs) run: | bazel test --config=perf-tests-flamegraphs //tests/benchmarks/... --build_tests_only + - run: df -h + if: always() diff --git a/.github/workflows/build_and_test_qnx.yml b/.github/workflows/build_and_test_qnx.yml index cd891c930..ec7b417e5 100644 --- a/.github/workflows/build_and_test_qnx.yml +++ b/.github/workflows/build_and_test_qnx.yml @@ -38,3 +38,22 @@ jobs: score-qnx-license: ${{ secrets.SCORE_QNX_LICENSE }} score-qnx-user: ${{ secrets.SCORE_QNX_USER }} score-qnx-password: ${{ secrets.SCORE_QNX_PASSWORD }} + + # TC8 SOME/IP conformance tests with someipd in QNX8 QEMU (x86_64 only). + # --config=tc8-itf-qnx bundles QNX platform, QEMU runner, and tc8 tag filter. + tc8_itf_qnx_conformance: + uses: eclipse-score/cicd-workflows/.github/workflows/qnx-build.yml@improved-caching + permissions: + contents: read + pull-requests: read + actions: write # needed for cache deletion at setup-bazel-cache + with: + bazel-target: "//src/someipd/... //tests/tc8_conformance/..." + bazel-test-target: "//tests/tc8_conformance/..." + bazel-config: tc8-itf-qnx + credential-helper: ".github/tools/qnx_credential_helper.py" + bazel-disk-cache: ${{ github.workflow }}-tc8-itf-qnx + secrets: + score-qnx-license: ${{ secrets.SCORE_QNX_LICENSE }} + score-qnx-user: ${{ secrets.SCORE_QNX_USER }} + score-qnx-password: ${{ secrets.SCORE_QNX_PASSWORD }} diff --git a/REUSE.toml b/REUSE.toml index 50f682381..018f988f0 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -51,6 +51,7 @@ path = [ "tests/benchmarks/config/*/*.json", "tests/integration_test/vsomeip-local.json", "tests/integration_test/vsomeip.json", + "tests/benchmarks/config/benchmark_mw_com_config.json", "tests/tc8_conformance/**/*.json", "quality/integration_testing/environments/qnx8_qemu/qemu_config.json", "quality/integration_testing/environments/ubuntu24_04_qemu/*" diff --git a/docs/architecture/components.rst b/docs/architecture/components.rst index bae93f870..3cef32e58 100644 --- a/docs/architecture/components.rst +++ b/docs/architecture/components.rst @@ -60,7 +60,6 @@ Components A serializer that does not actually serialize or deserialize data, but only handles messages that are already serialized by the application. - .. comp:: SOME/IP Network Daemon :id: comp__someipd :status: valid diff --git a/docs/architecture/index.rst b/docs/architecture/index.rst index 054ff1de4..968da67e3 100644 --- a/docs/architecture/index.rst +++ b/docs/architecture/index.rst @@ -40,3 +40,4 @@ Design decisions dec_someipgw_registration.rst dec_someip_api_abstraction.rst dec_ipc_implementation.rst + tc8_conformance_testing.rst diff --git a/docs/architecture/tc8_conformance_testing.rst b/docs/architecture/tc8_conformance_testing.rst new file mode 100644 index 000000000..6d5bd10a2 --- /dev/null +++ b/docs/architecture/tc8_conformance_testing.rst @@ -0,0 +1,468 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +TC8 SOME/IP Conformance Testing +================================ + +Overview +-------- + +`OPEN Alliance TC8 `_ +defines conformance tests for automotive SOME/IP implementations. +The TC8 test suite has two scopes: + +- **Protocol Conformance**: tests the production ``someipd`` binary at the wire level using the + ``someip`` Python package. No application processes are needed. + +- **Application Level Tests**: tests the full gateway path from mw::com client + through ``gatewayd`` and ``someipd`` to the network, using C++ apps + built on ``score::mw::com``. These tests are stack-agnostic. + +Both scopes live under ``tests/tc8_conformance/`` and share the ``tc8`` and +``conformance`` Bazel tags. For setup instructions and test details, see +``tests/tc8_conformance/README.md``. + +Test Scope Overview +------------------- + +.. uml:: + + @startuml + !theme plain + scale max 800 width + skinparam packageStyle rectangle + + package "Protocol Conformance" { + [pytest] as L1Test + [someipd] as L1DUT + [gatewayd] as L1GW + L1Test -down-> L1DUT : raw SOME/IP\nUDP / TCP + L1DUT -right-> L1GW : IPC (idles) + } + + package "Application-Level Tests" { + [pytest\norchestrator] as L2Orch + + [TC8 Service\n(mw::com Skeleton)] as L2Svc + [gatewayd] as L2GW1 + [someipd] as L2SD1 + + [someipd] as L2SD2 + [gatewayd] as L2GW2 + [TC8 Client\n(mw::com Proxy)] as L2Cli + + L2Orch .down.> L2Svc + L2Orch .down.> L2GW1 + L2Orch .down.> L2SD1 + L2Orch .down.> L2SD2 + L2Orch .down.> L2GW2 + L2Orch .down.> L2Cli + + L2Svc -right-> L2GW1 : LoLa IPC + L2GW1 -right-> L2SD1 : LoLa IPC + L2SD1 -right-> L2SD2 : SOME/IP\nUDP / TCP + L2SD2 -right-> L2GW2 : LoLa IPC + L2GW2 -right-> L2Cli : LoLa IPC + } + + L1DUT -[hidden]down-> L2Orch + @enduml + +Protocol Conformance +-------------------- + +Protocol conformance tests exercise the SOME/IP stack at the wire protocol +level. Tests send raw SOME/IP messages and verify responses against the TC8 +specification. + +DUT Binary +^^^^^^^^^^ + +The protocol conformance DUT is the production ``someipd`` binary. It uses +vsomeip directly for all SOME/IP network I/O and service discovery. +``someipd`` is QM only and is not part of the ASIL-B safety path. + +At startup, ``someipd`` reads a FlatBuffer binary config (``-c`` flag) that +declares the service ID, instance ID, version, and events to offer. The binary +initialises a vsomeip application and calls ``offer_service()`` for each entry. +For TC8, the config is ``tc8_someipd_config.bin``, generated at build time from +``tests/tc8_conformance/config/tc8_someipd_config.json``. + +``gatewayd`` is started alongside ``someipd`` as a companion process. In the +SD-only conformance tests ``gatewayd`` idles (its FlatBuffer config declares no +service types), but the IPC handshake between ``someipd`` and ``gatewayd`` must +complete before the test proceeds. ``gatewayd`` becomes active only in ETS +end-to-end tests where a mw::com application offers or consumes the TC8 service. + +``tc8_itf_conftest.py`` launches ``someipd`` first (it becomes the vsomeip +routing manager), waits for an OfferService multicast, then starts ``gatewayd``. +Both processes are force-killed (``pkill -9``) during fixture teardown. + +Port Isolation and Parallel Execution +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Each Bazel TC8 target runs in its own OS process and receives unique SOME/IP +port values via the Bazel ``env`` attribute. Three environment variables +control port assignment: + +``TC8_SD_PORT`` + SOME/IP-SD port. Set in both the vsomeip config template (replacing the + ``__TC8_SD_PORT__`` placeholder) and read by the Python SD sender socket + at module import time via ``helpers/constants.py``. The SOME/IP-SD + protocol requires SD messages to originate from the configured SD port; + satisfying this does not require a fixed port, it requires only that + both sides use the *same* port, which is guaranteed because both the + vsomeip config and the Python constants read the same env var. + +``TC8_SVC_PORT`` + DUT UDP (unreliable) service port. Replaces the ``__TC8_SVC_PORT__`` + placeholder in config templates. + +``TC8_SVC_TCP_PORT`` + DUT TCP (reliable) service port. Replaces the ``__TC8_SVC_TCP_PORT__`` + placeholder in config templates. Only set for targets that use reliable + transport (``tc8_message_format``, ``tc8_event_notification``, + ``tc8_field_conformance``). + +All three constants default to the historical static values (30490 / 30509 / +30510) when the environment variables are not set, preserving backward +compatibility for local development runs without Bazel. + +For the per-target port matrix, see ``tests/tc8_conformance/README.md``. + +Skipped Test Categories +^^^^^^^^^^^^^^^^^^^^^^^ + +The production stack has no mw::com ETS application to generate events, handle +methods, or provide field values. The following test modules and individual +tests are permanently skipped until a mw::com ETS app is added to the repo. + +.. list-table:: Skipped TC8 Tests (2026-08-11) + :header-rows: 1 + :widths: 30 20 50 + + * - Module / Test + - Skip level + - Reason + * - ``test_event_notification.py`` + - All tests + - No event delivery without mw::com ETS app + * - ``test_field_conformance.py`` + - All tests + - No initial field values or getter/setter handling without mw::com ETS app + * - ``test_someip_message_format.py`` + - All tests + - No SOME/IP method handling (gatewayd to_num_of_methods=0) without mw::com ETS app + * - ``test_service_discovery.py`` + - 4 individual tests + - Require event notifications before SD lifecycle actions + +The 4 individually skipped tests in ``test_service_discovery.py`` are: + +- ``TestSDSubscribeLifecycle.test_tc8_sd_008_stop_subscribe_ceases_notifications`` +- ``TestSDTTLExpiry.test_tc8_sd_014_ttl_expiry_ceases_notifications`` +- ``TestSDSubscribeLifecycleAdvanced.test_ets_155_resubscribe_after_stop`` +- ``TestSDSubscribeLifecycleAdvanced.test_ets_095_subscribe_ttl_expires_no_events`` + +Test Module Structure +^^^^^^^^^^^^^^^^^^^^^ + +Each TC8 area has a test module (pytest) and one or more helper modules. +The diagrams below show the dependencies grouped by TC8 domain. +Blue boxes represent test modules and green boxes represent shared helper +modules. Dashed arrows indicate internal helper-to-helper dependencies. + +Service Discovery (SD) +~~~~~~~~~~~~~~~~~~~~~~~~ + +The Service Discovery tests (TC8-SD) verify SOME/IP-SD offer announcements, +find/subscribe responses, SD phase timing, byte-level SD field values, +malformed packet robustness, and SD client lifecycle. + +.. uml:: + + @startuml + !theme plain + scale max 800 width + skinparam component { + BackgroundColor<> #E3F2FD + BorderColor<> #1565C0 + BackgroundColor<> #E8F5E9 + BorderColor<> #2E7D32 + } + + title Service Discovery — Test Module Dependencies + + [test_service_discovery] <> + [test_sd_phases_timing] <> + [test_sd_reboot] <> + [test_sd_format_compliance] <> + [test_sd_robustness] <> + [test_sd_client] <> + + [sd_helpers] <> + [sd_sender] <> + [sd_malformed] <> + [someip_assertions] <> + [timing] <> + + test_service_discovery --> sd_helpers + test_service_discovery --> sd_sender + test_service_discovery --> someip_assertions + test_service_discovery --> timing + test_sd_phases_timing --> timing + test_sd_phases_timing --> sd_helpers + test_sd_reboot --> sd_helpers + test_sd_format_compliance --> sd_helpers + test_sd_robustness --> sd_malformed + test_sd_robustness --> sd_helpers + test_sd_client --> sd_helpers + test_sd_client --> sd_sender + + timing ..> sd_helpers : <> + @enduml + +Message Format, Events, Fields, and TCP Transport (MSG / EVT / FLD / TCP) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +These tests cover message format (TC8-MSG), event notification (TC8-EVT), +field access (TC8-FLD), and TCP transport binding. Domain-specific helpers +handle packet construction, subscription workflows, field get/set operations, +and TCP stream framing. + +.. uml:: + + @startuml + !theme plain + scale max 800 width + skinparam component { + BackgroundColor<> #E3F2FD + BorderColor<> #1565C0 + BackgroundColor<> #E8F5E9 + BorderColor<> #2E7D32 + } + + title Message / Event / Field / TCP — Test Module Dependencies + + [test_someip_message_format] <> + [test_event_notification] <> + [test_field_conformance] <> + + [message_builder] <> + [someip_assertions] <> + [sd_helpers] <> + [sd_sender] <> + [event_helpers] <> + [field_helpers] <> + [tcp_helpers] <> + [udp_helpers] <> + + test_someip_message_format --> message_builder + test_someip_message_format --> someip_assertions + test_someip_message_format --> sd_helpers + test_someip_message_format --> tcp_helpers + test_someip_message_format --> udp_helpers + test_event_notification --> event_helpers + test_event_notification --> sd_helpers + test_event_notification --> sd_sender + test_event_notification --> tcp_helpers + test_field_conformance --> field_helpers + test_field_conformance --> event_helpers + test_field_conformance --> sd_helpers + + event_helpers ..> sd_sender : <> + field_helpers ..> message_builder : <> + field_helpers ..> tcp_helpers : <> + @enduml + +Multi-service and Multi-instance +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``test_multi_service.py`` verifies that ``someipd`` correctly handles +vsomeip configurations that declare multiple service entries, each advertising +its own distinct UDP port in the SD endpoint option. + +.. uml:: + + @startuml + !theme plain + scale max 800 width + skinparam component { + BackgroundColor<> #E3F2FD + BorderColor<> #1565C0 + BackgroundColor<> #E8F5E9 + BorderColor<> #2E7D32 + } + + title Multi-service / Multi-instance — Test Module Dependencies + + [test_multi_service] <> + + [sd_helpers] <> + [sd_sender] <> + + test_multi_service --> sd_helpers + test_multi_service --> sd_sender + @enduml + +Application Level Tests +----------------------- + +Application level tests verify the full gateway pipeline end to end. +A **service** (mw::com Skeleton) and **client** (mw::com Proxy) communicate +through ``gatewayd`` and ``someipd``. Because both apps use the mw::com API +only, the same test code works with any SOME/IP binding. + +.. note:: + + Application level tests are planned. See + ``tests/tc8_conformance/application/README.md`` for the intended scope. + +Planned Topology +^^^^^^^^^^^^^^^^ + +.. uml:: + + @startuml + !theme plain + scale max 800 width + + node "Host" { + [TC8 Service\n(mw::com Skeleton)] as Svc + [gatewayd] as GW1 + [someipd] as SD1 + + [someipd] as SD2 + [gatewayd] as GW2 + [TC8 Client\n(mw::com Proxy)] as Cli + + Svc -right-> GW1 : LoLa IPC + GW1 -right-> SD1 : LoLa IPC + SD1 -right-> SD2 : SOME/IP\nUDP / TCP + SD2 -right-> GW2 : LoLa IPC + GW2 -right-> Cli : LoLa IPC + } + + [pytest\norchestrator] as Orch + Orch .down.> Svc + Orch .down.> GW1 + Orch .down.> SD1 + Orch .down.> SD2 + Orch .down.> GW2 + Orch .down.> Cli + @enduml + +Stack-Agnostic Design +^^^^^^^^^^^^^^^^^^^^^ + +The test apps depend only on ``score::mw::com``. Switching the SOME/IP stack +requires changing the deployment config, not test code. + +.. uml:: + + @startuml + !theme plain + scale max 800 width + + package "Test Code (stack-agnostic)" { + class "TC8 Service" { + mw::com Skeleton + events, fields + } + class "TC8 Client" { + mw::com Proxy + subscribe, read + } + } + + package "Deployment Config (stack-specific)" { + class "mw_com_config.json" { + binding: | + } + class "someip_stack.json" { + service routing + } + } + + package "Runtime (swappable)" { + class "someipd\n(Stack A)" as vS + class "someipd\n(Stack B)" as eS + } + + "TC8 Service" ..> "mw_com_config.json" : reads at startup + "TC8 Client" ..> "mw_com_config.json" : reads at startup + "mw_com_config.json" ..> vS : binds to + "mw_com_config.json" ..> eS : or binds to + + note bottom of "TC8 Service" + Swapping stacks = change + config only. + Zero code changes. + end note + @enduml + +Planned Components +^^^^^^^^^^^^^^^^^^ + +The application level test design introduces four planned components. +The **Enhanced Testability Service** (**ETS**) and **Enhanced Testability +Client** (**ETC**) implement the TC8 service interface defined in OA TC8 +§5.1.4, while the **Test Orchestrator** and **Process Orchestrator** manage +test and process lifecycle. + +.. uml:: + + @startuml + !theme plain + scale max 800 width + skinparam classAttributeIconSize 0 + + class "Enhanced Testability Service" as ETS { + mw::com Skeleton + -- + +offer_tc8_events() + +offer_tc8_fields() + } + + class "Enhanced Testability Client" as ETC { + mw::com Proxy + -- + +subscribe_events() + +read_fields() + +validate_tc8_values() + } + + class "Test Orchestrator" as TO { + pytest + -- + +run_end_to_end_tests() + } + + class "Process Orchestrator" as PO { + helper + -- + +start_stack(config_dir) + +stop_stack(handle) + +wait_service_available(name, timeout) + } + + TO -down-> ETS : starts / stops + TO -down-> ETC : starts / stops + TO -down-> PO : uses + @enduml + +``someipd`` and ``gatewayd`` run as the DUT in the QEMU guest. The Python +socket-based tester and pytest run on the host side. ``tc8_itf_conftest.py`` +manages DUT lifecycle on the QEMU guest. ``TC8_DUT_IP`` addresses the QEMU +guest and ``TC8_TESTER_IP`` addresses the host TAP interface. diff --git a/docs/requirements/index.rst b/docs/requirements/index.rst index 017a1c134..8472dc381 100644 --- a/docs/requirements/index.rst +++ b/docs/requirements/index.rst @@ -65,7 +65,7 @@ The following component identifiers are used in requirement IDs and file names: - Gateway daemon — bridges IPC and SOME/IP, E2E protection, ACL enforcement * - ``someipd`` - QM - - SOME/IP stack daemon — wraps vsomeip, handles network I/O and SOME/IP-SD + - SOME/IP stack daemon — handles network I/O and SOME/IP-SD * - ``network_service`` - ASIL-B - IPC interface between ``gatewayd`` and ``someipd`` (SomeipMessageTransfer) diff --git a/docs/requirements/stakeholder.rst b/docs/requirements/stakeholder.rst index 8e42e3e85..a2bffa418 100644 --- a/docs/requirements/stakeholder.rst +++ b/docs/requirements/stakeholder.rst @@ -20,18 +20,28 @@ Stakeholder requirements capture high-level needs from users, integrators, and safety standards. They are the top of the requirements hierarchy — feature and component requirements derive from these. -.. stkh_req:: Example Functional Requirement - :id: stkh_req__docgen_enabled__example +.. stkh_req:: SOME/IP Wire-Level Protocol Interoperability + :id: stkh_req__someip_gw__interoperability :status: valid :version: 1 + :valid_from: v0.0.0 :safety: QM - :security: YES + :security: NO :reqtype: Functional - :rationale: Ensure documentation builds are possible for all modules - :valid_from: v0.0.0 + :rationale: Automotive ECUs from different manufacturers communicate over SOME/IP. The SOME/IP Gateway network-facing component must conform to the SOME/IP wire-level protocol specification so that it interoperates with any compliant SOME/IP implementation. + + The SOME/IP Gateway shall ensure that its SOME/IP network interface + component conforms to the SOME/IP wire-level protocol specification, + enabling correct interoperation with SOME/IP-compliant devices. .. note:: - The requirement above is a placeholder inherited from the module template. - It should be replaced with proper SOME/IP Gateway stakeholder requirements - as they are defined. + This is a module-local stakeholder requirement. An upstream S-CORE + stakeholder requirement for SOME/IP interoperability does not yet exist. + If one is defined in a future S-CORE release, the ``:derived_from:`` link + on the feature requirement(s) below should be updated to reference it. + + ``feat_req__tc8_conformance__conformance`` (see + :doc:`/tc8_conformance/requirements`) derives from this stakeholder + requirement: TC8 wire-level conformance testing is the verification + activity for this interoperability need. diff --git a/docs/tc8_conformance/index.rst b/docs/tc8_conformance/index.rst index 742d186fa..bb601cf79 100644 --- a/docs/tc8_conformance/index.rst +++ b/docs/tc8_conformance/index.rst @@ -16,13 +16,45 @@ TC8 SOME/IP Conformance Testing ================================ This section defines the requirements, test specifications, and traceability -for OPEN Alliance TC8 SOME/IP conformance testing of the ``someipd`` -component (vsomeip 3.6.1 stack). +for `OPEN Alliance TC8 `_ +SOME/IP conformance testing of the SOME/IP Gateway. -All tests are **application-less** — they exercise the SOME/IP protocol stack -directly at the wire level without requiring ``gatewayd`` or application processes. +The TC8 test suite covers two scopes: + +- **Protocol Conformance** — Tests ``someipd`` at the wire level using raw + UDP/TCP sockets and the ``someip`` Python package. No application processes + are needed. ``someipd`` runs in ``--tc8-standalone`` mode. + +- **Application-Level Tests** — Tests the full gateway path + (mw::com client → ``gatewayd`` → ``someipd`` → network) using C++ apps + built on ``score::mw::com``. These tests are stack-agnostic. + +All tests live under ``tests/tc8_conformance/`` and share the ``tc8`` / +``conformance`` Bazel tags. For the architectural overview, test topology +diagrams, and module structure, see +:doc:`/architecture/tc8_conformance_testing`. + +.. note:: + + Protocol conformance tests run ``someipd`` in ``--tc8-standalone`` mode. + Fixture defaults (DUT IP, tester IP, port numbers) are defined in + ``tc8_itf_conftest.py``. To run all protocol conformance tests:: + + bazel test --config=tc8-itf //tests/tc8_conformance/... + + For QNX x86_64 use ``--config=tc8-itf-qnx``. .. toctree:: :maxdepth: 2 requirements.rst + test_specification.rst + traceability.rst + +.. seealso:: + + :doc:`/architecture/tc8_conformance_testing` — test topology, module + dependency diagrams, and planned components. + + :doc:`traceability` — full OA TC8 v3.0 Chapter 5 scope analysis, gap + analysis, coverage breakdown, and known stack limitations. diff --git a/docs/tc8_conformance/requirements.rst b/docs/tc8_conformance/requirements.rst index 36cd6c6bd..fb741e91e 100644 --- a/docs/tc8_conformance/requirements.rst +++ b/docs/tc8_conformance/requirements.rst @@ -15,6 +15,90 @@ TC8 Conformance Test Requirements ================================= +Overview +-------- + +This document defines the requirements for verifying ``someipd`` against +the OPEN Alliance TC8 SOME/IP test specification. + +It belongs to a set of three documents that work together: + +.. list-table:: TC8 Conformance Documentation Set + :widths: 25 75 + :header-rows: 1 + + * - Document + - Purpose + * - **requirements.rst** (this file) + - Defines *what* must be verified: one feature requirement and + multiple component requirements, each linked to the S-CORE + requirement hierarchy. + * - :doc:`test_specification` + - Defines *how* each test runs: purpose, preconditions, stimuli, + and expected results. + * - :doc:`traceability` + - Maps external OA spec test case IDs to internal test IDs, + component requirements, and Python test functions. + +How the feature / component split works for TC8 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For TC8 conformance, the split is simple: + +* **One feature requirement** + (``feat_req__tc8_conformance__conformance``) covers the overall goal: + "verify ``someipd`` against OA TC8 SOME/IP at the wire level." This + requirement does **not** change when new test areas are added. + +* **Many component requirements** — one per testable protocol aspect + (e.g., SD offer format, cyclic timing, response headers, TCP + transport). Each component requirement: + + - Describes the specific behaviour under test. + - References the relevant AUTOSAR PRS or TC8 specification section. + - Is verified by one or more pytest functions. + +Requirement Areas +^^^^^^^^^^^^^^^^^ + +The component requirements are grouped by TC8 test area: + +.. list-table:: + :widths: 30 20 50 + :header-rows: 1 + + * - Area + - Req Count + - Scope + * - Service Discovery + - 9 + - SD offer format, cyclic timing, find response, subscribe lifecycle, + subscription TTL expiry, phases timing, endpoint options, reboot + detection, multicast eventgroup + * - SD Format and Options Compliance + - 3 + - Byte-level field assertions for SD SOME/IP header, offer entry, + SubscribeAck entry, StopSubscribeEventgroup entry, IPv4EndpointOption, + and IPv4MulticastOption + * - SD Robustness + - 1 + - Malformed SD packet handling without crash or state corruption + * - SOME/IP Message Format + - 3 + - Response header fields, error return codes, malformed message handling + * - Event Notification + - 1 + - Notification delivery lifecycle (subscribe, event ID, multicast, stop) + * - Field Conformance + - 3 + - Initial value on subscribe, getter/setter methods, on-change-only field notification + * - TCP Transport Binding + - 1 + - TCP reliable transport for RPC, field get/set, event notification + * - Multi-service and Multi-instance + - 1 + - Multi-service config loading, per-service port advertisement, SD isolation + Feature Requirement ------------------- @@ -26,7 +110,7 @@ as a formal verification activity for the SOME/IP Gateway's protocol stack. :status: valid :version: 1 :tags: tc8, conformance, someip, verification - :derived_from: stkh_req__docgen_enabled__example + :derived_from: stkh_req__someip_gw__interoperability :satisfied_by: feat__someip_gateway :safety: QM :security: NO @@ -38,9 +122,12 @@ as a formal verification activity for the SOME/IP Gateway's protocol stack. specifications at the wire protocol level, without requiring application processes. - Note: The ``:derived_from:`` link targets a placeholder stakeholder requirement. - This shall be updated to reference the upstream S-CORE stakeholder requirement - for SOME/IP interoperability once it is formally defined. + **Scope boundary:** The TC8 conformance tests run ``someipd`` in + standalone mode (``--tc8-standalone`` flag). They validate the wire-level + SOME/IP behavior of ``someipd`` only. They do not exercise the full + middleware path from ``score::mw::com`` through ``gatewayd`` to + ``someipd``. End-to-end middleware integration is covered by the + integration test suite under ``tests/integration``. Component Requirements — Service Discovery ------------------------------------------ @@ -121,13 +208,31 @@ Specification (AUTOSAR PRS_SOMEIP_SD). The conformance test suite shall verify that ``someipd`` correctly handles the SubscribeEventgroup lifecycle: acknowledge valid subscriptions (SubscribeEventgroupAck), reject unknown eventgroups - (SubscribeEventgroupNack with TTL=0), and honor StopSubscribeEventgroup - by ceasing notifications. + (SubscribeEventgroupNack with TTL=0), honor StopSubscribeEventgroup + by ceasing notifications, and clean up expired subscriptions after + the subscription TTL elapses. Note: Traces to SOME/IP-SD specification sections 4.1.2.4 (SubscribeEventgroup), 4.1.2.5 (StopSubscribeEventgroup), - and 4.1.2.6 (SubscribeEventgroupAck/Nack). - Covers TC8-SD-006, TC8-SD-007, and TC8-SD-008 from the test strategy. + 4.1.2.6 (SubscribeEventgroupAck/Nack), and 4.1.2.7 (TTL handling). + Covers TC8-SD-006, TC8-SD-007, TC8-SD-008, and TC8-SD-014 from the + test strategy. + +.. comp_req:: TC8 SD Subscription TTL Expiry + :id: comp_req__tc8_conformance__sd_ttl_expiry + :status: valid + :version: 1 + :tags: tc8, conformance, service_discovery, eventgroup, timing + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that event notifications cease to arrive + after the subscription TTL expires: when a tester subscribes with TTL = 1 and no + renewal is sent, no further SOME/IP notifications shall be received beyond 2 seconds + after the TTL expiry, conforming to OA TC8 SOMEIP_ETS_095. .. comp_req:: TC8 SD Initial Delay and Repetitions Phase :id: comp_req__tc8_conformance__sd_phases_timing @@ -149,6 +254,66 @@ Specification (AUTOSAR PRS_SOMEIP_SD). (SD Phases — Initial Wait, Repetition, Main Phase). Covers TC8-SD-009 and TC8-SD-010 from the test strategy. +.. comp_req:: TC8 SD IPv4 Endpoint Option Validation + :id: comp_req__tc8_conformance__sd_endpoint_option + :status: valid + :version: 1 + :tags: tc8, conformance, service_discovery + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` includes + a valid IPv4EndpointOption in OfferService SD entries, carrying the + correct unicast address, port, and L4 protocol (UDP) so that clients + can reach the offered service. + + Note: Traces to SOME/IP-SD specification section 4.1.2.4 + (SD Options — IPv4 Endpoint Option format). + Covers TC8-SD-011 from the test strategy. + +.. comp_req:: TC8 SD Reboot Detection + :id: comp_req__tc8_conformance__sd_reboot + :status: valid + :version: 1 + :tags: tc8, conformance, service_discovery, reboot + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` resets its + SD state upon restart: the reboot flag (SD flags byte bit 7) shall + be set in the first SD message after restart, and the SD session ID + shall reset to a low value (≤ 2). + + Note: Traces to SOME/IP-SD specification section 4.1.1 + (Reboot Detection — session ID and reboot flag handling). + Covers TC8-SD-012 from the test strategy. + +.. comp_req:: TC8 SD Multicast Eventgroup Option + :id: comp_req__tc8_conformance__sd_mcast_eg + :status: valid + :version: 1 + :tags: tc8, conformance, service_discovery, multicast + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` includes + a multicast IPv4EndpointOption in the SubscribeEventgroupAck for + eventgroups configured with a multicast address, so that clients + know which multicast group to join for event delivery. + + Note: Traces to SOME/IP-SD specification section 4.1.2.6 + (SubscribeEventgroupAck options — multicast endpoint). + Covers TC8-SD-013 from the test strategy. + Component Requirements — SOME/IP Message Format ----------------------------------------------- @@ -170,8 +335,8 @@ Component Requirements — SOME/IP Message Format Note: Traces to SOME/IP specification sections 4.1.4 (Protocol Version), 4.1.6 (Message Type), and 4.1.3 (Request ID — - Client ID / Session ID). Covers TC8-SOMEIP-MSG-001, - TC8-SOMEIP-MSG-002, TC8-SOMEIP-MSG-005, and TC8-SOMEIP-MSG-008 + Client ID / Session ID). Covers TC8-MSG-001, + TC8-MSG-002, TC8-MSG-005, and TC8-MSG-008 from the test strategy. .. comp_req:: TC8 SOME/IP Error Return Codes @@ -187,13 +352,35 @@ Component Requirements — SOME/IP Message Format The conformance test suite shall verify that ``someipd`` returns the correct SOME/IP return codes for error conditions: - ``E_UNKNOWN_SERVICE`` (0x02) for requests to non-existent services, + ``E_UNKNOWN_SERVICE`` (0x02) or no response for requests to + non-existent services (both are valid stack behaviors), ``E_UNKNOWN_METHOD`` (0x03) for invalid method IDs, and ``E_WRONG_INTERFACE_VERSION`` for interface version mismatches. Note: Traces to SOME/IP specification section 4.1.7 (Return Code) - and the return code table (Table 4.14). Covers TC8-SOMEIP-MSG-003, - TC8-SOMEIP-MSG-004, and TC8-SOMEIP-MSG-006 from the test strategy. + and the return code table (Table 4.14). Covers TC8-MSG-003, + TC8-MSG-004, and TC8-MSG-006 from the test strategy. + +.. comp_req:: TC8 SOME/IP Malformed Message Handling + :id: comp_req__tc8_conformance__msg_malformed + :status: valid + :version: 1 + :tags: tc8, conformance, message_format, robustness + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` does not + crash when receiving malformed SOME/IP messages, including truncated + messages (shorter than the 8-byte minimum header), messages with an + invalid protocol version, and messages whose length field claims more + data than the UDP payload contains. + + Note: Traces to SOME/IP specification section 4.1 (Header format + validation and error handling). Covers TC8-MSG-007 from the + test strategy. Component Requirements — Event Notification ------------------------------------------- @@ -216,52 +403,264 @@ Component Requirements — Event Notification Note: Traces to SOME/IP specification section 5.1 (Events) and SOME/IP-SD section 4.1.2.4 (SubscribeEventgroup triggering - notification delivery). Covers TC8-EVT-001 through TC8-EVT-004 - and TC8-EVT-006 from the test strategy. + notification delivery). Covers TC8-EVT-001 through TC8-EVT-006 + from the test strategy. -Traceability Summary --------------------- +Component Requirements — Field Conformance +------------------------------------------- -The following table links each component requirement to the SOME/IP -specification section it verifies. +.. comp_req:: TC8 Field Initial Value on Subscribe + :id: comp_req__tc8_conformance__fld_initial_value + :status: valid + :version: 1 + :tags: tc8, conformance, fields, notification + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional -.. list-table:: TC8 Requirement Traceability Matrix - :widths: 30 20 30 20 - :header-rows: 1 + The conformance test suite shall verify that ``someipd`` delivers an initial + NOTIFICATION message to a new subscriber of a field eventgroup (``is_field: true``) + immediately upon subscription, carrying the last known field value. + + Note: Traces to SOME/IP specification section 5.3 (Fields — initial value + notification on subscribe) and AUTOSAR SWS_CM_00719. + Covers TC8-FLD-001 and TC8-FLD-002 from the test strategy. + +.. comp_req:: TC8 Field Getter and Setter + :id: comp_req__tc8_conformance__fld_get_set + :status: valid + :version: 1 + :tags: tc8, conformance, fields, request_response + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` handles field + getter (method 0x0001) and setter (method 0x0002) requests: the getter + shall return the current field value in a RESPONSE; the setter shall update + the stored field value, respond with E_OK, and immediately notify all + active subscribers with the new value. + + Note: Traces to SOME/IP specification section 5.3 (Fields — getter/setter + methods) and AUTOSAR SWS_CM_00720/SWS_CM_00721. + Covers TC8-FLD-003 and TC8-FLD-004 from the test strategy. + +.. comp_req:: TC8 Field Getter/Setter Notification Behavior + :id: comp_req__tc8_conformance__fld_getter_setter + :status: valid + :version: 1 + :tags: tc8, conformance, fields, notification + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` sends a field event + notification only when the field value actually changes, not on every cyclic + trigger or repeated SET with the same value. + + Note: Traces to OA TC8 v3.0 §5.1.5.7 SOMEIPSRV_RPC_16 (on-change-only + notification for fields). + Covered by TC8-EVT-007 in ``test_event_notification.py``. + +Component Requirements — TCP Transport Binding +----------------------------------------------- + +.. comp_req:: TC8 TCP Transport Binding for RPC + :id: comp_req__tc8_conformance__tcp_transport + :status: valid + :version: 1 + :tags: tc8, conformance, tcp, transport, rpc + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` supports + TCP (reliable) transport binding for SOME/IP RPC request/response + communication, including correct TCP endpoint advertisement in + Service Discovery and successful method invocation over a TCP + connection. + + Note: Traces to OA TC8 specification references SOMEIPSRV_RPC_01, + SOMEIPSRV_RPC_02, and SOMEIPSRV_OPTIONS_15. Also traces to + PRS_SOMEIP_00142 (SOME/IP TCP message framing) and PRS_SOMEIP_00569 + (unaligned message handling over TCP), covered by TC8-TCP-009. + Addresses Gap 1 (TCP transport binding) from the architecture + conformance analysis. + +.. seealso:: + + For the full traceability chain (OA specification → internal TC8 ID → + requirement → test function), see :doc:`traceability`. + + For detailed test case specifications (purpose, stimuli, expected results), + see :doc:`test_specification`. + +Component Requirements — Multi-service and Multi-instance +----------------------------------------------------------- + +.. comp_req:: TC8 Multi-service and Multi-instance Routing + :id: comp_req__tc8_conformance__multi_service + :status: valid + :version: 1 + :tags: tc8, conformance, multi_service, routing + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` accepts a vsomeip + configuration containing multiple service entries and, for each offered + service, advertises the correct UDP port in the SD OfferService endpoint + option. Tests also verify that only the configured service IDs appear in + SD traffic and that the multi-service config can be loaded without process + failure. + + Note: Traces to OA TC8 specification references SOMEIPSRV_RPC_13 + (multi-service hosting) and SOMEIPSRV_RPC_14 (per-instance port isolation). + Covered by ``test_multi_service.py`` in the ``test_tc8_multi_service`` Bazel target. + +Component Requirements — SD Format and Options Compliance +----------------------------------------------------------- + +The following component requirements cover byte-level field assertions for +SOME/IP-SD messages sent by ``someipd``, corresponding to OA TC8 v3.0 §5.1.5.1 +(FORMAT_*) and §5.1.5.2 (OPTIONS_*). + +.. comp_req:: TC8 SD SOME/IP Header and Entry Field Validation + :id: comp_req__tc8_conformance__sd_format_fields + :status: valid + :version: 1 + :tags: tc8, conformance, service_discovery, format + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` transmits SD + OfferService and SubscribeEventgroupAck messages with correct byte-level + field values, including: Client-ID = 0x0000 (FORMAT_01), Session-ID + starting from 0x0001 (FORMAT_02), Interface Version = 0x01 (FORMAT_04), + Message Type = 0x02 Notification (FORMAT_05), Return Code = 0x00 E_OK + (FORMAT_06), undefined SD flag bits = 0 (FORMAT_09), reserved entry bytes + = 0 (FORMAT_10), entry length = 16 bytes (FORMAT_11), correct option run + indices and option counts (FORMAT_12/13), instance ID (FORMAT_15), major + version (FORMAT_16), minor version (FORMAT_18) matching the configured + values, SubscribeAck entry type = 0x06 (FORMAT_19), SubscribeAck entry + length = 16 bytes (FORMAT_20), SubscribeAck option run index (FORMAT_21), + SubscribeAck service ID (FORMAT_23), instance ID (FORMAT_24), major version + (FORMAT_25), TTL > 0 (FORMAT_26), reserved field = 0 (FORMAT_27), and + eventgroup ID (FORMAT_28) matching the subscribe request. + + Note: Traces to OA TC8 v3.0 §5.1.5.1 (SOME/IP-SD header and entry + format assertions). + +.. comp_req:: TC8 SD IPv4 Endpoint and Multicast Option Field Validation + :id: comp_req__tc8_conformance__sd_options_fields + :status: valid + :version: 1 + :tags: tc8, conformance, service_discovery, options + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` encodes SD + IPv4EndpointOptions with correct sub-field values: option length = 0x0009 + (OPTIONS_01), option type = 0x04 (OPTIONS_02), reserved byte after type + = 0x00 (OPTIONS_03), reserved byte before protocol = 0x00 (OPTIONS_05), + and L4 protocol = 0x11 UDP (OPTIONS_06). For multicast eventgroups the + suite shall also verify that SubscribeEventgroupAck messages contain + IPv4MulticastOptions with correct encoding: length = 0x0009 (OPTIONS_08), + type = 0x14 (OPTIONS_09), reserved byte = 0x00 (OPTIONS_10), multicast + address matching configuration (OPTIONS_11), reserved byte before port + = 0x00 (OPTIONS_12), L4 protocol = 0x11 UDP (OPTIONS_13), and port number + matching configuration (OPTIONS_14). + + Note: Traces to OA TC8 v3.0 §5.1.5.2 (SD Options format assertions). + Multicast option tests (OPTIONS_08–14) require a non-loopback interface + (``@pytest.mark.network``). + +.. comp_req:: TC8 SD StopSubscribeEventgroup Entry Wire Format + :id: comp_req__tc8_conformance__sd_stop_sub_fmt + :status: valid + :version: 1 + :tags: tc8, conformance, service_discovery, format + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that a StopSubscribeEventgroup SD entry + has entry type byte ``0x06`` and TTL field (bytes 9–11 of the entry) equal to + ``0x000000`` at the wire level, conforming to OA TC8 SOMEIPSRV_SD_MESSAGE_12. + +Component Requirements — SD Robustness +---------------------------------------- + +.. comp_req:: TC8 SD Robustness — Malformed Packet Survival + :id: comp_req__tc8_conformance__sd_robustness + :status: valid + :version: 1 + :tags: tc8, conformance, service_discovery, robustness + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` survives and + remains functional (still responds to a valid FindService) after receiving + malformed SOME/IP-SD packets, covering: empty entries arrays (ETS_111), + zero-length or malformed options (ETS_112/113), mismatched entries-length + fields (ETS_114), entries referencing more options than present (ETS_115), + unknown option types (ETS_116/174), overlapping option indices (ETS_117), + FindService with unexpected endpoint options (ETS_118), entries-length + exceeding the payload (ETS_123/124/125), option lengths extending past the + options array (ETS_134/135), option lengths shorter than the minimum + (ETS_136), unaligned option lengths (ETS_137), options-array-length + mismatches (ETS_138/139), SubscribeEventgroup without endpoint option + (ETS_109), with zero IP endpoint (ETS_110), with wrong L4 protocol + (ETS_119), for unknown service/instance/eventgroup (ETS_140/141/142/143), + with reserved option type (ETS_144), SD with near-wrap or maximum session + IDs (ETS_152), SOME/IP length field mismatches (ETS_153), and wrong + SOME/IP service ID in the header (ETS_178). + + Note: Traces to OA TC8 v3.0 §5.1.6 (Enhanced Testability Service Tests — + SD robustness cases). + +Component Requirements — UDP Transport Binding +----------------------------------------------- + +.. comp_req:: TC8 UDP Transport Binding — Multiple Messages per Datagram + :id: comp_req__tc8_conformance__udp_transport + :status: valid + :version: 1 + :tags: tc8, conformance, udp, transport + :derived_from: feat_req__tc8_conformance__conformance + :satisfied_by: comp__someipd + :safety: QM + :security: NO + :reqtype: Functional + + The conformance test suite shall verify that ``someipd`` correctly parses + a UDP datagram that contains multiple SOME/IP messages packed consecutively, + including the case where a message starts at a non-4-byte-aligned byte offset + within the datagram. The DUT shall respond to each contained SOME/IP request + individually. - * - Requirement ID - - TC8 Test IDs - - SOME/IP Spec Reference - - Safety - * - ``comp_req__tc8_conformance__sd_offer_format`` - - TC8-SD-001, -002 - - SOME/IP-SD §4.1.2.1, §4.1.2.3 - - QM - * - ``comp_req__tc8_conformance__sd_cyclic_timing`` - - TC8-SD-003 - - SOME/IP-SD §4.1.1 (Main Phase) - - QM - * - ``comp_req__tc8_conformance__sd_find_response`` - - TC8-SD-004, -005 - - SOME/IP-SD §4.1.2.2 - - QM - * - ``comp_req__tc8_conformance__sd_sub_lifecycle`` - - TC8-SD-006, -007, -008 - - SOME/IP-SD §4.1.2.4–4.1.2.6 - - QM - * - ``comp_req__tc8_conformance__sd_phases_timing`` - - TC8-SD-009, -010 - - SOME/IP-SD §4.1.1 (Phases) - - QM - * - ``comp_req__tc8_conformance__msg_resp_header`` - - TC8-MSG-001, -002, -005, -008 - - SOME/IP §4.1.3, §4.1.4, §4.1.6 - - QM - * - ``comp_req__tc8_conformance__msg_error_codes`` - - TC8-MSG-003, -004, -006 - - SOME/IP §4.1.7 (Table 4.14) - - QM - * - ``comp_req__tc8_conformance__evt_subscription`` - - TC8-EVT-001–004, -006 - - SOME/IP §5.1, SOME/IP-SD §4.1.2.4 - - QM + Note: Traces to PRS_SOMEIP_00142 and PRS_SOMEIP_00569 (unaligned SOME/IP message + parsing over UDP). + Covered by TC8-UDP-001 in ``test_someip_message_format.py`` + (``test_tc8_ets_069_unaligned_someip_messages_over_udp``). diff --git a/docs/tc8_conformance/test_specification.rst b/docs/tc8_conformance/test_specification.rst new file mode 100644 index 000000000..5f117b767 --- /dev/null +++ b/docs/tc8_conformance/test_specification.rst @@ -0,0 +1,3344 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +TC8 Test Specifications +======================== + +This document provides the detailed test specification for each TC8 +conformance test case. Each entry describes the purpose, preconditions, +test stimuli, expected results, and requirement traceability. + +For the full OA specification mapping see :doc:`traceability`. +For requirement definitions see :doc:`requirements`. + +.. note:: + + The "OA Spec Reference" field in each test case references the + corresponding section from Chapter 5 of the OPEN Alliance + TC8 Automotive Ethernet ECU Test Specification v3.0. + See :doc:`traceability` for the full mapping. + +.. note:: Execution model + + The host address is ``TC8_TESTER_IP``; the DUT address is ``TC8_DUT_IP``. + Both default values are defined in ``tc8_itf_conftest.py``. To run a + test:: + + bazel test --config=tc8-itf //tests/tc8_conformance:test_tc8_ + + For QNX x86_64 use ``--config=tc8-itf-qnx``. + +.. note:: Terminology + + Throughout this specification, **"server"** refers to the SOME/IP Service Provider role + (the DUT, which offers services and responds to requests), and **"client"** refers to the + SOME/IP Service Consumer role (the external test harness, which discovers services and + subscribes to events). This usage mirrors TC8 OA §5.1.5 ("SOME/IP Server Tests") and + §5.1.6 ("ETS Client / Control") directly. + +Service Discovery Tests +----------------------- + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json`` + +TC8-SD-001 — Multicast Offer on Startup +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_08 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_001_multicast_offer_on_startup`` +:Requirement: ``comp_req__tc8_conformance__sd_offer_format`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that ``someipd`` sends at least one SD OfferService entry on the +configured multicast group (``224.244.224.245:30490``) after startup. + +**Preconditions:** + +- ``someipd`` started with ``--tc8-standalone`` flag +- Multicast route available (``224.0.0.0/4``) + +**Stimuli:** +None — passive observation of DUT multicast traffic. + +**Expected Result:** +At least one SOME/IP-SD message containing an OfferService entry is +received on the multicast group within 5 seconds of DUT startup. + +TC8-SD-002 — Offer Entry Format +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_14–18 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_002_offer_entry_format`` +:Requirement: ``comp_req__tc8_conformance__sd_offer_format`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that the OfferService entry carries the correct service ID, +instance ID, major/minor version, and TTL as configured. + +**Preconditions:** + +- Same as TC8-SD-001. + +**Stimuli:** +None — passive observation. + +**Expected Result:** +OfferService entry has ``service_id=0x1234``, ``instance_id=0x5678``, +``major_version=0x00``, ``minor_version=0x00000000``, and ``TTL > 0``. + +TC8-SD-003 — Cyclic Offer Timing +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_02 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_003_cyclic_offer_timing`` +:Requirement: ``comp_req__tc8_conformance__sd_cyclic_timing`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that OfferService entries repeat at the configured +``cyclic_offer_delay`` (2000 ms ±20%) during the main phase. + +**Preconditions:** + +- DUT in SD main phase (wait for repetition phase to complete). + +**Stimuli:** +None — passive observation with timestamps. + +**Expected Result:** +Inter-offer gaps in main phase are within [1600 ms, 2400 ms]. + +TC8-SD-004 — FindService Known Service +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_171 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_004_find_known_service_unicast_offer`` +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that ``someipd`` responds with a unicast OfferService when a +FindService is sent for a known service. + +**Preconditions:** + +- DUT offering service ``0x1234``. + +**Stimuli:** +Send SD FindService entry for service ``0x1234`` / instance ``0x5678``. + +**Expected Result:** +Unicast OfferService entry received for the requested service. + +TC8-SD-005 — FindService Unknown Service +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.4 — implied by SD_BEHAVIOR_03/04 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_005_find_unknown_service_no_response`` +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that ``someipd`` does not respond to a FindService for an +unknown service. + +**Preconditions:** + +- DUT running, not offering service ``0xBEEF``. + +**Stimuli:** +Send SD FindService entry for service ``0xBEEF``. + +**Expected Result:** +No OfferService entry received for service ``0xBEEF`` within 2 seconds. + +TC8-SD-006 — Subscribe Eventgroup Ack +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_13 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_006_subscribe_valid_eventgroup_ack`` +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that ``someipd`` sends SubscribeEventgroupAck (TTL > 0) for a +valid eventgroup subscription. + +**Preconditions:** + +- DUT offering eventgroup ``0x4455``. + +**Stimuli:** +Send SD SubscribeEventgroup for service ``0x1234``, eventgroup ``0x4455``. + +**Expected Result:** +SubscribeEventgroupAck with TTL > 0 received. + +TC8-SD-007 — Subscribe Unknown Eventgroup Nack +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_14; §5.1.6 — SOMEIP_ETS_140 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_007_subscribe_unknown_eventgroup_nack`` +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that ``someipd`` sends SubscribeEventgroupNack (TTL = 0) for +an unknown eventgroup. + +**Preconditions:** + +- DUT running, eventgroup ``0xBEEF`` not configured. + +**Stimuli:** +Send SD SubscribeEventgroup for eventgroup ``0xBEEF``. + +**Expected Result:** +SubscribeEventgroupAck with TTL = 0 (Nack) received. + +TC8-SD-008 — StopSubscribe Ceases Notifications +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_108, SOMEIP_ETS_092 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_008_stop_subscribe_ceases_notifications`` +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that notifications cease after StopSubscribeEventgroup (TTL = 0). + +**Preconditions:** + +- Active subscription to eventgroup ``0x4455``. +- At least one notification received. + +**Stimuli:** +Send SD SubscribeEventgroup with TTL = 0 (StopSubscribe). + +**Expected Result:** +No further notifications received within 4 seconds. + +TC8-SD-009 — Repetition Phase Intervals +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_01 +:Test Module: ``test_sd_phases_timing.py`` +:Test Function: ``test_tc8_sd_009_repetition_phase_intervals`` +:Requirement: ``comp_req__tc8_conformance__sd_phases_timing`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that the first inter-offer gap after startup is a Repetition +Phase gap (shorter than half the cyclic offer delay). + +**Preconditions:** + +- Multicast socket opened before DUT startup to capture first offer. + +**Stimuli:** +None — passive observation from DUT start. + +**Expected Result:** +First gap < 1000 ms (half of ``cyclic_offer_delay`` 2000 ms). + +TC8-SD-010 — Repetition Count +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_01 +:Test Module: ``test_sd_phases_timing.py`` +:Test Function: ``test_tc8_sd_010_repetition_count_before_main_phase`` +:Requirement: ``comp_req__tc8_conformance__sd_phases_timing`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify at least ``repetitions_max - 1`` short-gap offers before +transition to main phase. + +**Preconditions:** + +- Same as TC8-SD-009. + +**Stimuli:** +None — passive observation from DUT start. + +**Expected Result:** +At least 2 short gaps (< 1000 ms) observed before a long gap (main phase). + +TC8-SD-011 — IPv4 Endpoint Option +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_01–07 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_011_offer_ipv4_endpoint_option`` +:Requirement: ``comp_req__tc8_conformance__sd_endpoint_option`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that OfferService SD entries include an IPv4EndpointOption +with the correct address, port, and L4 protocol. + +**Preconditions:** + +- DUT offering service on UDP port 30509. + +**Stimuli:** +None — passive observation with option parsing. + +**Expected Result:** +IPv4EndpointOption present with address matching ``host_ip``, +port = 30509, protocol = UDP. + +TC8-SD-012 — Reboot Detection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_02, FORMAT_07 +:Test Module: ``test_sd_reboot.py`` +:Test Functions: + - ``test_tc8_sd_012_reboot_flag_set_after_restart`` + - ``test_tc8_sd_012_session_id_resets_after_restart`` +:Requirement: ``comp_req__tc8_conformance__sd_reboot`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that ``someipd`` resets SD state on restart: reboot flag set +(bit 7 = 1) and session ID reset to ≤ 2. + +**Preconditions:** + +- DUT started, SD messages captured, then DUT terminated. + +**Stimuli:** +Restart ``someipd`` and capture the first post-reboot SD message. + +**Expected Result:** +First post-restart SD message has reboot flag set and session ID ≤ 2. + +TC8-SD-013 — Multicast Eventgroup Option +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_08–14 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_013_subscribe_ack_has_multicast_option`` +:Requirement: ``comp_req__tc8_conformance__sd_mcast_eg`` +:DUT Config: ``tc8_someipd_sd.json`` +:Marker: ``@pytest.mark.network`` (requires non-loopback interface) + +**Purpose:** +Verify that SubscribeEventgroupAck for a multicast eventgroup includes +a multicast IPv4EndpointOption. + +**Preconditions:** + +- Non-loopback network interface (``TC8_TESTER_IP`` set). +- Eventgroup ``0x4465`` configured with multicast address ``239.0.0.1``. + +**Stimuli:** +Send SD SubscribeEventgroup for eventgroup ``0x4465``. + +**Expected Result:** +SubscribeEventgroupAck contains a multicast IPv4EndpointOption. + +TC8-SD-014 — TTL Expiry Cleanup +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_095 +:Test Module: ``test_service_discovery.py`` +:Test Function: ``test_tc8_sd_014_ttl_expiry_ceases_notifications`` +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:DUT Config: ``tc8_someipd_sd.json`` + +**Purpose:** +Verify that notifications cease after the subscription TTL expires. + +**Preconditions:** + +- Active subscription with TTL = 3 seconds. +- At least one notification received before expiry. + +**Stimuli:** +Wait for TTL to expire (3 s + 2 s margin). + +**Expected Result:** +No notifications received in a 3-second window after TTL expiry. + +SOME/IP Message Format Tests +----------------------------- + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +TC8-MSG-001 — Protocol Version +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_05 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_msg_001_protocol_version`` +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that RESPONSE messages have ``protocol_version = 0x01``. + +**Stimuli:** +Send REQUEST to service ``0x1234``, method ``0x0421``. + +**Expected Result:** +RESPONSE with ``protocol_version == 1``. + +TC8-MSG-002 — Message Type +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_07 +:Test Module: ``test_someip_message_format.py`` +:Test Functions: + - ``test_tc8_msg_002_message_type_response`` + - ``test_tc8_msg_002_no_response_for_request_no_return`` +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify RESPONSE (0x80) for REQUEST; no response for REQUEST_NO_RETURN. + +**Stimuli:** + +- Send REQUEST → expect RESPONSE. +- Send REQUEST_NO_RETURN → expect silence. + +**Expected Result:** + +- REQUEST produces RESPONSE with ``message_type = 0x80``. +- REQUEST_NO_RETURN produces no response within 2 seconds. + +TC8-MSG-003 — Unknown Service +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_10; §5.1.6 — SOMEIP_ETS_077 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_msg_003_unknown_service`` +:Requirement: ``comp_req__tc8_conformance__msg_error_codes`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify error handling for requests to non-existent services. + +**Stimuli:** +Send REQUEST to service ``0xBEEF``. + +**Expected Result:** +``E_UNKNOWN_SERVICE`` (0x02) or no response (both valid per TC8). + +TC8-MSG-004 — Unknown Method +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_12; §5.1.6 — SOMEIP_ETS_076 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_msg_004_unknown_method`` +:Requirement: ``comp_req__tc8_conformance__msg_error_codes`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify error handling for requests with invalid method IDs. + +**Stimuli:** +Send REQUEST to service ``0x1234``, method ``0xBEEF``. + +**Expected Result:** +``E_UNKNOWN_METHOD`` (0x03). + +TC8-MSG-005 — Session ID Echo +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_03 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_msg_005_session_id_echo`` +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify RESPONSE echoes the REQUEST session ID. + +**Stimuli:** +Send REQUEST with ``session_id = 0x1234``. + +**Expected Result:** +RESPONSE has ``session_id == 0x1234``. + +TC8-MSG-006 — Wrong Interface Version +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_074 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_msg_006_wrong_interface_version`` +:Requirement: ``comp_req__tc8_conformance__msg_error_codes`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify error handling for requests with wrong interface version. + +**Stimuli:** +Send REQUEST with ``interface_version = 0xFF``. + +**Expected Result:** +``E_WRONG_INTERFACE_VERSION`` (0x08). + +TC8-MSG-007 — Malformed Message Handling +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_054, 055, 058, 078 +:Test Module: ``test_someip_message_format.py`` +:Test Functions: + - ``test_tc8_msg_007_truncated_message_no_crash`` + - ``test_tc8_msg_007_wrong_protocol_version_no_crash`` + - ``test_tc8_msg_007_oversized_length_field_no_crash`` +:Requirement: ``comp_req__tc8_conformance__msg_malformed`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that DUT does not crash when receiving malformed messages. + +**Stimuli:** + +- Truncated message (7 bytes, below 8-byte minimum). +- Message with ``protocol_version = 0xFF``. +- Message with length field claiming 0x7FF3 bytes (actual payload = 16 bytes). + +**Expected Result:** +DUT process remains alive (``poll() is None``) after each malformed message. + +TC8-MSG-008 — Client ID Echo +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_03 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_msg_008_client_id_echo`` +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify RESPONSE echoes the REQUEST client ID. + +**Stimuli:** +Send REQUEST with ``client_id = 0x0011``. + +**Expected Result:** +RESPONSE has ``client_id == 0x0011``. + +Event Notification Tests +------------------------ + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +TC8-EVT-001 — Notification Message Type +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.5 — SOMEIPSRV_BASIC_03; §5.1.6 — SOMEIP_ETS_147 +:Test Module: ``test_event_notification.py`` +:Test Function: ``test_tc8_evt_001_notification_message_type`` +:Requirement: ``comp_req__tc8_conformance__evt_subscription`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that event notifications have ``message_type = NOTIFICATION (0x02)``. + +**Preconditions:** + +- DUT offering service, subscription acknowledged. + +**Stimuli:** +Subscribe to eventgroup ``0x4455`` and wait for notification. + +**Expected Result:** +Notification received with ``message_type == 0x02``. + +TC8-EVT-002 — Correct Event ID +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.5 — SOMEIPSRV_BASIC_03 +:Test Module: ``test_event_notification.py`` +:Test Function: ``test_tc8_evt_002_correct_event_id`` +:Requirement: ``comp_req__tc8_conformance__evt_subscription`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that notification ``method_id`` field carries the correct event ID. + +**Stimuli:** +Subscribe and capture notification. + +**Expected Result:** +``method_id == 0x0777`` (configured event ID). + +TC8-EVT-003 — Notification Only to Subscriber +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_147 +:Test Module: ``test_event_notification.py`` +:Test Function: ``test_tc8_evt_003_notification_only_to_subscriber`` +:Requirement: ``comp_req__tc8_conformance__evt_subscription`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that only the subscribed endpoint receives notifications. + +**Stimuli:** +Open two sockets: subscribe on one, leave the other unsubscribed. + +**Expected Result:** +Subscribed socket receives notification; unsubscribed socket receives nothing. + +TC8-EVT-004 — No Notification Before Subscribe +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_147 (pre-subscribe) +:Test Module: ``test_event_notification.py`` +:Test Function: ``test_tc8_evt_004_no_notification_before_subscribe`` +:Requirement: ``comp_req__tc8_conformance__evt_subscription`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that no notifications arrive before subscribing. + +**Stimuli:** +Open a socket without subscribing, listen for 3 seconds. + +**Expected Result:** +No notifications received. + +TC8-EVT-005 — Multicast Notification Delivery +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_150 +:Test Module: ``test_event_notification.py`` +:Test Function: ``test_tc8_evt_005_multicast_notification_delivery`` +:Requirement: ``comp_req__tc8_conformance__evt_subscription`` +:DUT Config: ``tc8_someipd_service.json`` +:Marker: ``@pytest.mark.network`` (requires non-loopback interface) + +**Purpose:** +Verify that notifications for a multicast eventgroup arrive on the +multicast address. + +**Preconditions:** + +- Multicast group ``239.0.0.1:40490`` joinable. +- Eventgroup ``0x4465`` configured with multicast. + +**Stimuli:** +Subscribe to eventgroup ``0x4465`` and listen on multicast socket. + +**Expected Result:** +NOTIFICATION received on ``239.0.0.1:40490``. + +TC8-EVT-006 — StopSubscribe Ceases Notifications +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_108 +:Test Module: ``test_event_notification.py`` +:Test Function: ``test_tc8_evt_006_stop_subscribe_ceases_notifications`` +:Requirement: ``comp_req__tc8_conformance__evt_subscription`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that notifications stop after StopSubscribeEventgroup. + +**Preconditions:** + +- Active subscription with at least one notification received. + +**Stimuli:** +Send SubscribeEventgroup with TTL = 0. + +**Expected Result:** +No notifications received within 4 seconds after StopSubscribe. + +TC8-EVT-007 — Field Notifies Only on Value Change +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_16 +:Test Module: ``test_event_notification.py`` +:Test Function: ``TestEventNotification::test_rpc_16_field_notifies_only_on_change`` +:Requirement: ``comp_req__tc8_conformance__fld_getter_setter`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that a field event notification is sent only when the field value changes, +not on every cyclic trigger. + +**Preconditions:** + +- DUT offering service with a field configured for on-change notification. +- Active subscription acknowledged. + +**Stimuli:** +SET field to value A; observe notifications; SET field to the same value A again; +observe again. + +**Expected Result:** +Notification sent after first SET (value changed from initial); no duplicate +notification sent when SET issues the same value a second time. + +TC8-EVT-008 — Cyclic Notification Rate +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_15 +:Test Module: ``test_event_notification.py`` +:Test Function: ``TestEventNotificationFormat::test_rpc_15_cyclic_notification_rate`` +:Requirement: ``comp_req__tc8_conformance__evt_subscription`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that cyclic event notifications arrive at the configured cycle period +(``update-cycle: 500`` ms). + +**Preconditions:** + +- DUT offering service, subscription acknowledged. +- Event 0x0777 configured with ``update-cycle: 500`` ms. + +**Stimuli:** +Subscribe to eventgroup ``0x4455``, collect 4 notification timestamps. + +**Expected Result:** +Inter-notification intervals are within [200 ms, 1200 ms] — a 2.4× tolerance +band accounting for OS jitter and initial-value notification. + +Field Conformance Tests +----------------------- + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +TC8-FLD-001 — Initial Notification on Subscribe +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_121 +:Test Module: ``test_field_conformance.py`` +:Test Function: ``test_tc8_fld_001_initial_notification_on_subscribe`` +:Requirement: ``comp_req__tc8_conformance__fld_initial_value`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that subscribing to a field eventgroup (``is_field: true``) +triggers an immediate NOTIFICATION with the cached value. + +**Preconditions:** + +- DUT has sent at least one ``notify()`` so vsomeip has a cached value. + +**Stimuli:** +Subscribe to eventgroup ``0x4455``. + +**Expected Result:** +NOTIFICATION received promptly after subscription acknowledgment. + +TC8-FLD-002 — Initial Value Timing +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.6 — SOMEIP_ETS_121 +:Test Module: ``test_field_conformance.py`` +:Test Function: ``test_tc8_fld_002_is_field_sends_initial_value_within_one_second`` +:Requirement: ``comp_req__tc8_conformance__fld_initial_value`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that the initial field notification arrives within 1 second of +subscribe ACK (contrasting with non-field events that only notify on +the next cycle). + +**Stimuli:** +Subscribe and measure time to first notification. + +**Expected Result:** +Notification received within 1 second. + +TC8-FLD-003 — Field Getter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_03; §5.1.6 — SOMEIP_ETS_166 +:Test Module: ``test_field_conformance.py`` +:Test Function: ``test_tc8_fld_003_getter_returns_current_value`` +:Requirement: ``comp_req__tc8_conformance__fld_get_set`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that a GET request (method ``0x0001``) returns the current +field value. + +**Stimuli:** +Send REQUEST to method ``0x0001``. + +**Expected Result:** +RESPONSE with ``return_code = E_OK`` and non-empty payload. + +TC8-FLD-004 — Field Setter and Notify +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_11; §5.1.6 — SOMEIP_ETS_166 +:Test Module: ``test_field_conformance.py`` +:Test Function: ``test_tc8_fld_004_setter_updates_value_and_notifies`` +:Requirement: ``comp_req__tc8_conformance__fld_get_set`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that a SET request (method ``0x0002``) updates the field value +and notifies all active subscribers with the new value. + +**Preconditions:** + +- Active subscription to eventgroup ``0x4455``. +- Initial field notification drained. + +**Stimuli:** +Send REQUEST to method ``0x0002`` with payload ``0xCAFE``. + +**Expected Result:** + +- RESPONSE with ``return_code = E_OK``. +- NOTIFICATION received with payload matching ``0xCAFE``. + +TCP Transport Binding Tests +---------------------------- + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +TC8-TCP-001 — TCP Request/Response +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIPSRV_RPC_01 §5.1.5.7 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_rpc_01_tcp_request_response`` +:Requirement ID: ``comp_req__tc8_conformance__tcp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose:** +Verify that the DUT accepts a TCP REQUEST and returns a RESPONSE on +the same TCP connection. + +**Preconditions:** + +- DUT started with ``--tc8-standalone`` flag. +- Service ``0x1234`` offered and SD OfferService received. + +**Stimuli:** +Open TCP connection to service port 30510; send a SOME/IP REQUEST +(method ``0x0421``, ``message_type=0x00``). + +**Expected Result:** +DUT sends SOME/IP RESPONSE (``message_type=0x80``, +``return_code=E_OK=0x00``) on the same TCP connection. + +TC8-TCP-002 — TCP Session ID Echo +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIPSRV_RPC_01 §5.1.5.7 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_rpc_01_tcp_session_id_echo`` +:Requirement ID: ``comp_req__tc8_conformance__tcp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose:** +Verify that the DUT echoes the ``session_id`` from a TCP REQUEST in +the TCP RESPONSE. + +**Preconditions:** + +- DUT started, service offered. + +**Stimuli:** +Send TCP REQUEST with a known ``session_id`` value. + +**Expected Result:** +TCP RESPONSE carries the same ``session_id`` value. + +TC8-TCP-003 — TCP Client ID Echo +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIPSRV_RPC_01 §5.1.5.7 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_rpc_01_tcp_client_id_echo`` +:Requirement ID: ``comp_req__tc8_conformance__tcp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose:** +Verify that the DUT echoes the ``client_id`` from a TCP REQUEST in +the TCP RESPONSE. + +**Preconditions:** + +- DUT started, service offered. + +**Stimuli:** +Send TCP REQUEST with a known ``client_id`` value. + +**Expected Result:** +TCP RESPONSE carries the same ``client_id`` value. + +TC8-TCP-004 — Multiple Methods over Single TCP Connection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIPSRV_RPC_02 §5.1.5.7 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_rpc_02_tcp_multiple_methods_single_connection`` +:Requirement ID: ``comp_req__tc8_conformance__tcp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose:** +Verify that the DUT handles multiple SOME/IP method calls over a +single persistent TCP connection. + +**Preconditions:** + +- DUT started, service offered. + +**Stimuli:** +Open one TCP connection; send two consecutive REQUESTs for different +methods. + +**Expected Result:** +Both REQUESTs receive valid RESPONSEs on the same TCP connection +without reconnection. + +TC8-TCP-005 — TCP Endpoint Advertised in SD Offer +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIPSRV_OPTIONS_15 §5.1.5.2 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``test_tc8_sd_options_15_tcp_endpoint_advertised`` +:Requirement ID: ``comp_req__tc8_conformance__tcp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose:** +Verify that the DUT includes a TCP IPv4EndpointOption +(``L4-Proto=0x06``) in SD OfferService messages. + +**Preconditions:** + +- DUT starting up; multicast socket opened before DUT launch to + capture initial SD traffic. + +**Stimuli:** +Capture SD multicast traffic on DUT startup. + +**Expected Result:** +SD OfferService contains an IPv4EndpointOption with +``L4-Proto=0x06`` (TCP) in addition to the UDP endpoint option. + +TC8-TCP-006 — TCP Field Getter (partial SOMEIPSRV_RPC_17) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIPSRV_RPC_17 §5.1.5.7 (partial coverage — see note) +:Test Module: ``test_field_conformance.py`` +:Test Function: ``test_tc8_rpc_17_tcp_field_getter`` +:Requirement ID: ``comp_req__tc8_conformance__tcp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose:** +Verify that the DUT responds to a field GET request (method ``0x0001``) +over TCP with ``E_OK`` and the current field value. + +**Preconditions:** + +- DUT started, service offered. + +**Stimuli:** +Open TCP connection to port 30510; send SOME/IP REQUEST for method +``0x0001``. + +**Expected Result:** +DUT returns RESPONSE with ``return_code=E_OK`` and payload containing +the current field value. + +TC8-TCP-007 — TCP Field Setter (partial SOMEIPSRV_RPC_17) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIPSRV_RPC_17 §5.1.5.7 (partial coverage — see note) +:Test Module: ``test_field_conformance.py`` +:Test Function: ``test_tc8_rpc_17_tcp_field_setter`` +:Requirement ID: ``comp_req__tc8_conformance__tcp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose:** +Verify that the DUT processes a field SET request (method ``0x0002``) +over TCP and updates the field value. + +**Preconditions:** + +- DUT started, service offered. + +**Stimuli:** +Open TCP connection; send SET REQUEST with new field value; then send +GET REQUEST to confirm. + +**Expected Result:** +SET RESPONSE with ``E_OK``; subsequent GET confirms the updated value. + +TC8-TCP-008 — TCP Event Notification Delivery (partial SOMEIPSRV_RPC_17) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIPSRV_RPC_17 §5.1.5.7 (partial coverage — see note) +:Test Module: ``test_event_notification.py`` +:Test Function: ``test_tc8_rpc_17_tcp_event_notification_delivery`` +:Requirement ID: ``comp_req__tc8_conformance__tcp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose:** +Verify that the DUT delivers SOME/IP NOTIFICATION messages over TCP +to a subscribed client. + +**Preconditions:** + +- DUT started, service offered. + +**Stimuli:** +Subscribe to eventgroup ``0x4475`` (TCP); open TCP connection to port +30510. + +**Expected Result:** +DUT sends NOTIFICATION messages (``message_type=0x02``, +``event_id=0x0778``) over the TCP connection. + +TC8-TCP-009: Unaligned SOME/IP Messages over TCP (SOMEIP_ETS_068) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIP_ETS_068 (PRS_SOMEIP_00142, PRS_SOMEIP_00569) +:Test Module: ``test_someip_message_format`` +:Test Function: ``test_tc8_ets_068_unaligned_someip_messages_over_tcp`` +:Requirement ID: ``comp_req__tc8_conformance__tcp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose** + Verify that someipd parses a TCP byte stream containing multiple SOME/IP messages + packed into a single TCP segment, including the case where one message starts at + a non-4-byte-aligned byte offset. + +**Preconditions** + - someipd running in ``--tc8-standalone`` mode with ``tc8_someipd_service.json``. + - TCP port ``DUT_RELIABLE_PORT`` (30510) accepting connections. + +**Stimuli** + Connect via TCP; send three concatenated SOME/IP REQUEST messages (session IDs + 0x0071, 0x0072, 0x0073) as a **single** ``sendall()`` call. Message sizes are + 16 / 18 / 16 bytes so the third starts at offset 34 (not 4-byte aligned). + +**Expected Result** + Receive three SOME/IP RESPONSE messages with ``message_type = RESPONSE (0x80)``, + ``service_id = 0x1234``, and session IDs ``{0x0071, 0x0072, 0x0073}`` (any order). + +.. note:: + + **SOMEIPSRV_RPC_17 partial coverage:** TC8-TCP-006, TC8-TCP-007, + and TC8-TCP-008 verify TCP transport for field GET/SET and event + notification operations using a single service instance. The full + SOMEIPSRV_RPC_17 requirement (each service instance on a separate + TCP connection) is not covered by the current implementation. The + multi-instance TCP scenario is a known gap that will be addressed + when multi-instance vsomeip configurations are available. + +TC8-UDP-001: Unaligned SOME/IP Messages over UDP (SOMEIP_ETS_069) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: SOMEIP_ETS_069 (PRS_SOMEIP_00142, PRS_SOMEIP_00569) +:Test Module: ``test_someip_message_format`` +:Test Function: ``test_tc8_ets_069_unaligned_someip_messages_over_udp`` +:Requirement ID: ``comp_req__tc8_conformance__udp_transport`` +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` + +**Purpose** + Verify that someipd parses a UDP datagram containing multiple SOME/IP messages + where one message starts at a non-4-byte-aligned byte offset within the datagram. + +**Preconditions** + - someipd running in ``--tc8-standalone`` mode with ``tc8_someipd_service.json``. + - UDP port ``DUT_UNRELIABLE_PORT`` (30502 in ``tc8_message_format`` target) accepting requests. + +**Stimuli** + Create a UDP socket bound to an ephemeral port; send three concatenated SOME/IP + REQUEST messages (session IDs 0x0081, 0x0082, 0x0083) as a **single** ``sendto()`` + call. Message sizes are 16 / 18 / 16 bytes so the third starts at offset 34 + (not 4-byte aligned). + +**Expected Result** + Receive three SOME/IP RESPONSE messages with ``message_type = RESPONSE (0x80)``, + ``service_id = 0x1234``, and session IDs ``{0x0081, 0x0082, 0x0083}`` (any order). + Each response arrives as a separate UDP datagram. + +Multi-service and Multi-instance Tests +--------------------------------------- + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_multi.json`` + +TC8-MULTI-001 — Multi-service Config Loads and Primary Service Offered +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_13 +:Test Module: ``test_multi_service`` +:Test Function: ``TestMultiServiceInstanceRouting::test_rpc_13_multi_service_config_loads_and_primary_service_offered`` +:Requirement: ``comp_req__tc8_conformance__multi_service`` +:DUT Config: ``tc8_someipd_multi.json`` + +**Purpose** + Verify that ``someipd`` accepts a vsomeip configuration that declares two + service entries and that the primary service (0x1234/0x5678) is correctly + offered via SD OfferService after startup. + +**Preconditions** + + - ``someipd`` started with ``--tc8-standalone`` and ``tc8_someipd_multi.json`` + - ``tc8_someipd_multi.json`` declares two service entries (0x1234/0x5678 and + 0x5678/0x0001) + - Multicast route available (224.0.0.0/4) + +**Stimuli** + Passive observation of SD OfferService multicast messages. + +**Expected Result** + - The ``someipd`` process remains alive after startup (multi-service config + parsed without crash) + - An SD OfferService entry for service_id=0x1234, instance_id=0x5678 is + received within 10 seconds + - The OfferService entry has TTL > 0 + +TC8-MULTI-002 — Service Instance UDP Port Advertisement and Isolation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_14 +:Test Module: ``test_multi_service`` +:Test Functions: ``TestMultiServiceInstanceRouting::test_rpc_14_service_a_advertises_configured_udp_port``, + ``TestMultiServiceInstanceRouting::test_rpc_14_no_unexpected_service_ids_in_offers`` +:Requirement: ``comp_req__tc8_conformance__multi_service`` +:DUT Config: ``tc8_someipd_multi.json`` + +**Purpose** + Verify that the offered service advertises the UDP port from its ``unreliable`` + config field (TC8_SVC_PORT) in the SD IPv4EndpointOption, and that no + unexpected service IDs appear in SD traffic. + +**Preconditions** + + - Same as TC8-MULTI-001. + +**Stimuli** + Passive observation of SD OfferService for a 6-second window; extraction of + IPv4EndpointOption from each OfferService entry. + +**Expected Result** + - Service 0x1234 OfferService carries an IPv4 UDP endpoint option with + ``port == DUT_UNRELIABLE_PORT`` (TC8_SVC_PORT = 30512) + - All observed service IDs are in the set {0x1234, 0x5678} (configured services) + - No phantom service IDs are present + +SD Format and Options Compliance Tests +--------------------------------------- + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json`` +:Test Module: ``test_sd_format_compliance`` + +TC8-SDF-001 — SD SOME/IP Header: Client ID = 0x0000 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_01 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsOfferService::test_format_01_client_id_is_zero`` + +**Purpose:** +Verify the Client-ID field in SOME/IP SD OfferService messages is always 0x0000. + +**Preconditions:** +``someipd`` started in standalone mode; multicast capture socket open before launch. + +**Stimulus:** +Passive capture of the initial multicast OfferService. + +**Expected Result:** +``sd_hdr.client_id == 0x0000``. + +TC8-SDF-002 — SD SOME/IP Header: Session ID non-zero +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_02 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsOfferService::test_format_02_session_id_is_nonzero_and_in_range`` + +**Purpose:** +Verify the SD Session-ID field is non-zero (never 0x0000) and fits in 16 bits. + +**Stimulus:** +Passive capture of the multicast OfferService. + +**Expected Result:** +``sd_hdr.session_id != 0x0000`` and ``sd_hdr.session_id <= 0xFFFF``. + +TC8-SDF-003 — SD SOME/IP Header: Interface Version = 0x01 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_04 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsOfferService::test_format_04_interface_version_is_one`` + +**Purpose:** +Verify the SOME/IP interface_version field in SD messages equals 0x01. + +**Stimulus:** +Passive capture of the multicast OfferService. + +**Expected Result:** +``sd_hdr.interface_version == 0x01``. + +TC8-SDF-004 — SD SOME/IP Header: Message Type = NOTIFICATION +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_05 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsOfferService::test_format_05_message_type_is_notification`` + +**Purpose:** +Verify the SOME/IP message_type field in SD messages equals 0x02 (NOTIFICATION). + +**Stimulus:** +Passive capture of the multicast OfferService. + +**Expected Result:** +``sd_hdr.message_type == SOMEIPMessageType.NOTIFICATION``. + +TC8-SDF-005 — SD SOME/IP Header: Return Code = E_OK +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_06 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsOfferService::test_format_06_return_code_is_e_ok`` + +**Purpose:** +Verify the SOME/IP return_code field in SD messages equals 0x00 (E_OK). + +**Stimulus:** +Passive capture of the multicast OfferService. + +**Expected Result:** +``sd_hdr.return_code == SOMEIPReturnCode.E_OK``. + +TC8-SDF-006 — SD Flags: Reserved Bits are Zero +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_09 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsOfferService::test_format_09_sd_flags_reserved_bits_are_zero`` + +**Purpose:** +Verify that bits 5-0 of the SD flags byte (reserved/undefined) are all zero. + +**Stimulus:** +Passive capture of the multicast OfferService. + +**Expected Result:** +``sd_hdr.flags_unknown == 0``. + +TC8-SDF-007 — SD Entry: Reserved Byte is Zero +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_10 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsOfferService::test_format_10_sd_entry_reserved_bytes_are_zero`` + +**Purpose:** +Verify that byte [2] (index_second_option_run, reserved for Type-1 entries) in the +16-byte OfferService SD entry is zero. + +**Stimulus:** +Passive capture and raw byte inspection of the OfferService entry. + +**Expected Result:** +``entry_bytes[2] == 0``. + +TC8-SDF-008 — SD Entry: Length is 16 Bytes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_11 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdOfferEntryFields::test_format_11_entry_is_16_bytes`` + +**Purpose:** +Verify that each SD entry serialises to exactly 16 bytes. + +**Stimulus:** +Passive capture; entry built and measured. + +**Expected Result:** +``len(entry.build()) == 16``. + +TC8-SDF-009 — SD Entry: First Option Run Index is Zero +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_12 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdOfferEntryFields::test_format_12_first_option_run_index_is_zero`` + +**Purpose:** +Verify that byte [1] (option_index_1) in the OfferService entry is 0x00. + +**Stimulus:** +Passive capture; raw byte inspection. + +**Expected Result:** +``entry_bytes[1] == 0``. + +TC8-SDF-010 — SD Entry: num_options_1 Matches Resolved Options +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_13 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdOfferEntryFields::test_format_13_num_options_matches_options_list`` + +**Purpose:** +Verify that the num_options_1 counter field equals the actual number of options +associated with the entry. + +**Stimulus:** +Passive capture; compare raw entry counter with resolved options list length. + +**Expected Result:** +``raw_entry.num_options_1 == len(entry.options_1)``. + +TC8-SDF-011 — SD Entry: Instance ID Matches Config +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_15 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdOfferEntryFields::test_format_15_instance_id_matches_config`` + +**Purpose:** +Verify that the OfferService entry instance_id matches the configured value (0x5678). + +**Stimulus:** +Passive capture. + +**Expected Result:** +``entry.instance_id == 0x5678``. + +TC8-SDF-012 — SD Entry: Major Version Matches Config +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_16 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdOfferEntryFields::test_format_16_major_version_matches_config`` + +**Purpose:** +Verify that the OfferService entry major_version matches the configured value (0x00). + +**Stimulus:** +Passive capture. + +**Expected Result:** +``entry.major_version == 0x00``. + +TC8-SDF-013 — SD Entry: Minor Version Matches Config +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_18 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdOfferEntryFields::test_format_18_minor_version_matches_config`` + +**Purpose:** +Verify that the OfferService entry minor_version matches the configured value (0x00000000). + +**Stimulus:** +Passive capture. + +**Expected Result:** +``entry.service_minor_version == 0x00000000``. + +TC8-SDF-014 — SubscribeAck Entry Type = 0x06 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_19 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_19_ack_entry_type_is_subscribe_ack`` + +**Purpose:** +Verify that the SubscribeEventgroupAck SD entry type equals 0x07 (SubscribeAck). + +**Preconditions:** +``someipd`` offering eventgroup 0x4455. + +**Stimulus:** +Send SubscribeEventgroup and capture the Ack. + +**Expected Result:** +``ack.sd_type == SOMEIPSDEntryType.SubscribeAck``. + +TC8-SDF-015 — SubscribeAck Entry: 16 Bytes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_20 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_20_ack_entry_is_16_bytes`` + +**Purpose:** +Verify that the SubscribeEventgroupAck entry serialises to exactly 16 bytes. + +**Stimulus:** +Send SubscribeEventgroup; capture Ack; measure serialised length. + +**Expected Result:** +``len(ack.build()) == 16``. + +TC8-SDF-016 — SubscribeAck: Option Run Index Correct +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_21 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_21_ack_option_run_index_is_zero_when_no_options`` + +**Purpose:** +Verify that the SubscribeAck option run index is 0x00 when no options are attached, +or a valid index (< 16) when options are present. + +**Stimulus:** +Send SubscribeEventgroup for UDP unicast eventgroup (no multicast option expected). + +**Expected Result:** +``option_index_1 == 0`` when ``num_options_1 == 0``. + +TC8-SDF-017 — SubscribeAck: Service ID Matches +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_23 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_23_ack_service_id_matches_config`` + +**Purpose:** +Verify that the SubscribeAck entry carries the same service_id as the subscribe request. + +**Stimulus:** +Send SubscribeEventgroup for service 0x1234. + +**Expected Result:** +``ack.service_id == 0x1234``. + +TC8-SDF-018 — SubscribeAck: Instance ID Matches +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_24 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_24_ack_instance_id_matches_config`` + +**Purpose:** +Verify that the SubscribeAck entry carries the same instance_id as the subscribe request. + +**Stimulus:** +Send SubscribeEventgroup for instance 0x5678. + +**Expected Result:** +``ack.instance_id == 0x5678``. + +TC8-SDF-019 — SubscribeAck: Major Version Matches +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_25 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_25_ack_major_version_matches_config`` + +**Purpose:** +Verify that the SubscribeAck entry major_version matches the service definition. + +**Stimulus:** +Send SubscribeEventgroup with major_version = 0x00. + +**Expected Result:** +``ack.major_version == 0x00``. + +TC8-SDF-020 — SubscribeAck: TTL > 0 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_26 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_26_ack_ttl_is_nonzero`` + +**Purpose:** +Verify that a positive SubscribeEventgroupAck has TTL > 0 (TTL = 0 means Nack). + +**Stimulus:** +Send valid SubscribeEventgroup. + +**Expected Result:** +``ack.ttl > 0``. + +TC8-SDF-021 — SubscribeAck: Reserved Field = 0 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_27 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_27_ack_reserved_field_is_zero`` + +**Purpose:** +Verify that the high 16 bits of the SubscribeAck minver_or_counter field (the reserved +counter portion) are all zero. + +**Stimulus:** +Send SubscribeEventgroup; inspect raw ack entry fields. + +**Expected Result:** +``(ack.minver_or_counter >> 16) & 0xFFFF == 0``. + +TC8-SDF-022 — SubscribeAck: Eventgroup ID Matches +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_28 +:Requirement: ``comp_req__tc8_conformance__sd_format_fields`` +:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_28_ack_eventgroup_id_matches_subscribe`` + +**Purpose:** +Verify that the SubscribeAck carries the same eventgroup_id as the subscribe request. + +**Stimulus:** +Send SubscribeEventgroup for eventgroup 0x4455. + +**Expected Result:** +``(ack.minver_or_counter & 0xFFFF) == 0x4455``. + +TC8-SDF-023 — Endpoint Option: Length = 0x0009 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_01 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Test Function: ``TestSdOptionsEndpoint::test_options_01_endpoint_option_length_is_nine`` + +**Purpose:** +Verify that the IPv4EndpointOption length field in SD OfferService messages equals 0x0009. + +**Stimulus:** +Passive capture; extract first IPv4EndpointOption from the OfferService entry; read raw bytes. + +**Expected Result:** +``length_field == 0x0009``. + +TC8-SDF-024 — Endpoint Option: Type = 0x04 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_02 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Test Function: ``TestSdOptionsEndpoint::test_options_02_endpoint_option_type_is_0x04`` + +**Purpose:** +Verify that the IPv4EndpointOption type byte equals 0x04. + +**Stimulus:** +Passive capture; inspect raw option byte [2]. + +**Expected Result:** +``type_byte == 0x04``. + +TC8-SDF-025 — Endpoint Option: Reserved Byte After Type = 0x00 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_03 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Test Function: ``TestSdOptionsEndpoint::test_options_03_endpoint_option_reserved_after_type_is_zero`` + +**Purpose:** +Verify that the reserved byte at option offset [3] (after the type byte) equals 0x00. + +**Stimulus:** +Passive capture; inspect raw option byte [3]. + +**Expected Result:** +``reserved_byte == 0x00``. + +TC8-SDF-026 — Endpoint Option: Reserved Before Protocol = 0x00 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_05 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Test Function: ``TestSdOptionsEndpoint::test_options_05_endpoint_option_reserved_before_protocol_is_zero`` + +**Purpose:** +Verify that the reserved byte at option offset [8] (before the L4 protocol byte) equals 0x00. + +**Stimulus:** +Passive capture; inspect raw option byte [8]. + +**Expected Result:** +``reserved_byte == 0x00``. + +TC8-SDF-027 — Endpoint Option: L4 Protocol = 0x11 (UDP) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_06 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Test Function: ``TestSdOptionsEndpoint::test_options_06_endpoint_option_protocol_is_udp`` + +**Purpose:** +Verify that the IPv4EndpointOption L4 protocol field equals 0x11 (UDP). + +**Stimulus:** +Passive capture; read ``opt.l4proto``. + +**Expected Result:** +``opt.l4proto == L4Protocols.UDP``. + +TC8-SDF-028 — Multicast Option: Length = 0x0009 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_08 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Marker: ``@pytest.mark.network`` (requires non-loopback interface) +:Test Function: ``TestSdOptionsMulticast::test_options_08_multicast_option_length_is_nine`` + +**Purpose:** +Verify that the IPv4MulticastOption in SubscribeEventgroupAck has length field 0x0009. + +**Preconditions:** +Non-loopback interface (``TC8_TESTER_IP`` set); eventgroup 0x4465 configured with multicast address. + +**Stimulus:** +Send SubscribeEventgroup for eventgroup 0x4465; capture Ack and extract multicast option. + +**Expected Result:** +``length_field == 0x0009``. + +TC8-SDF-029 — Multicast Option: Type = 0x14 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_09 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Marker: ``@pytest.mark.network`` (requires non-loopback interface) +:Test Function: ``TestSdOptionsMulticast::test_options_09_multicast_option_type_is_0x14`` + +**Purpose:** +Verify that the IPv4MulticastOption type byte equals 0x14 (decimal 20). + +**Stimulus:** +Same as TC8-SDF-028. + +**Expected Result:** +``type_byte == 0x14``. + +TC8-SDF-030 — Multicast Option: Reserved = 0x00 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_10 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Marker: ``@pytest.mark.network`` (requires non-loopback interface) +:Test Function: ``TestSdOptionsMulticast::test_options_10_multicast_option_reserved_is_zero`` + +**Purpose:** +Verify that the reserved byte at offset [3] of the IPv4MulticastOption equals 0x00. + +**Stimulus:** +Same as TC8-SDF-028. + +**Expected Result:** +``reserved_byte == 0x00``. + +TC8-SDF-031 — Multicast Option: Address Matches Config +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_11 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Marker: ``@pytest.mark.network`` (requires non-loopback interface) +:Test Function: ``TestSdOptionsMulticast::test_options_11_multicast_address_matches_config`` + +**Purpose:** +Verify that the IPv4MulticastOption address field equals the configured multicast +group address (``239.0.0.1``). + +**Stimulus:** +Same as TC8-SDF-028. + +**Expected Result:** +``opt.address == IPv4Address("239.0.0.1")``. + +TC8-SDF-032 — Multicast Option: Reserved Before Port = 0x00 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_12 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Marker: ``@pytest.mark.network`` (requires non-loopback interface) +:Test Function: ``TestSdOptionsMulticast::test_options_12_multicast_option_reserved_before_port_is_zero`` + +**Purpose:** +Verify that the reserved byte at option offset [8] (before the port field) equals 0x00. + +**Stimulus:** +Same as TC8-SDF-028. + +**Expected Result:** +``reserved_byte == 0x00``. + +TC8-SDF-033 — Multicast Option: L4 Protocol = 0x11 (UDP) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_13 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Marker: ``@pytest.mark.network`` (requires non-loopback interface) +:Test Function: ``TestSdOptionsMulticast::test_options_13_multicast_option_protocol_is_udp`` + +**Purpose:** +Verify that the IPv4MulticastOption L4 protocol field equals 0x11 (UDP). + +**Stimulus:** +Same as TC8-SDF-028. + +**Expected Result:** +``opt.l4proto == L4Protocols.UDP``. + +TC8-SDF-034 — Multicast Option: Port Matches Config +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_14 +:Requirement: ``comp_req__tc8_conformance__sd_options_fields`` +:Marker: ``@pytest.mark.network`` (requires non-loopback interface) +:Test Function: ``TestSdOptionsMulticast::test_options_14_multicast_port_matches_config`` + +**Purpose:** +Verify that the IPv4MulticastOption port field equals the configured multicast port +(40490). + +**Stimulus:** +Same as TC8-SDF-028. + +**Expected Result:** +``opt.port == 40490``. + +TC8-SDF-035 — StopSubscribeEventgroup Entry Wire Format +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_12 +:Requirement: ``comp_req__tc8_conformance__sd_stop_sub_fmt`` +:Test Function: ``TestSdStopSubscribeFormat::test_sd_message_12_stop_subscribe_entry_format`` + +**Purpose:** +Verify that a StopSubscribeEventgroup SD entry has entry type byte ``0x06`` +and TTL field (bytes 9–11) equal to ``0x000000`` at the wire level. + +**Preconditions:** +None — pure byte-level format check, no DUT interaction. + +**Stimulus:** +Inline construction of a 16-byte SD entry with TTL=0. + +**Expected Result:** +``entry[0] == 0x06`` and ``entry[9:12] == b'\x00\x00\x00'``. + +SD Entry Semantics Tests +------------------------- + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json`` +:Test Module: ``test_service_discovery`` + +TC8-SDM-001 — FindService Wildcard Instance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_01 +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:Test Function: ``TestSDVersionMatching::test_sd_message_01_instance_wildcard`` + +**Purpose:** +Verify that a FindService with instance_id = 0xFFFF (wildcard) elicits an OfferService +for the configured instance. + +**Stimulus:** +Send FindService with instance_id = 0xFFFF. + +**Expected Result:** +OfferService received with ``instance_id == 0x5678``. + +TC8-SDM-002 — FindService Specific Instance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_02 +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:Test Function: ``TestSDVersionMatching::test_sd_message_02_instance_specific`` + +**Purpose:** +Verify that a FindService with the exact instance_id = 0x5678 elicits an OfferService. + +**Stimulus:** +Send FindService with instance_id = 0x5678. + +**Expected Result:** +OfferService received for service_id = 0x1234, instance_id = 0x5678. + +TC8-SDM-003 — FindService Wildcard Major Version +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_03 +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:Test Function: ``TestSDVersionMatching::test_sd_message_03_major_version_wildcard`` + +**Purpose:** +Verify that a FindService with major_version = 0xFF (any) elicits an OfferService. + +**Stimulus:** +Send FindService with major_version = 0xFF. + +**Expected Result:** +OfferService received for the configured service. + +TC8-SDM-004 — FindService Specific Major Version +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_04 +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:Test Function: ``TestSDVersionMatching::test_sd_message_04_major_version_specific`` + +**Purpose:** +Verify that a FindService with the exact major_version = 0x00 elicits an OfferService. + +**Stimulus:** +Send FindService with major_version = 0x00. + +**Expected Result:** +OfferService received for the configured service. + +TC8-SDM-005 — FindService Wildcard Minor Version +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_05 +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:Test Function: ``TestSDVersionMatching::test_sd_message_05_minor_version_wildcard`` + +**Purpose:** +Verify that a FindService with minor_version = 0xFFFFFFFF (wildcard) elicits an +OfferService. + +**Stimulus:** +Send FindService with minor_version = 0xFFFFFFFF. + +**Expected Result:** +OfferService received for the configured service. + +TC8-SDM-006 — FindService Specific Minor Version +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_06 +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:Test Function: ``TestSDVersionMatching::test_sd_message_06_minor_version_specific`` + +**Purpose:** +Verify that a FindService with minor_version = 0x00000000 (exact) elicits an +OfferService. + +**Stimulus:** +Send FindService with minor_version = 0x00000000. + +**Expected Result:** +OfferService received for the configured service. + +TC8-SDM-007 — SubscribeEventgroup: Wrong Major Version Rejected +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_14 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeNAck::test_sd_message_14_wrong_major_version`` + +**Purpose:** +Verify that a SubscribeEventgroup with a non-matching major version is rejected with +NAck (SubscribeEventgroupAck TTL = 0) or silently ignored. + +**Stimulus:** +Send SubscribeEventgroup with major_version = 0x7F (not 0x00). + +**Expected Result:** +No positive SubscribeAck (TTL > 0) received; DUT remains functional. + +TC8-SDM-008 — SubscribeEventgroup: Wrong Service ID Rejected +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_15 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeNAck::test_sd_message_15_wrong_service_id`` + +**Purpose:** +Verify that a SubscribeEventgroup for an unknown service is rejected or ignored. + +**Stimulus:** +Send SubscribeEventgroup with service_id = 0xBEEF. + +**Expected Result:** +No positive SubscribeAck; DUT remains functional. + +TC8-SDM-009 — SubscribeEventgroup: Wrong Instance ID Rejected +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_16 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeNAck::test_sd_message_16_wrong_instance_id`` + +**Purpose:** +Verify that a SubscribeEventgroup for an unknown instance is rejected or ignored. + +**Stimulus:** +Send SubscribeEventgroup with instance_id = 0xBEEF. + +**Expected Result:** +No positive SubscribeAck; DUT remains functional. + +TC8-SDM-010 — SubscribeEventgroup: Unknown Eventgroup Rejected +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_17 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeNAck::test_sd_message_17_unknown_eventgroup_id`` + +**Purpose:** +Verify that a SubscribeEventgroup for an unknown eventgroup ID is rejected with NAck. + +**Stimulus:** +Send SubscribeEventgroup with eventgroup_id = 0xBEEF. + +**Expected Result:** +SubscribeAck with TTL = 0 (NAck) received. + +TC8-SDM-011 — SubscribeEventgroup: TTL = 0 is StopSubscribe +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_18 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeNAck::test_sd_message_18_ttl_zero_stop_subscribe`` + +**Purpose:** +Verify that a SubscribeEventgroup with TTL = 0 is treated as StopSubscribeEventgroup +and does not elicit a positive ACK. + +**Stimulus:** +Subscribe (TTL > 0) then immediately send another SubscribeEventgroup with TTL = 0. + +**Expected Result:** +No positive ACK after the TTL = 0 message. + +TC8-SDM-012 — SubscribeEventgroup: Reserved Bits Set Rejected +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_19 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeNAck::test_sd_message_19_reserved_field_set`` + +**Purpose:** +Verify that a SubscribeEventgroup with reserved flag bits set is rejected with NAck. + +.. note:: + + This test is expected to **FAIL** against vsomeip 3.6.1: the stack sends a + positive ACK instead of NAck. + +**Stimulus:** +Send SubscribeEventgroup with reserved SD flags bits set to 1. + +**Expected Result:** +NAck (SubscribeAck TTL = 0) received; no positive ACK. + +TC8-SDM-013 — FindService Response Timing (Unicast) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_03 +:Requirement: ``comp_req__tc8_conformance__sd_cyclic_timing`` +:Test Function: ``TestSDFindServiceTiming::test_sd_behavior_03_unicast_findservice_timing`` + +**Purpose:** +Verify that a unicast OfferService response to a FindService arrives within the +configured request-response delay. + +**Stimulus:** +Send unicast FindService; measure time to first OfferService response. + +**Expected Result:** +OfferService received within the configured delay window. + +TC8-SDM-014 — FindService Response Timing (Multicast) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_04 +:Requirement: ``comp_req__tc8_conformance__sd_cyclic_timing`` +:Test Function: ``TestSDFindServiceTiming::test_sd_behavior_04_multicast_findservice_timing`` + +**Purpose:** +Verify that an OfferService response to a multicast FindService arrives within the +configured delay. + +**Stimulus:** +Send multicast FindService; measure time to OfferService response. + +**Expected Result:** +OfferService received within the allowed window. + +SD Lifecycle Advanced Tests +---------------------------- + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json`` +:Test Module: ``test_service_discovery`` (TestSDSubscribeLifecycleAdvanced, TestSDFindServiceAdvanced) and ``test_sd_client`` + +TC8-SDLC-001 — Two Simultaneous Subscribes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_088 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_088_two_subscribes_same_session`` + +**Purpose:** +Verify that two SubscribeEventgroup entries in one SD message each receive a +positive ACK. + +**Stimulus:** +Send an SD message containing two SubscribeEventgroup entries. + +**Expected Result:** +Two ACK entries received (one per subscription). + +TC8-SDLC-002 — TTL = 0 as StopSubscribe (No NAck) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_092 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_092_ttl_zero_stop_subscribe_no_nack`` + +**Purpose:** +Verify that a SubscribeEventgroup with TTL = 0 stops the subscription without +triggering a NAck from the DUT. + +**Stimulus:** +Subscribe then send SubscribeEventgroup TTL = 0. + +**Expected Result:** +No NAck (SubscribeAck TTL = 0) received after the StopSubscribe. + +TC8-SDLC-003 — Subscribe Without Prior RPC Call +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_098 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_098_subscribe_accepted_without_prior_rpc`` + +**Purpose:** +Verify that the DUT accepts a SubscribeEventgroup without any prior method call +(no prerequisite RPC interaction required). + +**Stimulus:** +Send SubscribeEventgroup immediately after service discovery, without any RPC. + +**Expected Result:** +Positive SubscribeAck received. + +TC8-SDLC-004 — Non-Standard SD Entry Order Handled +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_107 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_107_find_service_and_subscribe_processed_independently`` + +**Purpose:** +Verify that the DUT correctly handles SD messages where FindService and +SubscribeEventgroup entries appear in a non-standard order. + +**Stimulus:** +Send SD message with FindService followed by SubscribeEventgroup. + +**Expected Result:** +Both entries processed; OfferService and SubscribeAck received. + +TC8-SDLC-005 — Subscribe Endpoint IP Matches Tester +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_120 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_120_subscribe_endpoint_ip_matches_tester`` + +**Purpose:** +Verify that the DUT delivers events to the IP address specified in the subscribe +endpoint option, not the IP address the SD message was sourced from. + +**Stimulus:** +Send SubscribeEventgroup with endpoint option IP = tester_ip. + +**Expected Result:** +Events delivered to the tester_ip address. + +TC8-SDLC-006 — SD Interface Version = 0x01 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_122 +:Requirement: ``comp_req__tc8_conformance__sd_offer_format`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_122_sd_interface_version_is_one`` + +**Purpose:** +Verify that the SD interface_version field in DUT OfferService messages is 0x01. + +**Stimulus:** +Passive capture of SD OfferService. + +**Expected Result:** +``sd_hdr.interface_version == 0x01`` (same as SOMEIPSRV_FORMAT_04, verified in context +of the lifecycle sequence). + +TC8-SDLC-007 — Re-subscribe After StopSubscribe +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_155 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_155_resubscribe_after_stop`` + +**Purpose:** +Verify that a subscription can be re-established after a StopSubscribeEventgroup +(TTL = 0) and that events resume. + +**Stimulus:** +Subscribe, verify events, StopSubscribe, re-Subscribe, verify events resume. + +**Expected Result:** +Events received after re-subscription. + +TC8-SDLC-008 — Session ID Increments per OfferService +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_091 +:Requirement: ``comp_req__tc8_conformance__sd_offer_format`` +:Test Function: ``TestSDFindServiceAdvanced::test_ets_091_session_id_increments`` + +**Purpose:** +Verify that the SD SOME/IP header session_id increments by 1 between consecutive +OfferService messages. + +**Stimulus:** +Capture two consecutive OfferService messages. + +**Expected Result:** +``session_id[n+1] == session_id[n] + 1`` (with wrap from 0xFFFF to 0x0001). + +TC8-SDLC-009 — Initial Event Sent After Subscribe +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_099 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDFindServiceAdvanced::test_ets_099_initial_event_sent_after_subscribe`` + +**Purpose:** +Verify that the DUT sends an initial notification to a new subscriber immediately +after a successful SubscribeEventgroup ACK. + +**Stimulus:** +Subscribe to eventgroup; wait for first notification. + +**Expected Result:** +NOTIFICATION received promptly after ACK. + +TC8-SDLC-010 — Server Does Not Emit FindService +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_100 +:Requirement: ``comp_req__tc8_conformance__sd_offer_format`` +:Test Function: ``TestSDFindServiceAdvanced::test_ets_100_no_findservice_emitted_by_server`` + +**Purpose:** +Verify that the DUT (acting as service provider) does not emit FindService entries +in its SD messages during the main phase. + +**Stimulus:** +Observe SD traffic for 6 seconds; collect all SD entries from the DUT. + +**Expected Result:** +No FindService entries observed in DUT SD traffic. + +TC8-SDLC-011 — StopOfferService Ceases Client Events +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_101 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDFindServiceAdvanced::test_ets_101_stop_offer_ceases_client_events`` + +**Purpose:** +Verify that receipt of a StopOfferService (OfferService TTL = 0) from a server causes +the subscribed client to cease receiving events. + +.. note:: + + This test is currently implemented as ``pytest.skip`` because stopping the DUT's + OfferService from an external tester requires a reverse-direction SD client target. + +**Stimulus:** +N/A (skipped). + +TC8-SDLC-012 — Multicast FindService: Wildcard Versions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_128 +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:Test Function: ``TestSDFindServiceAdvanced::test_ets_128_multicast_findservice_version_wildcard`` + +**Purpose:** +Verify that a multicast FindService with wildcard major and minor versions elicits +an OfferService response. + +**Stimulus:** +Send multicast FindService with major_version = 0xFF, minor_version = 0xFFFFFFFF. + +**Expected Result:** +OfferService received for the configured service. + +TC8-SDLC-013 — Multicast FindService: Unicast Flag Clear +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_130 +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:Test Function: ``TestSDFindServiceAdvanced::test_ets_130_multicast_findservice_unicast_flag_clear`` + +**Purpose:** +Verify that a multicast FindService with the unicast flag cleared (0x00) elicits a +multicast OfferService response (not a unicast one). + +**Stimulus:** +Send multicast FindService with unicast flag = 0. + +**Expected Result:** +Multicast OfferService received. + +TC8-SDLC-014 — Client StopSubscribe Ceases Events +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_084 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Module: ``test_sd_client`` +:Test Function: ``TestSDClientStopSubscribe::test_ets_084_stop_subscribe_ceases_events`` + +**Purpose:** +Verify that after a client sends StopSubscribeEventgroup (TTL = 0) the DUT stops +sending NOTIFICATION messages to that subscriber. + +**Preconditions:** +Active subscription to eventgroup 0x4455; at least one notification received. + +**Stimulus:** +Send SubscribeEventgroup with TTL = 0. + +**Expected Result:** +No notifications received within 4 seconds after StopSubscribe. + +TC8-SDLC-015 — Client Reboot: Flag Set After First Restart +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_081 +:Requirement: ``comp_req__tc8_conformance__sd_reboot`` +:Test Module: ``test_sd_client`` +:Test Function: ``TestSDClientReboot::test_ets_081_reboot_flag_set_after_first_restart`` + +**Purpose:** +Verify that after a DUT restart the first SD message has the reboot flag (bit 7 +of SD flags byte) set and the session_id resets to a small value. + +**Preconditions:** +DUT started, 3 SD messages drained, DUT terminated. + +**Stimulus:** +Restart DUT; capture first post-reboot SD message. + +**Expected Result:** +``sd_hdr.flag_reboot == True`` and ``outer.session_id <= 2``. + +TC8-SDLC-016 — Client Reboot: Flag Set After Second Restart +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_082 +:Requirement: ``comp_req__tc8_conformance__sd_reboot`` +:Test Module: ``test_sd_client`` +:Test Function: ``TestSDClientReboot::test_ets_082_reboot_flag_set_after_second_restart`` + +**Purpose:** +Verify that the reboot flag and session ID reset hold across a second consecutive +restart (not just the first). + +**Stimulus:** +Start → drain → stop → start → drain → stop → start → capture first message. + +**Expected Result:** +``sd_hdr.flag_reboot == True`` and ``outer.session_id <= 2``. + +TC8-SDLC-017 — Reboot Detection on Unicast Channel +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_093 +:Requirement: ``comp_req__tc8_conformance__sd_reboot`` +:Test Module: ``test_sd_reboot`` +:Test Function: ``TestSDReboot::test_ets_093_reboot_on_unicast_channel`` + +**Purpose:** +Verify that a reboot is detected when the session ID resets on the unicast SD channel +(not only on multicast). + +**Stimulus:** +Restart DUT; observe first SD unicast message sent by DUT after restart. + +**Expected Result:** +``sd_hdr.flag_reboot == True`` and ``outer.session_id <= 2`` on the unicast channel. + +TC8-SDLC-018 — Server Reboot: Session ID Resets +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_094 +:Requirement: ``comp_req__tc8_conformance__sd_reboot`` +:Test Module: ``test_sd_reboot`` +:Test Function: ``TestSDReboot::test_ets_094_server_reboot_session_id_resets`` + +**Purpose:** +Verify that after a DUT restart the SD session ID starts from 1 (or 2), not from the +pre-restart value. + +**Stimulus:** +Capture pre-restart session IDs; restart DUT; capture post-restart session ID. + +**Expected Result:** +Post-restart session ID is ≤ 2, regardless of the pre-restart value. + +TC8-SDLC-019 — Subscribe TTL Expiry Stops Events +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_095 +:Requirement: ``comp_req__tc8_conformance__sd_ttl_expiry`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_095_subscribe_ttl_expires_no_events`` + +**Purpose:** +Verify that when a subscription's TTL expires without renewal the DUT ceases sending +event notifications to that subscriber. + +**Stimulus:** +Subscribe with a short TTL; do not renew; wait for TTL to expire; observe events. + +**Expected Result:** +No NOTIFICATION messages received after TTL expiry window. + +TC8-SDLC-020 — Initial Event Sent via UDP Unicast +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_105 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_105_initial_event_udp_unicast`` + +**Purpose:** +Verify that the first event notification after a unicast subscription is sent via +UDP unicast to the subscriber's endpoint. + +**Stimulus:** +Subscribe to a unicast eventgroup; capture the first NOTIFICATION after ACK. + +**Expected Result:** +First NOTIFICATION is unicast UDP addressed to the tester's endpoint. + +TC8-SDLC-021 — SubscribeEventgroup ACK Received +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_106 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_106_subscribe_eventgroup_ack_received`` + +**Purpose:** +Verify that the DUT sends a SubscribeEventgroupAck (entry type 0x06, TTL > 0) in +response to a valid SubscribeEventgroup. + +**Stimulus:** +Send a valid SubscribeEventgroup for the configured service/eventgroup. + +**Expected Result:** +SubscribeEventgroupAck received with ``entry_type == 0x06`` and ``TTL > 0``. + +TC8-SDLC-022 — Initial Field Event After Subscribe +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_121 +:Requirement: ``comp_req__tc8_conformance__fld_initial_value`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_121_initial_field_event_after_subscribe`` + +**Purpose:** +Verify that after subscribing to a field eventgroup the DUT sends an initial value +notification immediately (within one TTL cycle) without requiring a GET request. + +**Stimulus:** +Subscribe to field eventgroup ``0x4455``; observe notifications within 1 second. + +**Expected Result:** +At least one NOTIFICATION received within 1 second of subscription ACK. + +TC8-SDLC-023 — Unicast Subscribe Receives ACK +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_173 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_173_unicast_subscribe_receives_ack`` + +**Purpose:** +Verify that the DUT sends a SubscribeEventgroupAck via unicast directly to the +subscriber when a unicast SubscribeEventgroup is received. + +**Stimulus:** +Send SubscribeEventgroup via unicast to the DUT SD port. + +**Expected Result:** +SubscribeEventgroupAck received unicast at tester's endpoint; ``entry_type == 0x06``. + +TC8-SDLC-024 — Last Value Delivered via UDP Multicast +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_104 +:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle`` +:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_104_last_value_udp_multicast`` + +**Preconditions:** +Requires a non-loopback interface; skipped automatically on loopback. + +**Stimulus:** +Subscribe to multicast eventgroup; capture NOTIFICATION on configured multicast group. + +**Expected Result:** +NOTIFICATION is received on the eventgroup's multicast address, not unicast. + +TC8-SDLC-025 — Multicast FindService Elicits Offer Response +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_127 +:Requirement: ``comp_req__tc8_conformance__sd_find_response`` +:Test Function: ``TestSDFindServiceAdvanced::test_ets_127_multicast_findservice_response`` + +**Preconditions:** +Requires a non-loopback interface; skipped automatically on loopback. + +**Stimulus:** +Send SD FindService for the configured service via multicast. + +**Expected Result:** +DUT responds with OfferService (unicast or multicast) for the known service. + +SD Robustness Tests +-------------------- + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json`` +:Test Module: ``test_sd_robustness`` + +All robustness tests follow the pattern: inject one malformed SD packet, then send a +valid FindService and verify the DUT still replies with OfferService (DUT alive +assertion). + +TC8-SDROBUST-001 — Empty Entries Array +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_111 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_111_empty_entries_array`` + +**Stimulus:** +Send SD packet with entries_array_length = 0. + +**Expected Result:** +DUT alive (replies to FindService after injection). + +TC8-SDROBUST-002 — Zero-Length Option +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_112/113 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_112_empty_option_zero_length`` + +**Stimulus:** +Send SubscribeEventgroup with option length = 1 (too short). + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-003 — Entries Length Field Wrong +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_114 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Functions: + - ``TestSDMalformedEntries::test_ets_114_entries_length_zero`` + - ``TestSDMalformedEntries::test_ets_114_entries_length_mismatched`` + +**Stimulus:** +Send SD with entries_array_length = 0 (one entry present) and with +entries_array_length = 8 (not a multiple of 16). + +**Expected Result:** +DUT alive after each injection. + +TC8-SDROBUST-004 — Entry References More Options Than Exist +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_115 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_115_entry_refs_more_options_than_exist`` + +**Stimulus:** +Send SubscribeEventgroup with num_options_1 = 3 but only 1 option present. + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-005 — Entry Unknown Option Type +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_116 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_116_entry_unknown_option_type`` + +**Stimulus:** +Send SubscribeEventgroup with unknown option type 0x77. + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-006 — Two Entries Share Same Option Index +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_117 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_117_two_entries_same_option`` + +**Stimulus:** +Send SD with two entries both referencing option index 0. + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-007 — FindService With Endpoint Option +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_118 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_118_find_service_with_endpoint_option`` + +**Stimulus:** +Send FindService with an unexpected endpoint option attached. + +**Expected Result:** +DUT responds to the FindService; DUT alive. + +TC8-SDROBUST-008 — Entries Length Wildly Too Large +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_123/124 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_123_entries_length_wildly_too_large`` + +**Stimulus:** +Send SD with entries_array_length = 0xFFFF (far exceeds actual payload size). + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-009 — Truncated Entry +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_125 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_125_truncated_entry`` + +**Stimulus:** +Send SD with entries_array_length = 16 but only 8 bytes of entry data present. + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-010 — Option Length Much Too Large +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_134 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_134_option_length_much_too_large`` + +**Stimulus:** +Send SubscribeEventgroup with IPv4EndpointOption length = 0x00FF. + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-011 — Option Length One Too Large +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_135 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_135_option_length_one_too_large`` + +**Stimulus:** +Send SubscribeEventgroup with IPv4EndpointOption length = 0x000A (one too large). + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-012 — Option Length Too Short +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_136 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_136_option_length_too_short`` + +**Stimulus:** +Send SubscribeEventgroup with IPv4EndpointOption length = 0x0001 (shorter than minimum). + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-013 — Option Length Unaligned +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_137 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_137_option_length_unaligned`` + +**Stimulus:** +Send SubscribeEventgroup with IPv4EndpointOption length = 0x000A (unaligned/odd). + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-014 — Options Array Length Too Large +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_138 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_138_options_array_length_too_large`` + +**Stimulus:** +Send SubscribeEventgroup with options_array_length = 100 but only 12 bytes of options. + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-015 — Options Array Length Too Short +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_139 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_139_options_array_length_too_short`` + +**Stimulus:** +Send SubscribeEventgroup with options_array_length = 2 but 12 bytes of options present. + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-016 — Unknown Option Type 0x77 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_174 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_174_unknown_option_type_0x77`` + +**Stimulus:** +Send SD with option type 0x77 (reserved/unknown). + +**Expected Result:** +DUT alive. + +TC8-SDROBUST-017 — Subscribe With No Endpoint Option +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_109 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_109_subscribe_no_endpoint_option`` + +**Stimulus:** +Send SubscribeEventgroup with num_options_1 = 0 (no endpoint option). + +**Expected Result:** +NAck or silent discard; DUT alive. + +TC8-SDROBUST-018 — Subscribe With Zero IP Endpoint +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_110 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_110_subscribe_endpoint_ip_zero`` + +**Stimulus:** +Send SubscribeEventgroup with endpoint IP = 0.0.0.0. + +**Expected Result:** +NAck or silent discard; DUT alive. + +TC8-SDROBUST-019 — Subscribe With Unknown L4 Protocol +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_119 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_119_subscribe_unknown_l4proto`` + +**Stimulus:** +Send SubscribeEventgroup with L4 protocol byte = 0x00 (unknown). + +**Expected Result:** +NAck or silent discard; DUT alive. + +TC8-SDROBUST-020 — Subscribe Unknown Service ID +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_140 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_140_subscribe_unknown_service_id`` + +**Stimulus:** +Send SubscribeEventgroup for service_id = 0xDEAD (not offered). + +**Expected Result:** +No positive SubscribeAck; DUT alive. + +TC8-SDROBUST-021 — Subscribe Unknown Instance ID +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_141 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_141_subscribe_unknown_instance_id`` + +**Stimulus:** +Send SubscribeEventgroup for correct service_id but unknown instance_id = 0xBEEF. + +**Expected Result:** +No positive SubscribeAck; DUT alive. + +TC8-SDROBUST-022 — Subscribe Unknown Eventgroup ID +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_142 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_142_subscribe_unknown_eventgroup_id`` + +**Stimulus:** +Send SubscribeEventgroup for correct service/instance but unknown eventgroup 0xDEAD. + +**Expected Result:** +NAck or silent discard; DUT alive. + +TC8-SDROBUST-023 — Subscribe All IDs Unknown +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_143 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_143_subscribe_all_ids_unknown`` + +**Stimulus:** +Send SubscribeEventgroup with service_id, instance_id, and eventgroup_id all unknown. + +**Expected Result:** +No positive SubscribeAck; DUT alive. + +TC8-SDROBUST-024 — Subscribe With Reserved Option Type +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_144 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_144_subscribe_reserved_option_type`` + +**Stimulus:** +Send SubscribeEventgroup with reserved option type 0x20. + +**Expected Result:** +NAck or silent discard; DUT alive. + +TC8-SDROBUST-025 — Near-Wrap and Maximum Session IDs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_152 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Functions: + - ``TestSDMessageFramingErrors::test_ets_152_high_session_id_0xfffe`` + - ``TestSDMessageFramingErrors::test_ets_152_session_id_0xffff`` + - ``TestSDMessageFramingErrors::test_ets_152_session_id_one`` + +**Stimulus:** +Send FindService with session_id = 0xFFFE, then 0xFFFF, then 0x0001. + +**Expected Result:** +DUT alive after each injection. + +TC8-SDROBUST-026 — SOME/IP Length Field Mismatch +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_153 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Functions: + - ``TestSDMessageFramingErrors::test_ets_153_someip_length_too_small`` + - ``TestSDMessageFramingErrors::test_ets_153_someip_length_too_large`` + +**Stimulus:** +Send SD with SOME/IP length = 8 (smaller than payload) then length = 0x1000 (larger). + +**Expected Result:** +DUT alive after each injection. + +TC8-SDROBUST-027 — Wrong SOME/IP Service ID in SD Header +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_178 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMessageFramingErrors::test_ets_178_wrong_someip_service_id`` + +**Stimulus:** +Send SD packet with SOME/IP service_id = 0x1234 (not 0xFFFF). + +**Expected Result:** +DUT silently discards (not recognized as SD); DUT alive. + +TC8-SDROBUST-028 — SOME/IP Length Field Way Too Long +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_058 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMessageFramingErrors::test_ets_058_someip_length_way_too_long`` + +**Stimulus:** +Send SD packet where the SOME/IP length field is set to 0xFFFFFFFF (far beyond actual +packet size). + +**Expected Result:** +DUT silently discards the oversized-length SD message; DUT alive. + +TC8-SDROBUST-029 — Empty Options Array With Subscribe +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_113 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_113_empty_options_array_with_subscribe`` + +**Stimulus:** +Send SubscribeEventgroup entry where options_array_length = 0 but the entry +references option index 0. + +**Expected Result:** +DUT discards or NAcks the subscribe; DUT alive. + +TC8-SDROBUST-030 — Entries Length Too Long By Small Margin +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_124 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedEntries::test_ets_124_entries_length_too_long_by_small_margin`` + +**Stimulus:** +Send SD packet where entries_array_length is exactly 4 bytes larger than the +actual entries data present. + +**Expected Result:** +DUT discards the malformed SD message; DUT alive. + +TC8-SDROBUST-031 — Subscribe With Non-Routable Endpoint IP +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_154 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_154_subscribe_nonroutable_endpoint_ip`` + +**Stimulus:** +Send SubscribeEventgroup with an IPv4EndpointOption whose IP address is a +non-routable address (e.g., 0.0.0.1). + +**Expected Result:** +DUT rejects or ignores the subscribe; DUT alive. + +TC8-SDROBUST-032 — Subscribe Endpoint IP Not Matching Subscriber +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_162 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_162_subscribe_endpoint_ip_not_subscriber`` + +**Stimulus:** +Send SubscribeEventgroup where the IPv4EndpointOption IP address differs from the +actual sender IP. + +**Expected Result:** +DUT rejects or ignores the subscribe; DUT alive. + +TC8-SDROBUST-033 — Subscribe Endpoint IP Is the DUT Address +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_163 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDSubscribeEdgeCases::test_ets_163_subscribe_endpoint_dut_address`` + +**Stimulus:** +Send SubscribeEventgroup with an IPv4EndpointOption whose IP address is the DUT's +own loopback or service address. + +**Expected Result:** +DUT rejects or ignores the subscribe (loopback endpoint invalid); DUT alive. + +TC8-SDROBUST-034 — Unreferenced Option Ignored +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_175 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_175_unreferenced_option_ignored`` + +**Stimulus:** +Send an SD OfferService where the options array contains one option but the +entry's option run has num_options_1 = 0 (entry references no options). + +**Expected Result:** +DUT processes the entry normally (unreferenced option ignored); DUT alive. + +TC8-SDROBUST-035 — Trailing Data After Options Array +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_176 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_176_trailing_data_after_options_array`` + +**Stimulus:** +Send SD packet where extra trailing bytes appear after the options array +(options_array_length is correct but UDP payload is longer). + +**Expected Result:** +DUT discards or tolerates the trailing data; DUT alive. + +TC8-SDROBUST-036 — Trailing Data With Wrong Options Length +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_177 +:Requirement: ``comp_req__tc8_conformance__sd_robustness`` +:Test Function: ``TestSDMalformedOptions::test_ets_177_trailing_data_wrong_options_length`` + +**Stimulus:** +Send SD packet where options_array_length is inflated to include trailing garbage +bytes beyond the real options data. + +**Expected Result:** +DUT discards the malformed SD message; DUT alive. + +SOME/IP Message Protocol Compliance Tests +------------------------------------------ + +:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json`` +:Test Module: ``test_someip_message_format`` + +TC8-MSG-009 — Valid Service ID Gets Response +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.5 — SOMEIPSRV_BASIC_01 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipBasicIdentifiers::test_basic_01_correct_service_id_gets_response`` + +**Purpose:** +Verify that a REQUEST to a valid, offered service ID elicits a RESPONSE. + +**Stimulus:** +Send REQUEST to service 0x1234, method 0x0421. + +**Expected Result:** +RESPONSE received with message_type = 0x80. + +TC8-MSG-010 — Unknown Service ID: E_UNKNOWN_SERVICE +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.5 — SOMEIPSRV_BASIC_02 +:Requirement: ``comp_req__tc8_conformance__msg_error_codes`` +:Test Function: ``TestSomeipBasicIdentifiers::test_basic_02_unknown_service_id_no_response_or_error`` + +**Purpose:** +Verify that a REQUEST to an unknown service ID is rejected with E_UNKNOWN_SERVICE. + +.. note:: + + This test is expected to **FAIL** against vsomeip 3.6.1: the stack silently + drops requests for unknown services rather than responding with E_UNKNOWN_SERVICE. + +**Stimulus:** +Send REQUEST to service 0xDEAD (not offered). + +**Expected Result:** +ERROR response with return_code = E_UNKNOWN_SERVICE (0x02). + +TC8-MSG-011 — Event Method ID: No RESPONSE +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.5 — SOMEIPSRV_BASIC_03 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipBasicIdentifiers::test_basic_03_event_method_id_no_response`` + +**Purpose:** +Verify that a REQUEST using an event method ID (bit 15 = 1) does not elicit a +RESPONSE (0x80). An ERROR (0x81) response is permitted. + +**Stimulus:** +Send REQUEST with method_id = 0x8001 (event ID range). + +**Expected Result:** +No RESPONSE (0x80) received within the timeout. + +TC8-MSG-012 — Response Source Address Correct +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_01 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipResponseFields::test_onwire_01_response_source_address`` + +**Purpose:** +Verify that RESPONSE messages originate from the DUT's service endpoint address +and port (as advertised in the SD OfferService). + +**Stimulus:** +Send REQUEST; observe UDP source address of the RESPONSE. + +**Expected Result:** +RESPONSE source IP matches the DUT's service endpoint; source port equals the +advertised unreliable port. + +TC8-MSG-013 — Method ID MSB = 0 in RESPONSE +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_02 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipResponseFields::test_onwire_02_method_id_msb_zero_in_response`` + +**Purpose:** +Verify that RESPONSE messages have bit 15 of the method_id field equal to 0. + +**Stimulus:** +Send REQUEST; inspect method_id in the RESPONSE. + +**Expected Result:** +``(response.method_id & 0x8000) == 0``. + +TC8-MSG-014 — Request ID Reuse Across Sequential Requests +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_04 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipResponseFields::test_onwire_04_request_id_reuse`` + +**Purpose:** +Verify that the DUT correctly handles sequential requests that reuse the same +session_id value. + +**Stimulus:** +Send two consecutive REQUESTs with the same session_id. + +**Expected Result:** +Both receive valid RESPONSEs with the echoed session_id. + +TC8-MSG-015 — Interface Version Echoed in RESPONSE +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_06 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipResponseFields::test_onwire_06_interface_version_echoed`` + +**Purpose:** +Verify that the RESPONSE interface_version matches the REQUEST interface_version. + +**Stimulus:** +Send REQUEST with interface_version = 0x01. + +**Expected Result:** +RESPONSE has interface_version = 0x01. + +TC8-MSG-016 — Normal RESPONSE Return Code = E_OK +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_11 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipResponseFields::test_onwire_11_normal_response_return_code_ok`` + +**Purpose:** +Verify that a successful RESPONSE carries return_code = 0x00 (E_OK). + +**Stimulus:** +Send valid REQUEST to a known method. + +**Expected Result:** +``response.return_code == 0x00``. + +TC8-MSG-017 — Message ID Echoed in RESPONSE (RPC_18) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_18 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipResponseFields::test_rpc_18_message_id_echoed`` + +**Purpose:** +Verify that the RESPONSE message_id (service_id + method_id) matches the REQUEST. + +**Stimulus:** +Send REQUEST; compare message_id in RESPONSE. + +**Expected Result:** +RESPONSE service_id and method_id match the REQUEST. + +TC8-MSG-018 — Interface Version Copied From Request (RPC_20) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_20 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipResponseFields::test_rpc_20_interface_version_copied_from_request`` + +**Purpose:** +Verify that the DUT copies the interface_version from the REQUEST into the RESPONSE. + +**Stimulus:** +Send REQUEST with a specific interface_version. + +**Expected Result:** +RESPONSE interface_version matches the REQUEST value. + +TC8-MSG-019 — Fire-and-Forget: No Error Response (RPC_05) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_05 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_05_fire_and_forget_no_error`` + +**Purpose:** +Verify that REQUEST_NO_RETURN (fire-and-forget) messages do not elicit an error +response. + +**Stimulus:** +Send REQUEST_NO_RETURN to a valid method. + +**Expected Result:** +No response (ERROR or RESPONSE) received within the timeout. + +TC8-MSG-020 — Return Code Upper Bits Zero (RPC_06) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_06 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_06_return_code_upper_bits_zero`` + +**Purpose:** +Verify that bits 7-5 of the return_code field in RESPONSE messages are zero. + +**Stimulus:** +Send valid REQUEST; inspect RESPONSE return_code. + +**Expected Result:** +``(response.return_code & 0xE0) == 0``. + +TC8-MSG-021 — Inbound Return Code Upper Bits Ignored (RPC_07) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_07 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_07_request_with_return_code_bits_set`` + +**Purpose:** +Verify that the DUT ignores the two MSBs of the return_code field in inbound +REQUEST messages (still responds normally). + +**Stimulus:** +Send REQUEST with return_code = 0xC0 (two MSBs set). + +**Expected Result:** +RESPONSE received with E_OK. + +TC8-MSG-022 — No Reply for REQUEST With Non-Zero Return Code (RPC_08) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_08 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_08_request_with_error_return_code_no_reply`` + +**Purpose:** +Verify that the DUT does not reply to a REQUEST that has a non-zero return_code +(per spec, such requests should be silently discarded). + +.. note:: + + This test is expected to **FAIL** against vsomeip 3.6.1: the stack replies + to such messages. + +**Stimulus:** +Send REQUEST with return_code = 0x01 (non-zero). + +**Expected Result:** +No RESPONSE received within the timeout. + +TC8-MSG-023 — ERROR Response Has No Payload (RPC_09) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_09 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_09_error_response_no_payload`` + +**Purpose:** +Verify that ERROR responses carry no payload beyond the 8-byte SOME/IP header. + +**Stimulus:** +Trigger an error response (e.g., unknown method). + +**Expected Result:** +ERROR response has ``payload == b""`` (zero payload bytes). + +TC8-MSG-024 — Fire-and-Forget Reserved Type: No Error (RPC_10) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_10 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_10_fire_and_forget_reserved_type_no_error`` + +**Purpose:** +Verify that a fire-and-forget message sent to a wrong/reserved service ID does not +elicit an error response. + +**Stimulus:** +Send REQUEST_NO_RETURN to an unknown service. + +**Expected Result:** +No error response received. + +TC8-MSG-025 — Burst 10 Sequential Requests (ETS_004) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_004 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_004_burst_10_sequential_requests`` + +**Purpose:** +Verify that the DUT responds correctly to a burst of 10 sequential REQUEST messages. + +**Stimulus:** +Send 10 consecutive REQUESTs. + +**Expected Result:** +10 RESPONSE messages received, all with E_OK. + +TC8-MSG-026 — Empty Payload Request (ETS_054) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_054 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_054_empty_payload_request`` + +**Purpose:** +Verify that a REQUEST with an empty payload (length = 8, no payload bytes) is +handled correctly and elicits E_OK. + +**Stimulus:** +Send REQUEST with zero-length payload. + +**Expected Result:** +RESPONSE with return_code = E_OK. + +TC8-MSG-027 — Fire-and-Forget Wrong Service: No Error (ETS_059) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_059 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_059_fire_and_forget_wrong_service_no_error`` + +**Purpose:** +Verify that a REQUEST_NO_RETURN to an unknown service does not elicit an error +response. + +**Stimulus:** +Send REQUEST_NO_RETURN to service_id = 0xDEAD. + +**Expected Result:** +No response received. + +TC8-MSG-028 — Two Sequential Requests (ETS_061) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_061 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_061_two_sequential_requests`` + +**Purpose:** +Verify that two sequential REQUESTs each receive a correct RESPONSE. + +**Stimulus:** +Send two consecutive REQUESTs with different session_ids. + +**Expected Result:** +Two RESPONSEs received with matching session_ids and E_OK. + +TC8-MSG-029 — NOTIFICATION as REQUEST Ignored (ETS_075) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_075 +:Requirement: ``comp_req__tc8_conformance__msg_malformed`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_075_notification_as_request_ignored`` + +**Purpose:** +Verify that the DUT ignores a NOTIFICATION message (message_type = 0x02) sent +as if it were a REQUEST (i.e., directed at the service port). + +**Stimulus:** +Send SOME/IP message with message_type = 0x02 (NOTIFICATION) to the service port. + +**Expected Result:** +No RESPONSE or ERROR received within the timeout. + +TC8-MSG-030 — RESPONSE Uses Big-Endian Byte Order (ETS_005) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_005 +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:Test Function: ``TestSomeipResponseFields::test_ets_005_response_uses_big_endian_byte_order`` + +**Purpose:** +Verify that all multi-byte fields in a SOME/IP RESPONSE header (length, request_id, +interface_version, etc.) are encoded in big-endian byte order as required by the +SOME/IP protocol specification. + +**Stimulus:** +Send REQUEST; capture RESPONSE bytes; inspect raw byte ordering of length and +request_id fields. + +**Expected Result:** +Raw bytes of ``length`` and ``request_id`` fields match the big-endian encoding +of the decoded values (``struct.pack(">I", ...)``). + +TC8-MSG-031 — Oversized Length Field Does Not Crash DUT (ETS_058) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Reference: §5.1.6 — SOMEIP_ETS_058 +:Requirement: ``comp_req__tc8_conformance__msg_malformed`` +:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_058_oversized_length_field_no_crash`` + +**Purpose:** +Verify that the DUT does not crash or freeze when a SOME/IP message is received +with a length field value that far exceeds the actual UDP payload size. + +**Stimulus:** +Send SOME/IP REQUEST where the length header field is set to 0xFFFFFFFF while the +UDP payload is only 16 bytes. + +**Expected Result:** +DUT discards the malformed message; a subsequent valid REQUEST receives a correct +RESPONSE (DUT alive). + +TC8-MSG-032 — Session ID Echoed in Error Response (RPC_19) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_19 +:Test Module: ``test_someip_message_format.py`` +:Test Function: ``TestSomeipResponseFields::test_rpc_19_session_id_echoed_in_error`` +:Requirement: ``comp_req__tc8_conformance__msg_resp_header`` +:DUT Config: ``tc8_someipd_service.json`` + +**Purpose:** +Verify that error response session_id equals the request session_id. + +**Preconditions:** + +- DUT started, service offered. + +**Stimuli:** +Send REQUEST with a known ``session_id`` to an unknown method (triggering +E_UNKNOWN_METHOD error response). + +**Expected Result:** +Error RESPONSE has ``session_id`` matching the REQUEST value +(per PRS_SOMEIP_00137). diff --git a/docs/tc8_conformance/traceability.rst b/docs/tc8_conformance/traceability.rst new file mode 100644 index 000000000..f6fd07ee3 --- /dev/null +++ b/docs/tc8_conformance/traceability.rst @@ -0,0 +1,1259 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +TC8 Conformance Traceability +============================= + +This document provides the single source of truth for tracing +OPEN Alliance TC8 test cases from the external specification through +the project's internal requirements to the implementing test functions. + +Source Document +--------------- + +- **Title:** OA Automotive Ethernet ECU Test Specification — Layers 3–7 +- **Version:** v3.0 +- **Chapter:** 5 — SOME/IP +- **Publisher:** OPEN Alliance SIG + +.. note:: + + OA Spec References use test case identifiers from Chapter 5 of the + OA Automotive Ethernet ECU Test Specification v3.0 (October 2019). + ``SOMEIPSRV_*`` IDs are from §5.1.5 (SOME/IP Server Tests) and + ``SOMEIP_ETS_*`` IDs are from §5.1.6 (Enhanced Testability Service Tests). + +Full Traceability Matrix +------------------------ + +The following table links each OA TC8 specification test case to the +project's internal test ID, component requirement, and implementing +Python test function(s). All requirement IDs use the +``comp_req__tc8_conformance__`` prefix (omitted for brevity). + +Service Discovery +^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_08 + - TC8-SD-001 + - ``sd_offer_format`` + - ``test_service_discovery::test_tc8_sd_001_multicast_offer_on_startup`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_14–18 + - TC8-SD-002 + - ``sd_offer_format`` + - ``test_service_discovery::test_tc8_sd_002_offer_entry_format`` + * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_02 + - TC8-SD-003 + - ``sd_cyclic_timing`` + - ``test_service_discovery::test_tc8_sd_003_cyclic_offer_timing`` + * - §5.1.6 — SOMEIP_ETS_171 + - TC8-SD-004 + - ``sd_find_response`` + - ``test_service_discovery::test_tc8_sd_004_find_known_service_unicast_offer`` + * - §5.1.5.4 — implied by SD_BEHAVIOR_03/04 + - TC8-SD-005 + - ``sd_find_response`` + - ``test_service_discovery::test_tc8_sd_005_find_unknown_service_no_response`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_13 + - TC8-SD-006 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::test_tc8_sd_006_subscribe_valid_eventgroup_ack`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_14; §5.1.6 — SOMEIP_ETS_140 + - TC8-SD-007 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::test_tc8_sd_007_subscribe_unknown_eventgroup_nack`` + * - §5.1.6 — SOMEIP_ETS_108, SOMEIP_ETS_092 + - TC8-SD-008 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::test_tc8_sd_008_stop_subscribe_ceases_notifications`` + * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_01 + - TC8-SD-009 + - ``sd_phases_timing`` + - ``test_sd_phases_timing::test_tc8_sd_009_repetition_phase_intervals`` + * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_01 + - TC8-SD-010 + - ``sd_phases_timing`` + - ``test_sd_phases_timing::test_tc8_sd_010_repetition_count_before_main_phase`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_01–07 + - TC8-SD-011 + - ``sd_endpoint_option`` + - ``test_service_discovery::test_tc8_sd_011_offer_ipv4_endpoint_option`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_02, FORMAT_07 + - TC8-SD-012 + - ``sd_reboot`` + - | ``test_sd_reboot::test_tc8_sd_012_reboot_flag_set_after_restart`` + | ``test_sd_reboot::test_tc8_sd_012_session_id_resets_after_restart`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_08–14 + - TC8-SD-013 + - ``sd_mcast_eg`` + - ``test_service_discovery::test_tc8_sd_013_subscribe_ack_has_multicast_option`` + * - §5.1.6 — SOMEIP_ETS_095 + - TC8-SD-014 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::test_tc8_sd_014_ttl_expiry_ceases_notifications`` + +SOME/IP Message Format +^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_05 + - TC8-MSG-001 + - ``msg_resp_header`` + - ``test_someip_message_format::test_tc8_msg_001_protocol_version`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_07 + - TC8-MSG-002 + - ``msg_resp_header`` + - | ``test_someip_message_format::test_tc8_msg_002_message_type_response`` + | ``test_someip_message_format::test_tc8_msg_002_no_response_for_request_no_return`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_10; §5.1.6 — SOMEIP_ETS_077 + - TC8-MSG-003 + - ``msg_error_codes`` + - ``test_someip_message_format::test_tc8_msg_003_unknown_service`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_12; §5.1.6 — SOMEIP_ETS_076 + - TC8-MSG-004 + - ``msg_error_codes`` + - ``test_someip_message_format::test_tc8_msg_004_unknown_method`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_03 + - TC8-MSG-005 + - ``msg_resp_header`` + - ``test_someip_message_format::test_tc8_msg_005_session_id_echo`` + * - §5.1.6 — SOMEIP_ETS_074 + - TC8-MSG-006 + - ``msg_error_codes`` + - ``test_someip_message_format::test_tc8_msg_006_wrong_interface_version`` + * - §5.1.6 — SOMEIP_ETS_054, 055, 058, 078 + - TC8-MSG-007 + - ``msg_malformed`` + - | ``test_someip_message_format::test_tc8_msg_007_truncated_message_no_crash`` + | ``test_someip_message_format::test_tc8_msg_007_wrong_protocol_version_no_crash`` + | ``test_someip_message_format::test_tc8_msg_007_oversized_length_field_no_crash`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_03 + - TC8-MSG-008 + - ``msg_resp_header`` + - ``test_someip_message_format::test_tc8_msg_008_client_id_echo`` + +Event Notification +^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.5.5 — SOMEIPSRV_BASIC_03; §5.1.6 — SOMEIP_ETS_147 + - TC8-EVT-001 + - ``evt_subscription`` + - ``test_event_notification::test_tc8_evt_001_notification_message_type`` + * - §5.1.5.5 — SOMEIPSRV_BASIC_03 + - TC8-EVT-002 + - ``evt_subscription`` + - ``test_event_notification::test_tc8_evt_002_correct_event_id`` + * - §5.1.6 — SOMEIP_ETS_147 + - TC8-EVT-003 + - ``evt_subscription`` + - ``test_event_notification::test_tc8_evt_003_notification_only_to_subscriber`` + * - §5.1.6 — SOMEIP_ETS_147 (pre-subscribe) + - TC8-EVT-004 + - ``evt_subscription`` + - ``test_event_notification::test_tc8_evt_004_no_notification_before_subscribe`` + * - §5.1.6 — SOMEIP_ETS_150 + - TC8-EVT-005 + - ``evt_subscription`` + - ``test_event_notification::test_tc8_evt_005_multicast_notification_delivery`` + * - §5.1.6 — SOMEIP_ETS_108 + - TC8-EVT-006 + - ``evt_subscription`` + - ``test_event_notification::test_tc8_evt_006_stop_subscribe_ceases_notifications`` + * - §5.1.5.7 — SOMEIPSRV_RPC_16 + - TC8-EVT-007 + - ``fld_getter_setter`` + - ``test_event_notification::TestEventNotification::test_rpc_16_field_notifies_only_on_change`` + * - §5.1.5.7 — SOMEIPSRV_RPC_15 + - TC8-EVT-008 + - ``evt_subscription`` + - ``test_event_notification::TestEventNotificationFormat::test_rpc_15_cyclic_notification_rate`` + +Field Conformance +^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.6 — SOMEIP_ETS_121 + - TC8-FLD-001 + - ``fld_initial_value`` + - ``test_field_conformance::test_tc8_fld_001_initial_notification_on_subscribe`` + * - §5.1.6 — SOMEIP_ETS_121 + - TC8-FLD-002 + - ``fld_initial_value`` + - ``test_field_conformance::test_tc8_fld_002_is_field_sends_initial_value_within_one_second`` + * - §5.1.5.7 — SOMEIPSRV_RPC_03; §5.1.6 — SOMEIP_ETS_166 + - TC8-FLD-003 + - ``fld_get_set`` + - ``test_field_conformance::test_tc8_fld_003_getter_returns_current_value`` + * - §5.1.5.7 — SOMEIPSRV_RPC_11; §5.1.6 — SOMEIP_ETS_166 + - TC8-FLD-004 + - ``fld_get_set`` + - ``test_field_conformance::test_tc8_fld_004_setter_updates_value_and_notifies`` + +TCP Transport Binding +^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.5.7 — SOMEIPSRV_RPC_01 + - TC8-TCP-001 + - ``tcp_transport`` + - ``test_someip_message_format::test_tc8_rpc_01_tcp_request_response`` + * - §5.1.5.7 — SOMEIPSRV_RPC_01 + - TC8-TCP-002 + - ``tcp_transport`` + - ``test_someip_message_format::test_tc8_rpc_01_tcp_session_id_echo`` + * - §5.1.5.7 — SOMEIPSRV_RPC_01 + - TC8-TCP-003 + - ``tcp_transport`` + - ``test_someip_message_format::test_tc8_rpc_01_tcp_client_id_echo`` + * - §5.1.5.7 — SOMEIPSRV_RPC_02 + - TC8-TCP-004 + - ``tcp_transport`` + - ``test_someip_message_format::test_tc8_rpc_02_tcp_multiple_methods_single_connection`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_15 + - TC8-TCP-005 + - ``tcp_transport`` + - ``test_someip_message_format::test_tc8_sd_options_15_tcp_endpoint_advertised`` + * - §5.1.5.7 — SOMEIPSRV_RPC_17 **(partial — single-instance only)** + - TC8-TCP-006 + - ``tcp_transport`` + - ``test_field_conformance::test_tc8_rpc_17_tcp_field_getter`` + * - §5.1.5.7 — SOMEIPSRV_RPC_17 **(partial — single-instance only)** + - TC8-TCP-007 + - ``tcp_transport`` + - ``test_field_conformance::test_tc8_rpc_17_tcp_field_setter`` + * - §5.1.5.7 — SOMEIPSRV_RPC_17 **(partial — single-instance only)** + - TC8-TCP-008 + - ``tcp_transport`` + - ``test_event_notification::test_tc8_rpc_17_tcp_event_notification_delivery`` + * - SOMEIP_ETS_068 + - TC8-TCP-009 + - ``tcp_transport`` + - ``test_someip_message_format::test_tc8_ets_068_unaligned_someip_messages_over_tcp`` + +.. note:: + + **SOMEIPSRV_RPC_17 partial coverage:** TC8-TCP-006, TC8-TCP-007, + and TC8-TCP-008 verify TCP transport for field GET/SET and event + notification operations using a single service instance; the full + SOMEIPSRV_RPC_17 requirement (each service instance on a separate + TCP connection) is not covered — multi-instance TCP is a known gap. + +UDP Transport Binding +^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - SOMEIP_ETS_069 + - TC8-UDP-001 + - ``udp_transport`` + - ``test_someip_message_format::test_tc8_ets_069_unaligned_someip_messages_over_udp`` + +Multi-service and Multi-instance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.5.7 — SOMEIPSRV_RPC_13 + - TC8-MULTI-001 + - ``multi_service`` + - ``test_multi_service::TestMultiServiceInstanceRouting::test_rpc_13_multi_service_config_loads_and_primary_service_offered`` + * - §5.1.5.7 — SOMEIPSRV_RPC_14 + - TC8-MULTI-002 + - ``multi_service`` + - | ``test_multi_service::TestMultiServiceInstanceRouting::test_rpc_14_service_a_advertises_configured_udp_port`` + | ``test_multi_service::TestMultiServiceInstanceRouting::test_rpc_14_no_unexpected_service_ids_in_offers`` + +SD Format and Options Compliance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.5.1 — SOMEIPSRV_FORMAT_01 + - TC8-SDF-001 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_01_client_id_is_zero`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_02 + - TC8-SDF-002 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_02_session_id_is_nonzero_and_in_range`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_04 + - TC8-SDF-003 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_04_interface_version_is_one`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_05 + - TC8-SDF-004 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_05_message_type_is_notification`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_06 + - TC8-SDF-005 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_06_return_code_is_e_ok`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_09 + - TC8-SDF-006 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_09_sd_flags_reserved_bits_are_zero`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_10 + - TC8-SDF-007 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_10_sd_entry_reserved_bytes_are_zero`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_11 + - TC8-SDF-008 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_11_entry_is_16_bytes`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_12 + - TC8-SDF-009 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_12_first_option_run_index_is_zero`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_13 + - TC8-SDF-010 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_13_num_options_matches_options_list`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_15 + - TC8-SDF-011 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_15_instance_id_matches_config`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_16 + - TC8-SDF-012 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_16_major_version_matches_config`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_18 + - TC8-SDF-013 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_18_minor_version_matches_config`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_19 + - TC8-SDF-014 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_19_ack_entry_type_is_subscribe_ack`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_20 + - TC8-SDF-015 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_20_ack_entry_is_16_bytes`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_21 + - TC8-SDF-016 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_21_ack_option_run_index_is_zero_when_no_options`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_23 + - TC8-SDF-017 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_23_ack_service_id_matches_config`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_24 + - TC8-SDF-018 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_24_ack_instance_id_matches_config`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_25 + - TC8-SDF-019 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_25_ack_major_version_matches_config`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_26 + - TC8-SDF-020 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_26_ack_ttl_is_nonzero`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_27 + - TC8-SDF-021 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_27_ack_reserved_field_is_zero`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_28 + - TC8-SDF-022 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_28_ack_eventgroup_id_matches_subscribe`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_01 + - TC8-SDF-023 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_01_endpoint_option_length_is_nine`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_02 + - TC8-SDF-024 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_02_endpoint_option_type_is_0x04`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_03 + - TC8-SDF-025 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_03_endpoint_option_reserved_after_type_is_zero`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_05 + - TC8-SDF-026 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_05_endpoint_option_reserved_before_protocol_is_zero`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_06 + - TC8-SDF-027 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_06_endpoint_option_protocol_is_udp`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_08 + - TC8-SDF-028 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_08_multicast_option_length_is_nine`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_09 + - TC8-SDF-029 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_09_multicast_option_type_is_0x14`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_10 + - TC8-SDF-030 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_10_multicast_option_reserved_is_zero`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_11 + - TC8-SDF-031 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_11_multicast_address_matches_config`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_12 + - TC8-SDF-032 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_12_multicast_option_reserved_before_port_is_zero`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_13 + - TC8-SDF-033 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_13_multicast_option_protocol_is_udp`` + * - §5.1.5.2 — SOMEIPSRV_OPTIONS_14 + - TC8-SDF-034 + - ``sd_options_fields`` + - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_14_multicast_port_matches_config`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_12 + - TC8-SDF-035 + - ``sd_stop_sub_fmt`` + - ``test_sd_format_compliance::TestSdStopSubscribeFormat::test_sd_message_12_stop_subscribe_entry_format`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_03 + - TC8-SDF-036 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_03_protocol_version_is_one`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_08 + - TC8-SDF-037 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_08_unicast_flag_set`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_14 + - TC8-SDF-038 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_14_entry_type_is_offer`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_17 + - TC8-SDF-039 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_17_ttl_is_nonzero`` + * - §5.1.5.1 — SOMEIPSRV_FORMAT_22 + - TC8-SDF-040 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_22_ack_num_options_1_matches`` + +.. note:: + + TC8-SDF-028 through TC8-SDF-034 (SOMEIPSRV_OPTIONS_08–14, multicast option + fields) require a non-loopback interface and are skipped on loopback with + ``@pytest.mark.network``. + +.. note:: + + **TC8-SDF-037:** The test function ``test_format_08_unicast_flag_set`` + verifies the **Unicast Flag (bit 6)** of the SD Flags byte, which corresponds + to ``SOMEIPSRV_FORMAT_08`` in the OA spec. ``SOMEIPSRV_FORMAT_07`` (Reboot + Flag) is a separate requirement covered by ``TC8-SD-012``. + +SD Entry Semantics +^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_01 + - TC8-SDM-001 + - ``sd_find_response`` + - ``test_service_discovery::TestSDVersionMatching::test_sd_message_01_instance_wildcard`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_02 + - TC8-SDM-002 + - ``sd_find_response`` + - ``test_service_discovery::TestSDVersionMatching::test_sd_message_02_instance_specific`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_03 + - TC8-SDM-003 + - ``sd_find_response`` + - ``test_service_discovery::TestSDVersionMatching::test_sd_message_03_major_version_wildcard`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_04 + - TC8-SDM-004 + - ``sd_find_response`` + - ``test_service_discovery::TestSDVersionMatching::test_sd_message_04_major_version_specific`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_05 + - TC8-SDM-005 + - ``sd_find_response`` + - ``test_service_discovery::TestSDVersionMatching::test_sd_message_05_minor_version_wildcard`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_06 + - TC8-SDM-006 + - ``sd_find_response`` + - ``test_service_discovery::TestSDVersionMatching::test_sd_message_06_minor_version_specific`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_14 + - TC8-SDM-007 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_14_wrong_major_version`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_15 + - TC8-SDM-008 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_15_wrong_service_id`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_16 + - TC8-SDM-009 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_16_wrong_instance_id`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_17 + - TC8-SDM-010 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_17_unknown_eventgroup_id`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_18 + - TC8-SDM-011 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_18_ttl_zero_stop_subscribe`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_19 + - TC8-SDM-012 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_19_reserved_field_set`` + * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_03 + - TC8-SDM-013 + - ``sd_cyclic_timing`` + - ``test_service_discovery::TestSDFindServiceTiming::test_sd_behavior_03_unicast_findservice_timing`` + * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_04 + - TC8-SDM-014 + - ``sd_cyclic_timing`` + - ``test_service_discovery::TestSDFindServiceTiming::test_sd_behavior_04_multicast_findservice_timing`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_07 + - TC8-SDM-015 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdEntryOptionFields::test_sd_message_07_offer_entry_type_byte`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_08 + - TC8-SDM-016 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdEntryOptionFields::test_sd_message_08_offer_entry_option_run2_index_zero`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_09 + - TC8-SDM-017 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdEntryOptionFields::test_sd_message_09_offer_entry_num_options_2_zero`` + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_11 + - TC8-SDM-018 + - ``sd_format_fields`` + - ``test_sd_format_compliance::TestSdEntryOptionFields::test_sd_message_11_subscribe_entry_type_byte`` + +.. note:: + + **SOMEIPSRV_SD_MESSAGE_08 dual coverage:** ``TC8-SD-001`` verifies that an + OfferService message is *present* on multicast at startup (behavioural + assertion). ``TC8-SDM-016`` verifies the *option_index_2 byte* in the + serialised entry is zero — a distinct field-level assertion from the same + spec requirement. + +.. note:: + + TC8-SDM-012 (SOMEIPSRV_SD_MESSAGE_19) is expected to **FAIL** against + vsomeip 3.6.1: the stack sends a positive ACK instead of NAck for a + SubscribeEventgroup with reserved bits set. See :ref:`known_stack_limitations`. + +SD Lifecycle Advanced +^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.6 — SOMEIP_ETS_088 + - TC8-SDLC-001 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_088_two_subscribes_same_session`` + * - §5.1.6 — SOMEIP_ETS_092 + - TC8-SDLC-002 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_092_ttl_zero_stop_subscribe_no_nack`` + * - §5.1.6 — SOMEIP_ETS_098 + - TC8-SDLC-003 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_098_subscribe_accepted_without_prior_rpc`` + * - §5.1.6 — SOMEIP_ETS_107 + - TC8-SDLC-004 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_107_find_service_and_subscribe_processed_independently`` + * - §5.1.6 — SOMEIP_ETS_120 + - TC8-SDLC-005 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_120_subscribe_endpoint_ip_matches_tester`` + * - §5.1.6 — SOMEIP_ETS_122 + - TC8-SDLC-006 + - ``sd_offer_format`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_122_sd_interface_version_is_one`` + * - §5.1.6 — SOMEIP_ETS_155 + - TC8-SDLC-007 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_155_resubscribe_after_stop`` + * - §5.1.6 — SOMEIP_ETS_091 + - TC8-SDLC-008 + - ``sd_offer_format`` + - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_091_session_id_increments`` + * - §5.1.6 — SOMEIP_ETS_099 + - TC8-SDLC-009 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_099_initial_event_sent_after_subscribe`` + * - §5.1.6 — SOMEIP_ETS_100 + - TC8-SDLC-010 + - ``sd_offer_format`` + - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_100_no_findservice_emitted_by_server`` + * - §5.1.6 — SOMEIP_ETS_101 + - TC8-SDLC-011 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_101_stop_offer_ceases_client_events`` + * - §5.1.6 — SOMEIP_ETS_128 + - TC8-SDLC-012 + - ``sd_find_response`` + - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_128_multicast_findservice_version_wildcard`` + * - §5.1.6 — SOMEIP_ETS_130 + - TC8-SDLC-013 + - ``sd_find_response`` + - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_130_multicast_findservice_unicast_flag_clear`` + * - §5.1.6 — SOMEIP_ETS_084 + - TC8-SDLC-014 + - ``sd_sub_lifecycle`` + - ``test_sd_client::TestSDClientStopSubscribe::test_ets_084_stop_subscribe_ceases_events`` + * - §5.1.6 — SOMEIP_ETS_081 + - TC8-SDLC-015 + - ``sd_reboot`` + - ``test_sd_client::TestSDClientReboot::test_ets_081_reboot_flag_set_after_first_restart`` + * - §5.1.6 — SOMEIP_ETS_082 + - TC8-SDLC-016 + - ``sd_reboot`` + - ``test_sd_client::TestSDClientReboot::test_ets_082_reboot_flag_set_after_second_restart`` + * - §5.1.6 — SOMEIP_ETS_093 + - TC8-SDLC-017 + - ``sd_reboot`` + - ``test_sd_reboot::TestSDReboot::test_ets_093_reboot_on_unicast_channel`` + * - §5.1.6 — SOMEIP_ETS_094 + - TC8-SDLC-018 + - ``sd_reboot`` + - ``test_sd_reboot::TestSDReboot::test_ets_094_server_reboot_session_id_resets`` + * - §5.1.6 — SOMEIP_ETS_095 + - TC8-SDLC-019 + - ``sd_ttl_expiry`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_095_subscribe_ttl_expires_no_events`` + * - §5.1.6 — SOMEIP_ETS_105 + - TC8-SDLC-020 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_105_initial_event_udp_unicast`` + * - §5.1.6 — SOMEIP_ETS_106 + - TC8-SDLC-021 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_106_subscribe_eventgroup_ack_received`` + * - §5.1.6 — SOMEIP_ETS_121 + - TC8-SDLC-022 + - ``fld_initial_value`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_121_initial_field_event_after_subscribe`` + * - §5.1.6 — SOMEIP_ETS_173 + - TC8-SDLC-023 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_173_unicast_subscribe_receives_ack`` + * - §5.1.6 — SOMEIP_ETS_104 + - TC8-SDLC-024 + - ``sd_sub_lifecycle`` + - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_104_last_value_udp_multicast`` + * - §5.1.6 — SOMEIP_ETS_127 + - TC8-SDLC-025 + - ``sd_find_response`` + - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_127_multicast_findservice_response`` + +.. note:: + + TC8-SDLC-011 (SOMEIP_ETS_101) is implemented as ``pytest.skip`` because + stopping the DUT's own OfferService from an external tester requires a + dedicated reverse-direction SD client target; the current target does not + include that capability. + +SD Robustness +^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.6 — SOMEIP_ETS_111 + - TC8-SDROBUST-001 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_111_empty_entries_array`` + * - §5.1.6 — SOMEIP_ETS_112/113 + - TC8-SDROBUST-002 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_112_empty_option_zero_length`` + * - §5.1.6 — SOMEIP_ETS_114 + - TC8-SDROBUST-003 + - ``sd_robustness`` + - | ``test_sd_robustness::TestSDMalformedEntries::test_ets_114_entries_length_zero`` + | ``test_sd_robustness::TestSDMalformedEntries::test_ets_114_entries_length_mismatched`` + * - §5.1.6 — SOMEIP_ETS_115 + - TC8-SDROBUST-004 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_115_entry_refs_more_options_than_exist`` + * - §5.1.6 — SOMEIP_ETS_116 + - TC8-SDROBUST-005 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_116_entry_unknown_option_type`` + * - §5.1.6 — SOMEIP_ETS_117 + - TC8-SDROBUST-006 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_117_two_entries_same_option`` + * - §5.1.6 — SOMEIP_ETS_118 + - TC8-SDROBUST-007 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_118_find_service_with_endpoint_option`` + * - §5.1.6 — SOMEIP_ETS_123/124 + - TC8-SDROBUST-008 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_123_entries_length_wildly_too_large`` + * - §5.1.6 — SOMEIP_ETS_125 + - TC8-SDROBUST-009 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_125_truncated_entry`` + * - §5.1.6 — SOMEIP_ETS_134 + - TC8-SDROBUST-010 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_134_option_length_much_too_large`` + * - §5.1.6 — SOMEIP_ETS_135 + - TC8-SDROBUST-011 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_135_option_length_one_too_large`` + * - §5.1.6 — SOMEIP_ETS_136 + - TC8-SDROBUST-012 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_136_option_length_too_short`` + * - §5.1.6 — SOMEIP_ETS_137 + - TC8-SDROBUST-013 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_137_option_length_unaligned`` + * - §5.1.6 — SOMEIP_ETS_138 + - TC8-SDROBUST-014 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_138_options_array_length_too_large`` + * - §5.1.6 — SOMEIP_ETS_139 + - TC8-SDROBUST-015 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_139_options_array_length_too_short`` + * - §5.1.6 — SOMEIP_ETS_174 + - TC8-SDROBUST-016 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_174_unknown_option_type_0x77`` + * - §5.1.6 — SOMEIP_ETS_109 + - TC8-SDROBUST-017 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_109_subscribe_no_endpoint_option`` + * - §5.1.6 — SOMEIP_ETS_110 + - TC8-SDROBUST-018 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_110_subscribe_endpoint_ip_zero`` + * - §5.1.6 — SOMEIP_ETS_119 + - TC8-SDROBUST-019 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_119_subscribe_unknown_l4proto`` + * - §5.1.6 — SOMEIP_ETS_140 + - TC8-SDROBUST-020 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_140_subscribe_unknown_service_id`` + * - §5.1.6 — SOMEIP_ETS_141 + - TC8-SDROBUST-021 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_141_subscribe_unknown_instance_id`` + * - §5.1.6 — SOMEIP_ETS_142 + - TC8-SDROBUST-022 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_142_subscribe_unknown_eventgroup_id`` + * - §5.1.6 — SOMEIP_ETS_143 + - TC8-SDROBUST-023 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_143_subscribe_all_ids_unknown`` + * - §5.1.6 — SOMEIP_ETS_144 + - TC8-SDROBUST-024 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_144_subscribe_reserved_option_type`` + * - §5.1.6 — SOMEIP_ETS_152 + - TC8-SDROBUST-025 + - ``sd_robustness`` + - | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_152_high_session_id_0xfffe`` + | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_152_session_id_0xffff`` + | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_152_session_id_one`` + * - §5.1.6 — SOMEIP_ETS_153 + - TC8-SDROBUST-026 + - ``sd_robustness`` + - | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_153_someip_length_too_small`` + | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_153_someip_length_too_large`` + * - §5.1.6 — SOMEIP_ETS_178 + - TC8-SDROBUST-027 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_178_wrong_someip_service_id`` + * - §5.1.6 — SOMEIP_ETS_058 + - TC8-SDROBUST-028 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_058_someip_length_way_too_long`` + * - §5.1.6 — SOMEIP_ETS_113 + - TC8-SDROBUST-029 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_113_empty_options_array_with_subscribe`` + * - §5.1.6 — SOMEIP_ETS_124 + - TC8-SDROBUST-030 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedEntries::test_ets_124_entries_length_too_long_by_small_margin`` + * - §5.1.6 — SOMEIP_ETS_154 + - TC8-SDROBUST-031 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_154_subscribe_nonroutable_endpoint_ip`` + * - §5.1.6 — SOMEIP_ETS_162 + - TC8-SDROBUST-032 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_162_subscribe_endpoint_ip_not_subscriber`` + * - §5.1.6 — SOMEIP_ETS_163 + - TC8-SDROBUST-033 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_163_subscribe_endpoint_dut_address`` + * - §5.1.6 — SOMEIP_ETS_175 + - TC8-SDROBUST-034 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_175_unreferenced_option_ignored`` + * - §5.1.6 — SOMEIP_ETS_176 + - TC8-SDROBUST-035 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_176_trailing_data_after_options_array`` + * - §5.1.6 — SOMEIP_ETS_177 + - TC8-SDROBUST-036 + - ``sd_robustness`` + - ``test_sd_robustness::TestSDMalformedOptions::test_ets_177_trailing_data_wrong_options_length`` + +SOME/IP Message Protocol Compliance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 25 12 18 45 + + * - OA Spec Reference (Ch. 5) + - Internal ID + - Requirement + - Test Function(s) + * - §5.1.5.5 — SOMEIPSRV_BASIC_01 + - TC8-MSG-009 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipBasicIdentifiers::test_basic_01_correct_service_id_gets_response`` + * - §5.1.5.5 — SOMEIPSRV_BASIC_02 + - TC8-MSG-010 + - ``msg_error_codes`` + - ``test_someip_message_format::TestSomeipBasicIdentifiers::test_basic_02_unknown_service_id_no_response_or_error`` + * - §5.1.5.5 — SOMEIPSRV_BASIC_03 + - TC8-MSG-011 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipBasicIdentifiers::test_basic_03_event_method_id_no_response`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_01 + - TC8-MSG-012 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_01_response_source_address`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_02 + - TC8-MSG-013 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_02_method_id_msb_zero_in_response`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_04 + - TC8-MSG-014 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_04_request_id_reuse`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_06 + - TC8-MSG-015 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_06_interface_version_echoed`` + * - §5.1.5.6 — SOMEIPSRV_ONWIRE_11 + - TC8-MSG-016 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_11_normal_response_return_code_ok`` + * - §5.1.5.7 — SOMEIPSRV_RPC_18 + - TC8-MSG-017 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipResponseFields::test_rpc_18_message_id_echoed`` + * - §5.1.5.7 — SOMEIPSRV_RPC_20 + - TC8-MSG-018 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipResponseFields::test_rpc_20_interface_version_copied_from_request`` + * - §5.1.5.7 — SOMEIPSRV_RPC_05 + - TC8-MSG-019 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_05_fire_and_forget_no_error`` + * - §5.1.5.7 — SOMEIPSRV_RPC_06 + - TC8-MSG-020 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_06_return_code_upper_bits_zero`` + * - §5.1.5.7 — SOMEIPSRV_RPC_07 + - TC8-MSG-021 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_07_request_with_return_code_bits_set`` + * - §5.1.5.7 — SOMEIPSRV_RPC_08 + - TC8-MSG-022 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_08_request_with_error_return_code_no_reply`` + * - §5.1.5.7 — SOMEIPSRV_RPC_09 + - TC8-MSG-023 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_09_error_response_no_payload`` + * - §5.1.5.7 — SOMEIPSRV_RPC_10 + - TC8-MSG-024 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_10_fire_and_forget_reserved_type_no_error`` + * - §5.1.6 — SOMEIP_ETS_004 + - TC8-MSG-025 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_004_burst_10_sequential_requests`` + * - §5.1.6 — SOMEIP_ETS_054 + - TC8-MSG-026 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_054_empty_payload_request`` + * - §5.1.6 — SOMEIP_ETS_059 + - TC8-MSG-027 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_059_fire_and_forget_wrong_service_no_error`` + * - §5.1.6 — SOMEIP_ETS_061 + - TC8-MSG-028 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_061_two_sequential_requests`` + * - §5.1.6 — SOMEIP_ETS_075 + - TC8-MSG-029 + - ``msg_malformed`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_075_notification_as_request_ignored`` + * - §5.1.6 — SOMEIP_ETS_005 + - TC8-MSG-030 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipResponseFields::test_ets_005_response_uses_big_endian_byte_order`` + * - §5.1.6 — SOMEIP_ETS_058 + - TC8-MSG-031 + - ``msg_malformed`` + - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_058_oversized_length_field_no_crash`` + * - §5.1.5.7 — SOMEIPSRV_RPC_19 + - TC8-MSG-032 + - ``msg_resp_header`` + - ``test_someip_message_format::TestSomeipResponseFields::test_rpc_19_session_id_echoed_in_error`` + +.. note:: + + TC8-MSG-022 (SOMEIPSRV_RPC_08) is expected to **FAIL** against vsomeip + 3.6.1: the stack replies to a REQUEST with non-zero return_code when the + spec requires no reply. See :ref:`known_stack_limitations`. + + TC8-MSG-010 (SOMEIPSRV_BASIC_02) is expected to **FAIL** against vsomeip + 3.6.1: the stack silently drops unknown-service requests rather than + responding with E_UNKNOWN_SERVICE. + +Coverage Summary +---------------- + +All 189 internal TC8 test IDs are implemented and mapped to OA spec references. +Some IDs are covered by multiple pytest functions (~215 total). +Three tests are expected to fail against vsomeip 3.6.1 — see +:ref:`known_stack_limitations`. + +For the full OA TC8 v3.0 Chapter 5 scope analysis (including blocked tests +requiring the ETS application), see `TC8 Specification Alignment Analysis`_ +below. + +TC8 Specification Alignment Analysis +------------------------------------- + +This section maps the 230 test cases in Chapter 5 of the +`OPEN Alliance TC8 ECU Test Specification Layer 3-7 v3.0 (October 2019) +`_ +to the current implementation status. It answers three questions for every +TC8 group: + +1. **What is already tested and passing?** +2. **What can be tested today without any new software?** +3. **What requires new software before the tests can run?** + +For the full test case catalog see +``tests/tc8_conformance/tc8_ecu_test_chapter5_someip_v3.0_oct2019.md``. + +The specification organizes Chapter 5 into two top-level groups: + +- **SOME/IP Server Tests** (``SOMEIPSRV_*``, 93 items, Section 5.1.5) — + wire-level protocol checks. Only ``someipd`` and a raw socket are needed. + No application code is required. +- **Enhanced Testability Service Tests** (``SOMEIP_ETS_*``, 137 items, + Section 5.1.6) — behavior tests that range from wire-level SD tests + (needing only ``someipd``) to full-pipeline serialization tests that + require a C++ test application. + +Coverage at a Glance +^^^^^^^^^^^^^^^^^^^^^ + +The table below shows the top-level status for all five TC8 test groups. + +.. list-table:: + :header-rows: 1 + :widths: 32 8 9 10 41 + + * - TC8 Group + - Total + - ✅ Tested + - ⚠ Can add + - Infrastructure needed + * - SOMEIPSRV Protocol (§5.1.5) + - 93 + - 93 + - 0 + - **N/A** — all wire-level tests complete + * - ETS SD Protocol (§5.1.6 SD) + - 74 + - 60 + - 0 + - **14 tests blocked — ETS application required** + (ETS_089/096/097/103/146–151/164/166–168 require ETS C++ application) + * - ETS Robustness (§5.1.6 robustness) + - 14 + - 14 + - 0 + - **N/A** — all tests complete + * - ETS Serialization / Echo (§5.1.6 echo) + - 44 + - 0 + - 0 + - **ETS application + gatewayd** — see `ETS Application Gap`_ + * - ETS Client / Control (§5.1.6 client) + - 5 + - 3 + - 0 + - 2 of 5 require ETS control methods — see `ETS Application Gap`_ + +**Key points:** + +- The first three groups (181 specification items total) need only ``someipd`` + and the existing pytest framework. 167 of these items have + passing tests; 14 ETS SD items remain blocked pending the ETS C++ application + (see `ETS Application Gap`_). +- The last two groups (49 tests total) are **blocked**. They cannot be + written until a C++ ETS test application is implemented. See + `ETS Application Gap`_ for what is needed. + +SOME/IP Server Tests Coverage +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +These 93 tests check the SOME/IP wire protocol at the byte level. The DUT is +``someipd`` in standalone mode. Each test sends a raw UDP or TCP packet and +checks the DUT's response. **No C++ application code or gatewayd is needed.** + +The table below uses these status labels: + +- **Complete** — every specification item in this category has a passing test. + +All 93 SOMEIPSRV specification items have passing tests across the seven +subsections (SD Message Format, SD Options, SD Message Entries, SD Communication +Behavior, Basic Service Identifiers, On-Wire Format, Remote Procedure Call). + +ETS Tests Coverage +^^^^^^^^^^^^^^^^^^ + +The ETS test cases split into two tracks based on what infrastructure they +need. + +.. rubric:: Track A — Wire-level tests (88 items, pytest) + +These tests check the SOME/IP wire protocol directly. They use ``someipd`` +in standalone mode and send raw packets — exactly the same setup as the +SOMEIPSRV tests above. All wire-level ETS tests are now implemented; 14 +tests in the ETS SD Protocol group remain blocked pending the ETS C++ +application. + +**ETS SD Protocol (74 items)** + +This group covers Service Discovery at the wire level: FindService, +SubscribeEventgroup with various option types, NAck conditions, session ID +behavior, TTL expiry, reboot detection, and multicast/unicast interactions. + +*Status: 60 of 74 implemented (14 blocked — require ETS application).* + +All wire-level ETS SD tests that can run without the ETS C++ application +are now implemented. The 60 implemented tests cover session ID behavior, +FindService responses, subscribe edge cases, malformed SD entries and +options, TTL expiry, reboot detection, and multicast/unicast interactions. + +.. rubric:: ETS SD Protocol — Blocked Tests (14 items, require ETS application) + +The following 14 ETS SD test cases cannot be implemented without the ETS +C++ application: + +- ``SOMEIP_ETS_089`` — ``suspendInterface`` control method required +- ``SOMEIP_ETS_096`` — TCP connection prerequisite for subscription (needs + ETS app for TCP server) +- ``SOMEIP_ETS_097`` — TCP reconnection recovery (needs ETS app for TCP + server) +- ``SOMEIP_ETS_103`` — ``SD_ClientServiceGetLastValueOfEventTCP`` (TCP + event delivery, needs ETS app) +- ``SOMEIP_ETS_146`` — ``resetInterface`` control method required +- ``SOMEIP_ETS_147–151`` — ``triggerEvent*`` methods required (event push + triggers) +- ``SOMEIP_ETS_164`` — ``suspendInterface`` control method required +- ``SOMEIP_ETS_166–168`` — ``TestField*`` methods required (field + read/write via ETS app) + +These are tracked in `ETS Application Gap`_ and will be unblocked when the +ETS C++ application is implemented. + +**ETS Robustness (14 items)** + +These tests send wrong protocol versions, wrong message types, wrong IDs, +truncated messages, oversized messages, and unaligned packets. + +*Status: 14 of 14 implemented.* + +.. rubric:: Track B — Tests requiring an ETS application (49 items) + +.. _ETS Application Gap: + +These tests **cannot run yet** because they require a C++ test application +that does not exist. The tests cannot be written until that application is +built. This is the only infrastructure gap for ETS tests. + +**What is the ETS application?** + +It is a small C++ program (a ``score::mw::com`` Skeleton) that implements +the TC8 service interface defined in Section 5.1.4 of the specification. +The planned location is ``tests/tc8_conformance/application/`` (the +directory structure and README are already in place, but no code exists yet). +It must expose: + +- *Echo methods* — receive a value and return it unchanged + (``echoUINT8``, ``echoUINT8Array``, ``echoUTF8DYNAMIC``, ``echoUNION``, + and ~40 others). These let the tester verify that the full pipeline + (mw::com Skeleton → gatewayd → someipd → network) serializes every + SOME/IP data type correctly. +- *Event triggers* — fire an event on demand + (``triggerEventUINT8``, ``triggerEventUINT8Reliable``, etc.) +- *Field accessors* — getter, setter, and notifier for TC8 test fields +- *Control methods* — ``resetInterface``, ``suspendInterface``, + ``clientServiceActivate`` / ``clientServiceDeactivate`` + +**ETS Serialization / Echo (44 items)** ``SOMEIP_ETS_001–053, 063–073`` + +The tester sends an echo request with a specific data value. The DUT must +return the same value through the full pipeline. This validates the +**Payload Transformation** component inside ``gatewayd``. + +*Status: 0 of 44 implemented.* These tests cannot be written until both the +ETS application and the Payload Transformation component in gatewayd exist +and are working correctly. + +Data types covered by echo tests: UINT8, INT8, INT64, FLOAT64, arrays +(static and dynamic, 1D and 2D), strings (UTF-8 and UTF-16, fixed and +dynamic length), unions, enums, bitfields, E2E-protected messages, and +common data type combinations. + +**ETS Client / Control (5 items)** + +Three of these (``SOMEIP_ETS_081/082/084``) are already implemented in +``test_sd_client.py`` because they only need wire-level SD messages. The +remaining two (``SOMEIP_ETS_089/164``) use ``resetInterface`` and +``suspendInterface`` control methods, which require the ETS application. + +*Status: 3 of 5 implemented; 2 blocked on ETS application.* + +.. _known_stack_limitations: + +Known SOME/IP Stack Limitations +--------------------------------- + +The following table records the known limitations of **vsomeip 3.6.1** +against the OA TC8 v3.0 specification. This table must be reviewed and +updated whenever the SOME/IP stack version changes. + +Each test listed here is decorated with ``@pytest.mark.xfail(strict=True)`` +so that CI passes despite the known non-conformance. ``strict=True`` ensures +that if the limitation is fixed in a future stack version, the unexpected pass +(XPASS) will cause CI to fail, prompting removal of the marker. + +.. list-table:: + :header-rows: 1 + :widths: 25 35 30 10 + + * - OA Spec Reference + - Specification Requirement + - vsomeip 3.6.1 Actual Behaviour + - Test Result + * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_19 + - SubscribeEventgroup with reserved bits set in the entry MUST be + responded to with a NAck (SubscribeEventgroupAck with TTL = 0). + - Sends a positive SubscribeEventgroupAck (TTL > 0) regardless of + reserved bits. + - **XFAIL** — + ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_19_reserved_field_set`` + * - §5.1.5.5 — SOMEIPSRV_BASIC_03 + - When the DUT receives a message with method_id bit 15 = 1 (event + notification ID), it MUST NOT send a RESPONSE (message_type 0x80). + - Sends a RESPONSE (message_type 0x80) for event-ID messages even + though the spec prohibits it. + - **XFAIL** — + ``test_someip_message_format::TestSomeipBasicIdentifiers::test_basic_03_event_method_id_no_response`` + * - §5.1.5.7 — SOMEIPSRV_RPC_08 + - The DUT MUST NOT send a reply to a REQUEST message that already + carries a non-zero return code. + - Processes the REQUEST normally and sends a RESPONSE, ignoring the + return code field. + - **XFAIL** — + ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_08_request_with_error_return_code_no_reply`` diff --git a/score/gateway_ipc_binding/README.md b/score/gateway_ipc_binding/README.md index 7d37a27c1..0cc474510 100644 --- a/score/gateway_ipc_binding/README.md +++ b/score/gateway_ipc_binding/README.md @@ -1,17 +1,15 @@ - + SPDX-License-Identifier: Apache-2.0 +----------------------------------------------------------------------------- --> # Gateway IPC Binding diff --git a/score/gateway_ipc_binding/benchmark/README.md b/score/gateway_ipc_binding/benchmark/README.md index 9705a7e3f..f4a03e61e 100644 --- a/score/gateway_ipc_binding/benchmark/README.md +++ b/score/gateway_ipc_binding/benchmark/README.md @@ -1,17 +1,15 @@ - + SPDX-License-Identifier: Apache-2.0 +----------------------------------------------------------------------------- --> # Profiling Guide for gateway_ipc_binding diff --git a/tests/benchmarks/benchmarks_analysis.md b/tests/benchmarks/benchmarks_analysis.md index 666af2680..20673453d 100644 --- a/tests/benchmarks/benchmarks_analysis.md +++ b/tests/benchmarks/benchmarks_analysis.md @@ -1,17 +1,15 @@ - + SPDX-License-Identifier: Apache-2.0 +----------------------------------------------------------------------------- --> # S-Core SOME/IP Gateway IPC Performance Analysis diff --git a/tests/common/BUILD b/tests/common/BUILD new file mode 100644 index 000000000..ec7247e6a --- /dev/null +++ b/tests/common/BUILD @@ -0,0 +1,52 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@rules_python//python:defs.bzl", "py_library") +load("@score_tooling//:defs.bzl", "score_py_pytest") + +# --------------------------------------------------------------------------- +# Shared test utilities — consumed by both tests/integration_test and +# tests/tc8_conformance. imports=["."] adds tests/common/ to sys.path so +# downstream code can do: +# from capture import tcpdump_capture +# --------------------------------------------------------------------------- + +filegroup( + name = "all_files", + srcs = glob( + ["**"], + exclude = [ + "**/__pycache__/**", + "**/*.pyc", + ], + ), + visibility = ["//visibility:public"], +) + +py_library( + name = "capture", + srcs = ["capture.py"], + imports = ["."], + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//tests:__subpackages__"], +) + +score_py_pytest( + name = "test_capture", + size = "small", + srcs = ["test_capture.py"], + target_compatible_with = ["@platforms//os:linux"], + deps = [ + ":capture", + ], +) diff --git a/tests/common/capture.py b/tests/common/capture.py new file mode 100644 index 000000000..c36a7bdd8 --- /dev/null +++ b/tests/common/capture.py @@ -0,0 +1,204 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Host-side packet-capture helpers shared across test suites.""" + +import io +import os +import pwd +import shutil +import signal +import subprocess +import time +from typing import Any + + +def as_text(output: Any) -> str: + """Decode bytes to str; pass str through unchanged.""" + if isinstance(output, bytes): + return output.decode(errors="replace") + return str(output) + + +def _get_content_of_file_object(file_object: io.BufferedReader | None) -> str: + """Read available bytes from *file_object* in non-blocking mode; empty string on None.""" + if file_object is None: + return "" + + # Non-blocking so read() returns immediately instead of waiting for more data. + os.set_blocking(file_object.fileno(), False) + + data = file_object.read() + if data is None: + return "" + return data.decode(errors="replace") + + +def get_output(process: subprocess.Popen[bytes]) -> str: + """Return combined stdout + stderr from *process* as a single string.""" + return _get_content_of_file_object(process.stdout) + "\n, stderr: " + _get_content_of_file_object(process.stderr) + + +def wait_until_process_exits(process: subprocess.Popen[bytes], timeout: float = 10.0) -> str: + """Poll *process* until it exits or *timeout* seconds elapse. + + Returns the combined stdout+stderr output on success. + Raises ``TimeoutError`` if the process has not exited within *timeout* + seconds. + """ + start_time = time.time() + while time.time() - start_time < timeout: + if process.poll() is not None: + return get_output(process) + time.sleep(0.5) + raise TimeoutError(f"Process did not exit within {timeout} seconds. Last output: {get_output(process)}") + + +def stop_capture( + proc: subprocess.Popen[bytes], + timeout: float = 5.0, +) -> bool: + """Send SIGINT to *proc* (tcpdump flushes the pcap cleanly on SIGINT), wait, + fall back to SIGKILL + pkill sweep on timeout. + + After SIGKILL, ``pkill -9 -x tcpdump`` mops up any privilege-separation + child processes forked by tcpdump's -Z handling that survive a parent kill. + + Returns True if the process exited cleanly (or was already gone), False if + SIGKILL was required. + """ + if proc.poll() is not None: + return True # already exited + + try: + proc.send_signal(signal.SIGINT) + except ProcessLookupError: + return True # process exited between poll() and send_signal() + except PermissionError: + return False # EPERM from -Z sandbox barrier; process likely still alive + + try: + proc.wait(timeout=timeout) + return True + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + subprocess.run(["pkill", "-9", "-x", "tcpdump"], check=False) + return False + + +class CaptureProcess: + """Context manager wrapper around a tcpdump Popen. + + On context exit, sends SIGINT to flush the pcap cleanly, then falls back + to SIGKILL if the process does not terminate within the grace period. + If the pcap was written to a temporary /tmp path, it is moved to the + caller's requested destination after capture stops. + Direct attribute access (poll, kill, wait, returncode, …) is delegated to + the wrapped Popen so callers that store the object directly continue to work. + """ + + def __init__( + self, + proc: subprocess.Popen[bytes], + tmp_pcap: str | None = None, + final_pcap: str | None = None, + ) -> None: + self._proc = proc + self._tmp_pcap = tmp_pcap + self._final_pcap = final_pcap + + def __enter__(self) -> subprocess.Popen[bytes]: + return self._proc + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: Any, + ) -> None: + stop_capture(self._proc) + if self._tmp_pcap and self._final_pcap and self._tmp_pcap != self._final_pcap: + try: + shutil.move(self._tmp_pcap, self._final_pcap) + except OSError: + pass # best-effort; destination may be in a restricted sandbox path + + def __getattr__(self, name: str) -> Any: # noqa: ANN401 + return getattr(self._proc, name) + + +def tcpdump_capture( + filter_expression: str, + packet_count: int | None = None, + output_file: str | None = None, +) -> CaptureProcess: + """Start tcpdump on the host and return a CaptureProcess context manager. + + Args: + filter_expression: BPF filter string. + packet_count: Exit after this many packets; omit when using stop_capture. + output_file: Move the completed pcap to this path after capture stops; + None streams text to stdout. + + Raises: + RuntimeError: tcpdump exited immediately (missing binary or CAP_NET_RAW). + """ + # Pass -Z so tcpdump drops to the process's own uid rather + # than its compiled-in default user, which fails in CI without CAP_SETUID. + try: + _z_user = pwd.getpwuid(os.getuid()).pw_name + except KeyError: + # uid has no /etc/passwd entry (minimal container); use numeric fallback + _z_user = "root" if os.getuid() == 0 else str(os.getuid()) + + args = [ + "/usr/bin/tcpdump", + "-n", + "-i", + "any", + "-Z", + _z_user, + ] + + # Write pcap to /tmp, which is writable in all sandbox configurations. + # CaptureProcess.__exit__ moves it to output_file after capture stops. + tmp_pcap: str | None = None + if output_file is not None: + tmp_pcap = f"/tmp/tcpdump_{os.urandom(8).hex()}.pcap" + args.extend(["-U", "-w", tmp_pcap]) # -U: packet-buffered + else: + args.append("-l") # line-buffered output for text mode + + if packet_count is not None: + args.extend(["-c", str(packet_count)]) + if filter_expression: + args.append(filter_expression) + + proc = subprocess.Popen( + args, + stdout=subprocess.PIPE if output_file is None else subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + # tcpdump exits within milliseconds if CAP_NET_RAW is missing or binary absent. + time.sleep(0.2) + if proc.poll() is not None and proc.returncode != 0: + stderr_text = _get_content_of_file_object(proc.stderr) + raise RuntimeError( + f"tcpdump failed to start (exit code {proc.returncode}). " + f"Check that /usr/bin/tcpdump exists and the process has " + f"CAP_NET_RAW capability. stderr: {stderr_text}" + ) + + return CaptureProcess(proc, tmp_pcap=tmp_pcap, final_pcap=output_file) diff --git a/tests/common/test_capture.py b/tests/common/test_capture.py new file mode 100644 index 000000000..7e780621d --- /dev/null +++ b/tests/common/test_capture.py @@ -0,0 +1,500 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Unit tests for tests/common/capture.py. + +Covers as_text, _get_content_of_file_object, get_output, +wait_until_process_exits, stop_capture (ProcessLookupError branch, SIGINT +path, SIGKILL fallback + pkill sweep), and the CAP_NET_RAW startup-failure path. +All tests are host-only (no QEMU, no network, no CAP_NET_RAW required). +""" + +import io +import os +import subprocess + +import pytest + +import signal + +from capture import ( + as_text, + _get_content_of_file_object, + get_output, + stop_capture, + tcpdump_capture, + wait_until_process_exits, +) + + +# --------------------------------------------------------------------------- +# as_text +# --------------------------------------------------------------------------- + + +def test_as_text_decodes_bytes() -> None: + """Bytes are decoded to str with UTF-8 (replacement on error).""" + assert as_text(b"hello") == "hello" + + +def test_as_text_passes_str_through() -> None: + """Plain str is returned unchanged.""" + assert as_text("world") == "world" + + +def test_as_text_converts_other_types() -> None: + """Non-str, non-bytes values are converted via str().""" + assert as_text(42) == "42" + + +def test_as_text_handles_invalid_utf8() -> None: + """Invalid UTF-8 bytes are replaced rather than raising.""" + result = as_text(b"\xff\xfe") + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# _get_content_of_file_object +# --------------------------------------------------------------------------- + + +def test_get_content_of_file_object_none_returns_empty() -> None: + """None input returns an empty string without raising.""" + assert _get_content_of_file_object(None) == "" + + +def test_get_content_of_file_object_reads_pipe() -> None: + """Content written to a pipe is read back as a str.""" + proc = subprocess.Popen( + ["echo", "-n", "capture_test"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + proc.wait() + result = _get_content_of_file_object(proc.stdout) + assert "capture_test" in result + + +# --------------------------------------------------------------------------- +# get_output +# --------------------------------------------------------------------------- + + +def test_get_output_includes_stdout_and_stderr() -> None: + """get_output returns a string containing both stdout and stderr content.""" + proc = subprocess.Popen( + ["sh", "-c", "echo out; echo err >&2"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + proc.wait() + output = get_output(proc) + assert "out" in output + assert "err" in output + + +def test_get_output_empty_process() -> None: + """A process that writes nothing returns a string (may be empty or whitespace).""" + proc = subprocess.Popen( + ["true"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + proc.wait() + result = get_output(proc) + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# wait_until_process_exits +# --------------------------------------------------------------------------- + + +def test_wait_until_process_exits_returns_output() -> None: + """A process that exits immediately returns its output as a str.""" + proc = subprocess.Popen( + ["echo", "done"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output = wait_until_process_exits(proc, timeout=5.0) + assert isinstance(output, str) + assert proc.poll() is not None + + +def test_wait_until_process_exits_raises_on_timeout() -> None: + """TimeoutError is raised when the process does not exit in time.""" + proc = subprocess.Popen( + ["sleep", "60"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + with pytest.raises(TimeoutError): + wait_until_process_exits(proc, timeout=0.5) + finally: + proc.kill() + proc.wait() + + +# --------------------------------------------------------------------------- +# tcpdump_capture — argument validation and flag verification +# --------------------------------------------------------------------------- + + +def test_tcpdump_capture_output_file_without_packet_count_does_not_raise( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No error when output_file is set and packet_count is omitted; stop_capture handles teardown.""" + import capture as capture_module # noqa: PLC0415 + + original_popen = capture_module.subprocess.Popen + + def _popen_sleep(args: list, **kwargs): # type: ignore[override] + return original_popen(["sleep", "5"], **kwargs) + + monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_sleep) + + proc = tcpdump_capture("icmp", output_file="/tmp/test.pcap") + proc.kill() + proc.wait() + + +def test_tcpdump_capture_pcap_mode_includes_dash_u_flag( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """-U (packet-buffered) is included when output_file is set.""" + import capture as capture_module # noqa: PLC0415 + + original_popen = capture_module.subprocess.Popen + captured_args: list[list[str]] = [] + + def _popen_record(args: list, **kwargs): # type: ignore[override] + captured_args.append(list(args)) + return original_popen(["sleep", "5"], **kwargs) + + monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_record) + + proc = tcpdump_capture("icmp", output_file="/tmp/test.pcap") + proc.kill() + proc.wait() + + assert captured_args, "Popen was not called" + assert "-U" in captured_args[0], f"-U flag missing from tcpdump args: {captured_args[0]}" + + +def test_tcpdump_capture_text_mode_excludes_dash_u_flag( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """-U flag is NOT added in text mode (no output_file). + + In text mode stdout=PIPE is used; -U is a pcap-specific flag. + """ + import capture as capture_module # noqa: PLC0415 + + original_popen = capture_module.subprocess.Popen + captured_args: list[list[str]] = [] + + def _popen_record(args: list, **kwargs): # type: ignore[override] + captured_args.append(list(args)) + return original_popen(["sleep", "5"], **kwargs) + + monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_record) + + proc = tcpdump_capture("icmp") + proc.kill() + proc.wait() + + assert captured_args, "Popen was not called" + assert "-U" not in captured_args[0], f"-U should not be in text-mode args: {captured_args[0]}" + + +def test_tcpdump_capture_includes_z_flag_with_current_user( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """-Z is passed with the current username so tcpdump does not attempt to drop to its compiled-in default user (fails without CAP_SETUID in CI).""" + import os + import pwd + import capture as capture_module # noqa: PLC0415 + + original_popen = capture_module.subprocess.Popen + captured_args: list[list[str]] = [] + + def _popen_record(args: list, **kwargs): # type: ignore[override] + captured_args.append(list(args)) + return original_popen(["sleep", "5"], **kwargs) + + monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_record) + + proc = tcpdump_capture("icmp") + proc.kill() + proc.wait() + + assert captured_args, "Popen was not called" + args = captured_args[0] + assert "-Z" in args, f"-Z flag missing from tcpdump args: {args}" + + z_index = args.index("-Z") + assert z_index + 1 < len(args), "-Z flag has no argument" + z_user = args[z_index + 1] + + # The -Z argument must be the current user's name (prevents privilege drop + # to the compiled-in tcpdump user). + try: + expected_user = pwd.getpwuid(os.getuid()).pw_name + except KeyError: + expected_user = "root" if os.getuid() == 0 else str(os.getuid()) + + assert z_user == expected_user, f"-Z argument is '{z_user}', expected '{expected_user}'" + + +def test_tcpdump_capture_pcap_written_under_tmp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When output_file is set, tcpdump is told to write to /tmp (always writable). + + Writing directly to TEST_UNDECLARED_OUTPUTS_DIR fails in CI because the + bind-mounted testlogs path becomes unwritable after tcpdump's -Z credential drop. + /tmp is writable in all sandbox configurations (Linux + QNX8). + """ + import capture as capture_module # noqa: PLC0415 + + original_popen = capture_module.subprocess.Popen + captured_args: list[list[str]] = [] + + def _popen_record(args: list, **kwargs): # type: ignore[override] + captured_args.append(list(args)) + return original_popen(["sleep", "5"], **kwargs) + + monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_record) + + proc = tcpdump_capture("icmp", output_file="/some/output/dir/test.pcap") + proc.kill() + proc.wait() + + assert captured_args, "Popen was not called" + args = captured_args[0] + w_index = args.index("-w") + pcap_path = args[w_index + 1] + assert pcap_path.startswith("/tmp/"), f"tcpdump pcap path should be under /tmp, got: {pcap_path}" + + +# --------------------------------------------------------------------------- +# stop_capture +# --------------------------------------------------------------------------- + + +def test_stop_capture_returns_true_for_already_exited_process() -> None: + """stop_capture returns True immediately when the process has already exited.""" + proc = subprocess.Popen( + ["true"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + proc.wait() + result = stop_capture(proc, timeout=2.0) + assert result is True + + +def test_stop_capture_handles_permission_error() -> None: + """PermissionError from proc.send_signal (EPERM in sandbox) returns False. + + EPERM means the -Z privilege-drop created a signaling barrier; the process + is likely still alive (unlike ProcessLookupError, which returns True). + """ + proc = subprocess.Popen( + ["sleep", "60"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + original_send_signal = proc.send_signal + + def _raise_eperm(sig: int) -> None: + raise PermissionError("Operation not permitted (simulated)") + + proc.send_signal = _raise_eperm # type: ignore[method-assign] + + result = stop_capture(proc, timeout=1.0) + assert result is False + + # Process is still alive (signal was faked); restore and kill for real. + proc.send_signal = original_send_signal # type: ignore[method-assign] + proc.kill() + proc.wait() + + +def test_stop_capture_handles_process_lookup_error() -> None: + """ProcessLookupError from proc.send_signal is treated as 'process already gone'. + + Covers the race where the process exits between poll() returning None + and the send_signal() call. The correct response is True (not an exception). + """ + proc = subprocess.Popen( + ["sleep", "60"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + original_send_signal = proc.send_signal + + def _raise_esrch(sig: int) -> None: + raise ProcessLookupError("No such process (simulated)") + + proc.send_signal = _raise_esrch # type: ignore[method-assign] + + result = stop_capture(proc, timeout=1.0) + assert result is True + + # Process is still alive (signal was faked); restore and kill for real. + proc.send_signal = original_send_signal # type: ignore[method-assign] + proc.kill() + proc.wait() + + +def test_stop_capture_graceful_sigint_returns_true() -> None: + """stop_capture sends SIGINT; the process exits cleanly, returns True.""" + proc = subprocess.Popen( + ["sleep", "60"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + result = stop_capture(proc, timeout=2.0) + assert result is True + assert proc.poll() is not None, "process should have exited after stop_capture" + + +def test_stop_capture_kills_on_timeout_returns_false() -> None: + """stop_capture falls back to SIGKILL when SIGINT is ignored; returns False. + + A pipe gates SIGINT until SIG_IGN is installed, avoiding a startup race. + start_new_session=True isolates the child so SIGKILL does not reach the + test runner's process group. + """ + r_fd, w_fd = os.pipe() + proc = subprocess.Popen( + [ + "python3", + "-c", + ( + "import signal, time, os; " + "signal.signal(signal.SIGINT, signal.SIG_IGN); " + f"os.write({w_fd}, b'r'); " + "time.sleep(60)" + ), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + pass_fds=(w_fd,), + start_new_session=True, + ) + os.close(w_fd) + os.read(r_fd, 1) # Block until child has installed SIG_IGN. + os.close(r_fd) + + result = stop_capture(proc, timeout=0.3) + assert result is False + assert proc.poll() is not None, "process should be dead after SIGKILL fallback" + + +def test_stop_capture_sweeps_orphans_with_pkill( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """stop_capture calls pkill -9 -x tcpdump after SIGKILL to sweep orphan children. + + tcpdump's -Z privilege-separation may fork a child that survives when only + the parent PID is killed. pkill by name cleans up such orphans without + requiring process-group targeting (which raises EPERM in the CI sandbox). + """ + import capture as capture_module # noqa: PLC0415 + + pkill_calls: list[list[str]] = [] + original_run = capture_module.subprocess.run + + def _record_run(cmd: list, **kwargs): # type: ignore[override] + pkill_calls.append(list(cmd)) + return original_run(cmd, **kwargs) + + monkeypatch.setattr(capture_module.subprocess, "run", _record_run) + + r_fd, w_fd = os.pipe() + proc = subprocess.Popen( + [ + "python3", + "-c", + ( + "import signal, time, os; " + "signal.signal(signal.SIGINT, signal.SIG_IGN); " + f"os.write({w_fd}, b'r'); " + "time.sleep(60)" + ), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + pass_fds=(w_fd,), + start_new_session=True, + ) + os.close(w_fd) + os.read(r_fd, 1) + os.close(r_fd) + + result = stop_capture(proc, timeout=0.3) + assert result is False + + pkill_invoked = any( + len(cmd) >= 4 and cmd[0] == "pkill" and "-9" in cmd and "-x" in cmd and "tcpdump" in cmd for cmd in pkill_calls + ) + assert pkill_invoked, f"pkill -9 -x tcpdump not called after SIGKILL; subprocess.run calls: {pkill_calls}" + + +# --------------------------------------------------------------------------- +# tcpdump_capture — CAP_NET_RAW startup-failure detection +# --------------------------------------------------------------------------- + + +def test_tcpdump_capture_raises_on_immediate_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """RuntimeError is raised when tcpdump exits immediately (CAP_NET_RAW denied or binary absent).""" + import capture as capture_module # noqa: PLC0415 + + original_popen = capture_module.subprocess.Popen + + def _popen_false(args: list, **kwargs): # type: ignore[override] + return original_popen(["/usr/bin/false"], **kwargs) + + monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_false) + + with pytest.raises(RuntimeError, match="tcpdump failed to start"): + tcpdump_capture("icmp") + + +def test_tcpdump_capture_does_not_raise_on_immediate_clean_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No RuntimeError when tcpdump exits immediately with code 0 (e.g. packet_count=1 captured instantly). + + A clean zero-exit within the 0.2 s window is a valid completion (packet_count + satisfied), NOT a startup failure. Only non-zero exits indicate errors. + """ + import capture as capture_module # noqa: PLC0415 + + original_popen = capture_module.subprocess.Popen + + def _popen_true(args: list, **kwargs): # type: ignore[override] + return original_popen(["true"], **kwargs) + + monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_true) + + proc = tcpdump_capture("icmp") + proc.wait() diff --git a/tests/integration_test/README.md b/tests/integration_test/README.md index fd87b48ef..eac5c1505 100644 --- a/tests/integration_test/README.md +++ b/tests/integration_test/README.md @@ -1,17 +1,15 @@ - + SPDX-License-Identifier: Apache-2.0 +----------------------------------------------------------------------------- --> # Integration Tests diff --git a/tests/integration_test/conftest.py b/tests/integration_test/conftest.py index b243e9c07..3aff900ef 100644 --- a/tests/integration_test/conftest.py +++ b/tests/integration_test/conftest.py @@ -16,6 +16,7 @@ Pytest configuration and fixtures for integration tests. """ +import logging import os import pytest from typing import Generator @@ -40,15 +41,17 @@ def clean_state(target: Target) -> Generator[Target, None, None]: def gatewayd_with_someipd(clean_state: Target) -> Generator[Target, None, None]: """Start someipd and gatewayd before tests and stop them after.""" - # TODO tcpdump cannot be killed when the test is done. Need to figure out why - - # Store test traffic in file for later analysis of failures + # Store test traffic in file for later analysis of failures. + # tcpdump is kept alive for the entire fixture scope; the sandbox tears it + # down on exit so we do not call stop_capture here. pcap_dir = os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR", ".") pcap_file = os.path.join(pcap_dir, "test_traffic.pcap") - # no with statement is used by intention to avoid permission denied error when killing tcpdump - tcpdump_host = tcpdump_capture("", output_file=pcap_file) - tcpdump_process = tcpdump_host.__enter__() + try: + tcpdump_host = tcpdump_capture("", output_file=pcap_file) + tcpdump_host.__enter__() + except RuntimeError: + logging.warning("tcpdump could not start; pcap capture skipped") with ShellProcess( clean_state, diff --git a/tests/tc8_conformance/BUILD.bazel b/tests/tc8_conformance/BUILD.bazel new file mode 100644 index 000000000..df90f37da --- /dev/null +++ b/tests/tc8_conformance/BUILD.bazel @@ -0,0 +1,312 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@bazel_skylib//rules:native_binary.bzl", "native_binary") +load("@rules_pkg//pkg:mappings.bzl", "pkg_attributes", "pkg_files") +load("@rules_python//python:defs.bzl", "py_library") +load("@score_someip_gateway//bazel/tools:someip_config.bzl", "generate_someip_config_bin") +load("//bazel/tools:json_schema_validator.bzl", "validate_json_schema_test") +load("//quality/integration_testing:integration_testing.bzl", "integration_test") + +filegroup( + name = "all_files", + srcs = glob( + ["**"], + exclude = [ + "**/__pycache__/**", + "**/*.pyc", + ], + ), + visibility = ["//visibility:public"], +) + +# Linux only: QNX has no Python toolchain and is excluded from the QNX build sweep. +py_library( + name = "tc8-helpers-lib", + srcs = glob(["helpers/*.py"]), + imports = ["."], + target_compatible_with = ["@platforms//os:linux"], + deps = [ + "@score_someip_gateway_pip//someip", + ], +) + +pkg_files( + name = "tc8-vsomeip-runtime-pkg", + srcs = [ + "@vsomeip", + ], + attributes = pkg_attributes( + mode = "0777", + ), + prefix = "/usr", +) + +generate_someip_config_bin( + name = "tc8_someipd_config", + json = "config/tc8_someipd_config.json", + output = "tc8_someipd_config.bin", +) + +native_binary( + name = "someipd_tc8", + src = "//score/someipd", + out = "someipd", +) + +native_binary( + name = "gatewayd_tc8", + src = "//score/gatewayd", + out = "gatewayd", +) + +pkg_files( + name = "tc8-itf-pkg", + srcs = [ + "config/tc8_gatewayd_mw_com_config.json", + "config/tc8_logging.json", + "config/tc8_someipd_multi.json", + "config/tc8_someipd_sd.json", + "config/tc8_someipd_service.json", + ":gatewayd_tc8", + ":someipd_tc8", + ":tc8-vsomeip-runtime-pkg", + ":tc8_someipd_config", + "//score/serializer:null_serializer", + "//tests/tc8_conformance/application/ets_stub:config/tc8_ets_stub_mw_com_config.json", + "//tests/tc8_conformance/application/ets_stub:tc8_ets_stub", + ], + attributes = pkg_attributes( + mode = "0777", + ), + prefix = "/", + renames = { + "//tests/tc8_conformance/application/ets_stub:config/tc8_ets_stub_mw_com_config.json": "tc8_ets_stub_mw_com_config.json", + "config/tc8_gatewayd_mw_com_config.json": "tc8_gatewayd_mw_com_config.json", + "config/tc8_someipd_multi.json": "tc8_multi.json.tmpl", + "config/tc8_someipd_sd.json": "tc8_sd.json.tmpl", + "config/tc8_logging.json": "tc8_logging.json", + "config/tc8_someipd_service.json": "tc8_service.json.tmpl", + "tc8_someipd_config.bin": "tc8_someipd_config.bin", + "//score/serializer:null_serializer": "usr/lib/score_com_serializer.so", + }, +) + +# Linux only: QNX has no Python toolchain and is excluded from the QNX build sweep. +py_library( + name = "tc8-itf-lib", + srcs = ["tc8_itf_conftest.py"], + imports = ["."], + target_compatible_with = ["@platforms//os:linux"], + deps = [ + ":tc8-helpers-lib", + "@score_someip_gateway_pip//someip", + ], +) + +_ITF_ARGS = [ + "--color=yes", + "-p", + "tc8_itf_conftest", +] + +_ITF_DEPS = [ + ":tc8-itf-lib", + "//tests/common:capture", + "@score_tooling//python_basics/score_pytest:attribute_plugin", +] + +integration_test( + name = "test_tc8_service_discovery", + timeout = "long", + srcs = [ + "test_service_discovery.py", + ], + args = _ITF_ARGS, + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "tc8", + ], + deps = _ITF_DEPS, +) + +# exclusive: timing sensitive capture +integration_test( + name = "test_tc8_sd_phases_timing", + timeout = "long", + srcs = [ + "test_sd_phases_timing.py", + ], + args = _ITF_ARGS, + data = glob(["config/*.json"]), + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "exclusive", + "tc8", + ], + deps = _ITF_DEPS, +) + +# exclusive: lifecycle sensitive reboot detection +integration_test( + name = "test_tc8_sd_reboot", + timeout = "long", + srcs = [ + "test_sd_reboot.py", + ], + args = _ITF_ARGS, + data = glob(["config/*.json"]), + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "exclusive", + "tc8", + ], + deps = _ITF_DEPS, +) + +integration_test( + name = "test_tc8_sd_format_compliance", + timeout = "long", + srcs = [ + "test_sd_format_compliance.py", + ], + args = _ITF_ARGS, + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "tc8", + ], + deps = _ITF_DEPS, +) + +integration_test( + name = "test_tc8_sd_robustness", + timeout = "long", + srcs = [ + "test_sd_robustness.py", + ], + args = _ITF_ARGS, + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "tc8", + ], + deps = _ITF_DEPS, +) + +# exclusive: manages DUT lifecycle directly +integration_test( + name = "test_tc8_sd_client", + timeout = "long", + srcs = [ + "test_sd_client.py", + ], + args = _ITF_ARGS, + data = glob(["config/*.json"]), + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "exclusive", + "tc8", + ], + deps = _ITF_DEPS, +) + +integration_test( + name = "test_tc8_someip_message_format", + timeout = "long", + srcs = [ + "test_someip_message_format.py", + ], + args = _ITF_ARGS, + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "tc8", + ], + deps = _ITF_DEPS, +) + +integration_test( + name = "test_tc8_event_notification", + timeout = "long", + srcs = [ + "test_event_notification.py", + ], + args = _ITF_ARGS, + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "tc8", + ], + deps = _ITF_DEPS, +) + +integration_test( + name = "test_tc8_field_conformance", + timeout = "long", + srcs = [ + "test_field_conformance.py", + ], + args = _ITF_ARGS, + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "tc8", + ], + deps = _ITF_DEPS, +) + +integration_test( + name = "test_tc8_multi_service", + timeout = "long", + srcs = [ + "test_multi_service.py", + ], + args = _ITF_ARGS, + filesystem = ":tc8-itf-pkg", + tags = [ + "conformance", + "tc8", + ], + deps = _ITF_DEPS, +) + +[validate_json_schema_test( + name = cfg.replace(".json", "_schema_valid"), + size = "small", + json = "config/" + cfg, + schema = "config/tc8_vsomeip_config.schema.json", + tags = [ + "lint", + "tc8", + ], +) for cfg in [ + "tc8_someipd_sd.json", + "tc8_someipd_service.json", + "tc8_someipd_multi.json", +]] + +validate_json_schema_test( + name = "tc8_someipd_config_schema_valid", + size = "small", + json = "config/tc8_someipd_config.json", + schema = "//score/config:config_schema", + tags = [ + "lint", + "tc8", + ], +) diff --git a/tests/tc8_conformance/README.md b/tests/tc8_conformance/README.md new file mode 100644 index 000000000..75f82225b --- /dev/null +++ b/tests/tc8_conformance/README.md @@ -0,0 +1,203 @@ + + +# TC8 SOME/IP Conformance Tests + +Tests for the S-CORE SOME/IP Gateway based on +[OPEN Alliance TC8](https://www.opensig.org/about/specifications/). + +For architecture diagrams and design rationale, see +`docs/architecture/tc8_conformance_testing.rst`. + +## Test Scopes + +| Scope | Description | DUT | Status | +|---|---|---|---| +| **Protocol Conformance** | Wire-level SOME/IP (SD, messages, events, fields) | `someipd` standalone | Implemented (SD + MSG + EVT + FLD) | +| **Application-Level Tests** | End-to-end via mw::com through the gateway | gatewayd + someipd + C++ apps | [Planned](application/README.md) | + +Protocol conformance tests send/receive raw UDP packets using the Python `someip` library. +Application-level tests use C++ `mw::com` applications and work with any SOME/IP binding. + +For design rationale and UML dependency diagrams see +`docs/architecture/tc8_conformance_testing.rst`. For specification alignment +analysis, coverage status, and known stack limitations see +`docs/tc8_conformance/traceability.rst`. + +## Quick Start + +```bash +# Run all TC8 conformance tests (Linux) +bazel test --config=tc8-itf //tests/tc8_conformance/... + +# Run a specific target +bazel test --config=tc8-itf //tests/tc8_conformance:test_tc8_service_discovery + +# Run on QNX x86_64 +bazel test --config=tc8-itf-qnx //tests/tc8_conformance/... +``` + +## Network Setup + +Default port values and IP addresses are defined in `tc8_itf_conftest.py`. + +| Parameter | Default value | Source | +|---|---|---| +| SD multicast port (`TC8_SD_PORT`) | 30490 | `tc8_itf_conftest.py` | +| Service UDP port (`TC8_SVC_PORT`) | 30509 | `tc8_itf_conftest.py` | +| Service TCP port (`TC8_SVC_TCP_PORT`) | 30510 | `tc8_itf_conftest.py` | +| Host TAP IP (`TC8_TESTER_IP`) | auto-configured | `tc8_itf_conftest.py` | +| QEMU guest IP (`TC8_DUT_IP`) | auto-configured | `tc8_itf_conftest.py` | + +## Configuration Templates + +Each TC8 test area uses a SOME/IP stack config template. The DUT fixture +calls `render_someip_config()` to replace `__TC8_HOST_IP__` and `__TC8_SD_PORT__` +placeholders with the test host IP and a dynamically allocated SD port before +starting `someipd`. + +| Template | Used by | Key differences | +|---|---|---| +| `config/tc8_someipd_sd.json` | SD, SD-phases | Event `0x0777` (`is_field: true`, 2 s cycle), eventgroup `0x4455`, `cyclic_offer_delay=2000ms`, initial delay 10-100 ms, repetitions max 3; no TCP reliable port | +| `config/tc8_someipd_service.json` | MSG, EVT, FLD, TCP | Both events (0x0777 field + 0x0778 TCP-reliable), all 3 eventgroups (UDP 0x4455, multicast 0x4465, TCP 0x4475), TCP reliable port 30510, `cyclic_offer_delay=500ms` | +| `config/tc8_someipd_multi.json` | Multi-service | Two service entries for multi-service/instance config validation | +| `config/tc8_someipd_config.schema.json` | (all configs) | JSON Schema for validating TC8 vsomeip config templates | + +### Common Parameters + +| Parameter | Value | Purpose | +|---|---|---| +| Service ID | `0x1234` | DUT test service | +| Instance ID | `0x5678` | DUT test instance | +| SD multicast | `224.244.224.245` | SD capture endpoint | +| SD port | Dynamic (session-scoped) | Allocated at session start; enables parallel execution | +| Service UDP port | `30509` | SOME/IP data endpoint | +| Service TCP port | `30510` | TCP transport endpoint | +| `initial_delay_min/max` | 10-100 ms | SD phase tests | +| `ttl` | 30 s | Prevents expiry mid-test | + +### Creating a New Config + +Copy `tc8_someipd_sd.json` and change only the parameters that differ. +Keep `__TC8_HOST_IP__` and `__TC8_SD_PORT__` as placeholders so the fixture +can render them at test time. + +## Helper Modules + +| Module | Purpose | +|---|---| +| `helpers/sd_helpers.py` | SD multicast capture and `SOMEIPSDEntry` parsing | +| `helpers/sd_sender.py` | SD packet building (Find, Subscribe), unicast capture, auto-incrementing session IDs | +| `helpers/someip_assertions.py` | Field-level assertions for SD entries and SOME/IP response headers | +| `helpers/timing.py` | Timestamped offer capture for phase/cyclic analysis | +| `helpers/message_builder.py` | SOME/IP REQUEST/REQUEST_NO_RETURN packet construction and malformed packets | +| `helpers/event_helpers.py` | Event subscription (subscribe + wait Ack) and NOTIFICATION capture | +| `helpers/field_helpers.py` | Field GET/SET request helpers over UDP | +| `helpers/sd_malformed.py` | Malformed SD packet builders for robustness tests | +| `helpers/tcp_helpers.py` | TCP transport helpers (reliable binding, stream framing) | +| `helpers/udp_helpers.py` | UDP transport helpers (unreliable binding, length-field framing) | +| `helpers/dut_lifecycle.py` | DUT launch and teardown helpers for ITF and standalone modes | + +## Adding a New Test + +Every new test follows this pattern: + +1. **Create a config template** — copy `config/tc8_someipd_sd.json`, keep the + `__TC8_HOST_IP__` placeholder, and adjust service/event/timing parameters. + +2. **Add or extend helpers** — put reusable socket operations, packet builders, + and assertions in `helpers/`. Reuse existing helpers; do not duplicate. + +3. **Write the test module** — name it `test_.py`. Set `SOMEIP_CONFIG` + so the `someipd_dut` fixture picks up the right config: + + ```python + SOMEIP_CONFIG = "tc8_someipd_service.json" + ``` + + Decorate each test with `@add_test_properties` from `score_pytest` for + ISO 26262 traceability: + + ```python + from attribute_plugin import add_test_properties + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_xxx(self, ...) -> None: + """Docstring is mandatory (enforced by the plugin).""" + ``` + +4. **Register a Bazel target** — add an `integration_test()` entry in `BUILD.bazel` + following the pattern of existing entries, with tags `["tc8", "conformance"]`. + +5. **Add a requirement** — create a `comp_req` in + `docs/tc8_conformance/requirements.rst` referencing the TC8 clause. + +### Helper Conventions + +- One concern per module. +- Public functions only — no classes unless a `contextmanager` or `dataclass` is genuinely simpler. +- Callers close returned sockets (or use a `with`/`contextmanager` wrapper). +- Default timeouts: 5 s for single-message capture, 20 s for multi-message timing. +- Use `someip.header` for parsing and building. See `sd_sender.py` for the canonical pattern. + +## Directory Structure + +``` +tests/tc8_conformance/ +├── BUILD.bazel # integration_test() targets (one per TC8 test file) +├── README.md # This file +├── tc8_itf_conftest.py # ITF fixtures: someipd_dut, host_ip, dut_ip, tester_ip +├── test_service_discovery.py # TC8-SD-001 ... 008, 011, 013, 014 +├── test_sd_phases_timing.py # TC8-SD-009 / 010 +├── test_sd_reboot.py # TC8-SD-012 +├── test_sd_format_compliance.py # TC8-FORMAT_01 ... OPTIONS_14 (SD format & options) +├── test_sd_robustness.py # TC8 Group 4 — malformed SD message handling +├── test_sd_client.py # TC8-ETS_081/082/084 — SD client lifecycle +├── test_someip_message_format.py # TC8-MSG-001 ... 008 +├── test_event_notification.py # TC8-EVT-001 ... 006 +├── test_field_conformance.py # TC8-FLD-001 ... 004 +├── test_multi_service.py # SOMEIPSRV_RPC_13 — multi-service config +├── config/ +│ ├── tc8_someipd_sd.json # SD config template (slow 2 s cycle) +│ ├── tc8_someipd_service.json # Service config: MSG + EVT + FLD + TCP +│ ├── tc8_someipd_multi.json # Multi-service vsomeip config template +│ └── tc8_someipd_config.schema.json # JSON Schema for TC8 config validation +├── helpers/ +│ ├── __init__.py +│ ├── constants.py # Shared port/address constants +│ ├── dut_lifecycle.py # DUT launch and teardown (ITF + standalone) +│ ├── sd_helpers.py # SD capture + parsing +│ ├── sd_sender.py # SD packet building + unicast capture +│ ├── sd_malformed.py # Malformed SD packet builders (robustness) +│ ├── someip_assertions.py # Assertion helpers (SD + MSG) +│ ├── timing.py # Timestamped capture +│ ├── message_builder.py # SOME/IP message construction +│ ├── event_helpers.py # Event subscription + capture +│ ├── field_helpers.py # Field GET/SET helpers +│ ├── tcp_helpers.py # TCP transport (reliable binding) +│ └── udp_helpers.py # UDP transport (unreliable binding) +└── application/ # Enhanced testability (planned) + ├── README.md + ├── apps/ # C++ test apps (planned) + │ ├── tc8_service/ # mw::com skeleton + │ ├── tc8_client/ # mw::com proxy + │ └── config/ # mw_com + SOME/IP configs + └── helpers/ + └── process_orchestrator.py # Multi-process lifecycle (planned) +``` diff --git a/tests/tc8_conformance/application/README.md b/tests/tc8_conformance/application/README.md index f50e090af..e4c3a41c2 100644 --- a/tests/tc8_conformance/application/README.md +++ b/tests/tc8_conformance/application/README.md @@ -27,4 +27,5 @@ application/ ## See Also +- [Architecture](../../../docs/architecture/tc8_conformance_testing.rst) - Protocol conformance tests in the parent directory [`tests/tc8_conformance/`](../) diff --git a/tests/tc8_conformance/config/tc8_gatewayd_mw_com_config.json b/tests/tc8_conformance/config/tc8_gatewayd_mw_com_config.json new file mode 100644 index 000000000..ccb94e440 --- /dev/null +++ b/tests/tc8_conformance/config/tc8_gatewayd_mw_com_config.json @@ -0,0 +1,104 @@ +{ + "serviceTypes": [ + { + "serviceTypeName": "tc8_service", + "version": { + "major": 1, + "minor": 0 + }, + "bindings": [ + { + "binding": "SHM", + "serviceId": 4660, + "events": [ + { + "eventName": "TestEventUINT8", + "eventId": 1 + }, + { + "eventName": "TestEventUINT8Array", + "eventId": 2 + }, + { + "eventName": "TestEventUINT8Reliable", + "eventId": 3 + }, + { + "eventName": "TestEventUINT8E2E", + "eventId": 4 + }, + { + "eventName": "InterfaceVersion", + "eventId": 5 + }, + { + "eventName": "TestFieldUINT8", + "eventId": 6 + }, + { + "eventName": "TestFieldUINT8Array", + "eventId": 7 + }, + { + "eventName": "TestFieldUINT8Reliable", + "eventId": 8 + }, + { + "eventName": "TestEventUINT8Multicast", + "eventId": 9 + } + ] + } + ] + } + ], + "serviceInstances": [ + { + "instanceSpecifier": "tc8/tc8_service", + "serviceTypeName": "tc8_service", + "version": { + "major": 1, + "minor": 0 + }, + "instances": [ + { + "instanceId": 22136, + "asil-level": "QM", + "binding": "SHM", + "events": [ + { + "eventName": "TestEventUINT8" + }, + { + "eventName": "TestEventUINT8Array" + }, + { + "eventName": "TestEventUINT8Reliable" + }, + { + "eventName": "TestEventUINT8E2E" + }, + { + "eventName": "InterfaceVersion" + }, + { + "eventName": "TestFieldUINT8" + }, + { + "eventName": "TestFieldUINT8Array" + }, + { + "eventName": "TestFieldUINT8Reliable" + }, + { + "eventName": "TestEventUINT8Multicast" + } + ] + } + ] + } + ], + "global": { + "asil-level": "B" + } +} diff --git a/tests/tc8_conformance/config/tc8_logging.json b/tests/tc8_conformance/config/tc8_logging.json new file mode 100644 index 000000000..554a41ece --- /dev/null +++ b/tests/tc8_conformance/config/tc8_logging.json @@ -0,0 +1,7 @@ +{ + "appId": "TC8D", + "appDesc": "TC8 DUT", + "logLevel": "kInfo", + "logLevelThresholdConsole": "kInfo", + "logMode": "kConsole" +} diff --git a/tests/tc8_conformance/config/tc8_someipd_config.json b/tests/tc8_conformance/config/tc8_someipd_config.json new file mode 100644 index 000000000..99dd1e178 --- /dev/null +++ b/tests/tc8_conformance/config/tc8_someipd_config.json @@ -0,0 +1,99 @@ +{ + "service_types": [ + { + "service_type_name": "tc8_service", + "service_id": 4660, + "service_version_major": 1, + "service_version_minor": 0, + "events": [ + { + "event_name": "TestEventUINT8", + "event_id": 32769, + "eventgroup_ids": [2], + "serialization_config_type": "NullSerializerConfig", + "serialization_config": { + "max_message_size": 1 + } + }, + { + "event_name": "TestEventUINT8Array", + "event_id": 32770, + "eventgroup_ids": [2], + "serialization_config_type": "NullSerializerConfig", + "serialization_config": { + "max_message_size": 1 + } + }, + { + "event_name": "TestEventUINT8Reliable", + "event_id": 32771, + "eventgroup_ids": [2, 5], + "serialization_config_type": "NullSerializerConfig", + "serialization_config": { + "max_message_size": 1 + } + }, + { + "event_name": "TestEventUINT8E2E", + "event_id": 32772, + "eventgroup_ids": [2], + "serialization_config_type": "NullSerializerConfig", + "serialization_config": { + "max_message_size": 1 + } + }, + { + "event_name": "InterfaceVersion", + "event_id": 32773, + "eventgroup_ids": [2], + "serialization_config_type": "NullSerializerConfig", + "serialization_config": { + "max_message_size": 1 + } + }, + { + "event_name": "TestFieldUINT8", + "event_id": 32774, + "eventgroup_ids": [2], + "serialization_config_type": "NullSerializerConfig", + "serialization_config": { + "max_message_size": 1 + } + }, + { + "event_name": "TestFieldUINT8Array", + "event_id": 32775, + "eventgroup_ids": [2], + "serialization_config_type": "NullSerializerConfig", + "serialization_config": { + "max_message_size": 1 + } + }, + { + "event_name": "TestFieldUINT8Reliable", + "event_id": 32776, + "eventgroup_ids": [2], + "serialization_config_type": "NullSerializerConfig", + "serialization_config": { + "max_message_size": 1 + } + }, + { + "event_name": "TestEventUINT8Multicast", + "event_id": 32779, + "eventgroup_ids": [2, 6], + "serialization_config_type": "NullSerializerConfig", + "serialization_config": { + "max_message_size": 1 + } + } + ], + "local_service_instances": [ + { + "instance_specifier": "tc8/tc8_service", + "instance_id": 22136 + } + ] + } + ] +} diff --git a/tests/tc8_conformance/config/tc8_someipd_multi.json b/tests/tc8_conformance/config/tc8_someipd_multi.json new file mode 100644 index 000000000..e04337368 --- /dev/null +++ b/tests/tc8_conformance/config/tc8_someipd_multi.json @@ -0,0 +1,75 @@ +{ + "unicast": "__TC8_HOST_IP__", + "netmask": "255.255.0.0", + "logging": { + "level": "info", + "console": "true", + "file": { + "enable": "false", + "path": "__TC8_LOG_DIR__/vsomeip-tc8.log" + }, + "dlt": "false" + }, + "applications": [ + { + "name": "someipd", + "id": "0x1277" + } + ], + "services": [ + { + "service": "0x1234", + "instance": "0x5678", + "unreliable": "__TC8_SVC_PORT__", + "events": [ + { + "event": "0x0777", + "is_field": "true", + "update-cycle": 2000 + } + ], + "eventgroups": [ + { + "eventgroup": "0x4455", + "events": [ + "0x777" + ] + } + ] + }, + { + "service": "0x5678", + "instance": "0x0001", + "unreliable": "__TC8_SVC_TCP_PORT__", + "events": [ + { + "event": "0x0888", + "is_field": "true", + "update-cycle": 2000 + } + ], + "eventgroups": [ + { + "eventgroup": "0x4456", + "events": [ + "0x888" + ] + } + ] + } + ], + "routing": "someipd", + "service-discovery": { + "enable": "true", + "multicast": "224.244.224.245", + "port": "__TC8_SD_PORT__", + "protocol": "udp", + "initial_delay_min": "10", + "initial_delay_max": "100", + "repetitions_base_delay": "200", + "repetitions_max": "3", + "ttl": "30", + "cyclic_offer_delay": "2000", + "request_response_delay": "500" + } +} diff --git a/tests/tc8_conformance/config/tc8_someipd_sd.json b/tests/tc8_conformance/config/tc8_someipd_sd.json new file mode 100644 index 000000000..43226435e --- /dev/null +++ b/tests/tc8_conformance/config/tc8_someipd_sd.json @@ -0,0 +1,67 @@ +{ + "unicast": "__TC8_HOST_IP__", + "netmask": "255.255.0.0", + "logging": { + "level": "info", + "console": "true", + "file": { + "enable": "false", + "path": "__TC8_LOG_DIR__/vsomeip-tc8.log" + }, + "dlt": "false" + }, + "applications": [ + { + "name": "someipd", + "id": "0x1277" + } + ], + "services": [ + { + "service": "__TC8_SERVICE_ID__", + "instance": "__TC8_INSTANCE_ID__", + "major_version": "0x01", + "unreliable": "__TC8_SVC_PORT__", + "events": [ + { + "event": "0x8001", + "is_field": "true", + "update-cycle": 500 + } + ], + "eventgroups": [ + { + "eventgroup": "0x0002", + "events": [ + "0x8001" + ] + }, + { + "eventgroup": "0x0006", + "events": [ + "0x8001" + ], + "multicast": { + "address": "239.0.0.1", + "port": "40490" + }, + "threshold": "1" + } + ] + } + ], + "routing": "someipd", + "service-discovery": { + "enable": "true", + "multicast": "224.244.224.245", + "port": "__TC8_SD_PORT__", + "protocol": "udp", + "initial_delay_min": "10", + "initial_delay_max": "100", + "repetitions_base_delay": "200", + "repetitions_max": "3", + "ttl": "30", + "cyclic_offer_delay": "2000", + "request_response_delay": "500" + } +} diff --git a/tests/tc8_conformance/config/tc8_someipd_service.json b/tests/tc8_conformance/config/tc8_someipd_service.json new file mode 100644 index 000000000..7f475e7e1 --- /dev/null +++ b/tests/tc8_conformance/config/tc8_someipd_service.json @@ -0,0 +1,129 @@ +{ + "unicast": "__TC8_HOST_IP__", + "netmask": "255.255.0.0", + "logging": { + "level": "info", + "console": "true", + "file": { + "enable": "false", + "path": "__TC8_LOG_DIR__/vsomeip-tc8.log" + }, + "dlt": "false" + }, + "applications": [ + { + "name": "someipd", + "id": "0x1277" + } + ], + "services": [ + { + "service": "__TC8_SERVICE_ID__", + "instance": "__TC8_INSTANCE_ID__", + "unreliable": "__TC8_SVC_PORT__", + "reliable": "__TC8_SVC_TCP_PORT__", + "events": [ + { + "event": "0x8001", + "is_field": "true", + "is_reliable": "false", + "update-cycle": "500" + }, + { + "event": "0x8002", + "is_field": "false", + "is_reliable": "false", + "update-cycle": "500" + }, + { + "event": "0x8003", + "is_field": "false", + "is_reliable": "true", + "update-cycle": "500" + }, + { + "event": "0x8004", + "is_field": "false", + "is_reliable": "false", + "update-cycle": "500" + }, + { + "event": "0x8005", + "is_field": "true", + "is_reliable": "false", + "update-cycle": "0" + }, + { + "event": "0x8006", + "is_field": "true", + "is_reliable": "false", + "update-cycle": "0" + }, + { + "event": "0x8007", + "is_field": "true", + "is_reliable": "false", + "update-cycle": "0" + }, + { + "event": "0x8008", + "is_field": "true", + "is_reliable": "true", + "update-cycle": "0" + }, + { + "event": "0x800B", + "is_field": "false", + "is_reliable": "false", + "update-cycle": "500" + } + ], + "eventgroups": [ + { + "eventgroup": "0x0002", + "events": [ + "0x8001", + "0x8002", + "0x8003", + "0x8004", + "0x8005", + "0x8006", + "0x8007", + "0x8008", + "0x800B" + ] + }, + { + "eventgroup": "0x0005", + "events": [ + "0x8003" + ] + }, + { + "eventgroup": "0x0006", + "events": [ + "0x800B" + ], + "multicast": { + "address": "239.0.0.1", + "port": "40490" + } + } + ] + } + ], + "routing": "someipd", + "service-discovery": { + "enable": "true", + "multicast": "224.244.224.245", + "port": "__TC8_SD_PORT__", + "protocol": "udp", + "initial_delay_min": "10", + "initial_delay_max": "100", + "repetitions_base_delay": "200", + "repetitions_max": "3", + "ttl": "30", + "cyclic_offer_delay": "500", + "request_response_delay": "500" + } +} diff --git a/tests/tc8_conformance/config/tc8_vsomeip_config.schema.json b/tests/tc8_conformance/config/tc8_vsomeip_config.schema.json new file mode 100644 index 000000000..6364510c1 --- /dev/null +++ b/tests/tc8_conformance/config/tc8_vsomeip_config.schema.json @@ -0,0 +1,234 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "description": "JSON Schema for vsomeip configuration files used by TC8 conformance tests.", + "type": "object", + "required": [ + "unicast", + "applications", + "routing", + "services", + "service-discovery", + "logging" + ], + "additionalProperties": false, + "properties": { + "unicast": { + "type": "string", + "description": "Unicast IP address of the host running someipd. May be a placeholder string such as __TC8_HOST_IP__." + }, + "netmask": { + "type": "string", + "description": "Subnet mask used by vsomeip to validate that subscriber endpoint IPs are on the same subnet as the host. Default is 255.255.255.0 (/24). Set to 255.255.0.0 (/16) when the tester IP and DUT IP are in different /24 subnets (e.g. QEMU TAP: DUT 169.254.158.190, tester 169.254.21.88)." + }, + "applications": { + "type": "array", + "description": "List of vsomeip application registrations.", + "items": { + "type": "object", + "required": [ + "name", + "id" + ], + "properties": { + "name": { + "type": "string", + "description": "Application name used by vsomeip for routing." + }, + "id": { + "type": "string", + "description": "Hex-encoded application ID (e.g. 0x1277)." + } + }, + "additionalProperties": true + } + }, + "routing": { + "type": "string", + "description": "Name of the vsomeip routing manager application." + }, + "services": { + "type": "array", + "description": "List of SOME/IP service definitions offered by this application.", + "items": { + "type": "object", + "required": [ + "service", + "instance" + ], + "additionalProperties": false, + "properties": { + "service": { + "type": "string", + "description": "Hex-encoded SOME/IP service ID (e.g. 0x1234)." + }, + "instance": { + "type": "string", + "description": "Hex-encoded SOME/IP instance ID (e.g. 0x5678)." + }, + "major_version": { + "type": "string", + "description": "Major interface version as a hex string (e.g. 0x01). Used by vsomeip SD for version matching in SubscribeEventgroup." + }, + "unreliable": { + "type": "string", + "description": "UDP port number as a string (e.g. 30509)." + }, + "reliable": { + "type": "string", + "description": "TCP port number as a string (e.g. 30510)." + }, + "events": { + "type": "array", + "description": "Event definitions for this service.", + "items": { + "type": "object", + "required": [ + "event", + "is_field", + "update-cycle" + ], + "additionalProperties": true, + "properties": { + "event": { + "type": "string", + "description": "Hex-encoded event ID (e.g. 0x0777)." + }, + "is_field": { + "type": "string", + "description": "Whether this event is a field (getter/setter/notifier). Encoded as string 'true' or 'false'." + }, + "is_reliable": { + "type": "string", + "description": "Whether this event uses reliable (TCP) transport. Encoded as string 'true' or 'false'." + }, + "update-cycle": { + "description": "Notification cycle in milliseconds. May be a string or integer depending on vsomeip version.", + "type": [ + "string", + "integer" + ] + } + } + } + }, + "eventgroups": { + "type": "array", + "description": "Eventgroup definitions grouping one or more events.", + "items": { + "type": "object", + "required": [ + "eventgroup", + "events" + ], + "additionalProperties": true, + "properties": { + "eventgroup": { + "type": "string", + "description": "Hex-encoded eventgroup ID (e.g. 0x4455)." + }, + "events": { + "type": "array", + "description": "List of hex-encoded event IDs belonging to this eventgroup.", + "items": { + "type": "string" + } + }, + "multicast": { + "type": "object", + "description": "Optional multicast configuration for this eventgroup.", + "required": [ + "address", + "port" + ], + "properties": { + "address": { + "type": "string", + "description": "Multicast group IP address." + }, + "port": { + "type": "string", + "description": "Multicast port as a string." + } + }, + "additionalProperties": true + }, + "threshold": { + "type": "string", + "description": "Threshold for switching between unicast and multicast delivery." + } + } + } + } + } + } + }, + "service-discovery": { + "type": "object", + "description": "vsomeip service discovery (SD) configuration. All numeric values are encoded as strings.", + "required": [ + "enable", + "multicast", + "port", + "protocol", + "initial_delay_min", + "initial_delay_max", + "repetitions_base_delay", + "repetitions_max", + "ttl", + "cyclic_offer_delay", + "request_response_delay" + ], + "additionalProperties": false, + "properties": { + "enable": { + "type": "string", + "description": "Enable service discovery. String 'true' or 'false'." + }, + "multicast": { + "type": "string", + "description": "SD multicast group address." + }, + "port": { + "type": "string", + "description": "SD port as a string (e.g. '30490'). Must equal the source port used by all SD senders; the SOME/IP-SD stack drops messages from other source ports." + }, + "protocol": { + "type": "string", + "description": "SD transport protocol ('udp' or 'tcp')." + }, + "initial_delay_min": { + "type": "string", + "description": "Minimum initial offer delay in milliseconds." + }, + "initial_delay_max": { + "type": "string", + "description": "Maximum initial offer delay in milliseconds." + }, + "repetitions_base_delay": { + "type": "string", + "description": "Base delay for offer repetitions in milliseconds." + }, + "repetitions_max": { + "type": "string", + "description": "Maximum number of offer repetitions." + }, + "ttl": { + "type": "string", + "description": "SD entry time-to-live in seconds." + }, + "cyclic_offer_delay": { + "type": "string", + "description": "Cyclic main-phase offer delay in milliseconds." + }, + "request_response_delay": { + "type": "string", + "description": "Request/response delay in milliseconds." + } + } + }, + "logging": { + "type": "object", + "description": "vsomeip logging configuration. Permissive — additional keys are allowed." + } + } +} diff --git a/tests/tc8_conformance/helpers/__init__.py b/tests/tc8_conformance/helpers/__init__.py new file mode 100644 index 000000000..86ac71d4b --- /dev/null +++ b/tests/tc8_conformance/helpers/__init__.py @@ -0,0 +1,13 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 conformance test helper modules.""" diff --git a/tests/tc8_conformance/helpers/constants.py b/tests/tc8_conformance/helpers/constants.py new file mode 100644 index 000000000..44fbd5bb5 --- /dev/null +++ b/tests/tc8_conformance/helpers/constants.py @@ -0,0 +1,136 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Shared network constants for TC8 conformance tests. + +Single source of truth for port numbers and multicast addresses used +across all TC8 test modules and helpers. Import from here instead of +hardcoding literals in individual files. + +Port isolation for parallel Bazel execution +------------------------------------------- +Each Bazel TC8 target runs in its own OS process and receives unique port +values via the Bazel ``env`` attribute. The three port constants below read +from environment variables at **module import time**, which means every +helper that ``from helpers.constants import SD_PORT`` gets the correct +per-process value with no function-signature changes. + +Defaults reproduce the historical static values so that local developer +runs (without Bazel, no env vars set) continue to work unchanged. +""" + +import os + +#: SOME/IP Service Discovery port (UDP). Both DUT and tester must bind +#: to this port. The SOME/IP-SD stack drops SD packets arriving from any +#: source port other than the configured SD port. Read from ``TC8_SD_PORT`` +#: env var; defaults to 30490 (the well-known SOME/IP-SD port) for local +#: development. +SD_PORT: int = int(os.environ.get("TC8_SD_PORT", "30490")) + +#: SOME/IP-SD multicast group address (all SOME/IP nodes join this group). +SD_MULTICAST_ADDR: str = "224.244.224.245" + +#: DUT unreliable (UDP) service port — matches the ``unreliable`` port in +#: the DUT's ``tc8_someipd_*.json`` configuration templates. Read from +#: ``TC8_SVC_PORT`` env var; defaults to 30509 for local development. +DUT_UNRELIABLE_PORT: int = int(os.environ.get("TC8_SVC_PORT", "30509")) + +#: DUT reliable (TCP) service port — matches the ``reliable`` port in +#: the DUT's ``tc8_someipd_*.json`` configuration templates. Read from +#: ``TC8_SVC_TCP_PORT`` env var; defaults to 30510 for local development. +DUT_RELIABLE_PORT: int = int(os.environ.get("TC8_SVC_TCP_PORT", "30510")) + +# --------------------------------------------------------------------------- +# ETS service identity +# --------------------------------------------------------------------------- +SERVICE_ID: int = int(os.environ.get("TC8_SERVICE_ID", "0x1234"), 16) +INSTANCE_ID: int = int(os.environ.get("TC8_INSTANCE_ID", "0x5678"), 16) +MAJOR_VERSION: int = 1 +MINOR_VERSION: int = 0 + +# --------------------------------------------------------------------------- +# ETS Sec. 6.1.4.2 event and field notification IDs +# --------------------------------------------------------------------------- +EVENT_TEST_UINT8: int = 0x8001 +EVENT_TEST_UINT8_ARRAY: int = 0x8002 +EVENT_TEST_UINT8_RELIABLE: int = 0x8003 +EVENT_TEST_UINT8_E2E: int = 0x8004 +EVENT_INTERFACE_VERSION: int = 0x8005 +EVENT_FIELD_UINT8: int = 0x8006 +EVENT_FIELD_UINT8_ARRAY: int = 0x8007 +EVENT_FIELD_UINT8_RELIABLE: int = 0x8008 +EVENT_TEST_UINT8_MULTICAST: int = 0x800B + +# --------------------------------------------------------------------------- +# ETS Sec. 6.1.4.1 method IDs +# --------------------------------------------------------------------------- + +# Fire-and-forget trigger / control methods +METHOD_RESET_INTERFACE: int = 0x01 +METHOD_SUSPEND_INTERFACE: int = 0x02 +METHOD_TRIGGER_EVENT_UINT8: int = 0x03 +METHOD_TRIGGER_EVENT_UINT8_ARRAY: int = 0x04 +METHOD_TRIGGER_EVENT_UINT8_RELIABLE: int = 0x05 +METHOD_TRIGGER_EVENT_UINT8_E2E: int = 0x06 +METHOD_CLIENT_SERVICE_ACTIVATE: int = 0x2F +METHOD_CLIENT_SERVICE_DEACTIVATE: int = 0x30 +METHOD_CLIENT_SERVICE_SUBSCRIBE_EVENTGROUP: int = 0x32 +METHOD_TRIGGER_EVENT_UINT8_MULTICAST: int = 0x3A + +# Echo / RPC methods +METHOD_ECHO_UINT8: int = 0x08 +METHOD_ECHO_UINT8_ARRAY: int = 0x09 +METHOD_ECHO_UINT8_RELIABLE: int = 0x0A +METHOD_ECHO_UINT8_E2E: int = 0x0B +METHOD_ECHO_INT8: int = 0x0E +METHOD_ECHO_FLOAT64: int = 0x12 +METHOD_ECHO_UTF8_FIXED: int = 0x13 +METHOD_ECHO_UTF16_FIXED: int = 0x14 +METHOD_ECHO_UTF8_DYNAMIC: int = 0x15 +METHOD_ECHO_UTF16_DYNAMIC: int = 0x16 +METHOD_ECHO_ENUM: int = 0x17 +METHOD_ECHO_UNION: int = 0x19 +METHOD_CHECK_BYTE_ORDER: int = 0x1F +METHOD_ECHO_COMMON_DATATYPES: int = 0x23 +METHOD_ECHO_INT64: int = 0x34 +METHOD_ECHO_UINT8_ARRAY_2DIM: int = 0x35 +METHOD_ECHO_STATIC_UINT8_ARRAY: int = 0x36 +METHOD_ECHO_UINT8_ARRAY_MIN_SIZE: int = 0x37 +METHOD_CLIENT_SERVICE_GET_LAST_TCP: int = 0x3B +METHOD_CLIENT_SERVICE_GET_LAST_UDP_UNICAST: int = 0x3C +METHOD_CLIENT_SERVICE_GET_LAST_UDP_MULTICAST: int = 0x3D +METHOD_ECHO_UINT8_ARRAY_8BIT_LENGTH: int = 0x3E +METHOD_ECHO_UINT8_ARRAY_16BIT_LENGTH: int = 0x3F +METHOD_ECHO_BITFIELDS: int = 0x41 + +# Field getter / setter methods +METHOD_INTERFACE_VERSION_GET: int = 0x25 +METHOD_FIELD_UINT8_GET: int = 0x26 +METHOD_FIELD_UINT8_SET: int = 0x27 +METHOD_FIELD_UINT8_ARRAY_GET: int = 0x28 +METHOD_FIELD_UINT8_ARRAY_SET: int = 0x29 +METHOD_FIELD_UINT8_RELIABLE_GET: int = 0x2A +METHOD_FIELD_UINT8_RELIABLE_SET: int = 0x2B + +# --------------------------------------------------------------------------- +# ETS Sec. 6.1.4.2 eventgroup IDs +# --------------------------------------------------------------------------- +EVENTGROUP_UDP_UNICAST: int = 0x0002 +EVENTGROUP_TCP_RELIABLE: int = 0x0005 # mixed: UDP events + TestEventUINT8Reliable (TCP) +EVENTGROUP_UDP_MULTICAST: int = 0x0006 + +# --------------------------------------------------------------------------- +# Multicast transport +# --------------------------------------------------------------------------- +MULTICAST_ADDR: str = "239.0.0.1" +MULTICAST_EVENT_PORT: int = 40490 diff --git a/tests/tc8_conformance/helpers/dut_lifecycle.py b/tests/tc8_conformance/helpers/dut_lifecycle.py new file mode 100644 index 000000000..82e5fd039 --- /dev/null +++ b/tests/tc8_conformance/helpers/dut_lifecycle.py @@ -0,0 +1,340 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""DUT lifecycle helpers shared by TC8 tests that manage the DUT stack directly. + +Used by ``test_sd_client.py``, ``test_sd_reboot.py``, and +``test_sd_phases_timing.py``. All functions operate in ITF (QEMU) mode: +*target_init* is a ``QemuTarget`` provided by the ITF framework and +commands execute on the QEMU guest via SSH. +""" + +import glob +import logging +import os +import socket +import struct +import time +from pathlib import Path +from typing import Optional, Union + +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Config name → rendered guest path mapping +# --------------------------------------------------------------------------- + +#: Maps the template filename used in BUILD.bazel ``env`` to the rendered path +#: on the QEMU guest (written by ``tc8_itf_config_setup`` via sed). +_GUEST_CONFIG_MAP: dict[str, str] = { + "tc8_someipd_sd.json": "tc8_sd.json", + "tc8_someipd_service.json": "tc8_service.json", + "tc8_someipd_multi.json": "tc8_multi.json", +} + + +# --------------------------------------------------------------------------- +# Popen-compatible adapter for ITF AsyncProcess +# --------------------------------------------------------------------------- + + +class _TargetProcess: + """Wraps an ITF ``AsyncProcess`` with a ``subprocess.Popen``-compatible API. + + TC8 lifecycle helpers call ``proc.terminate()`` and ``proc.wait()`` on the + returned process object. + """ + + def __init__( + self, + proc: object, + target_init: object = None, + secondary_proc: object = None, + stub_proc: object = None, + ) -> None: + self._proc = proc + self._target_init = target_init + self._secondary_proc = secondary_proc + self._stub_proc = stub_proc + + def poll(self) -> Optional[int]: + """Return ``None`` while running, ``0`` after the process has stopped.""" + return None if self._proc.is_running() else 0 # type: ignore[attr-defined] + + def terminate(self) -> None: + """Stop the remote processes (someipd and gatewayd). + + Force-kills both binaries on the QEMU guest via ``pkill -9`` before + calling ``proc.stop()``. ``pkill`` is portable: procps on Linux, + ``slay`` symlink on QNX8 (pgrep absent from the QNX IFS). + + Failures are logged as warnings and do not re-raise so teardown + does not fail the test. + """ + if self._target_init is not None: + try: + self._target_init.execute( # type: ignore[attr-defined] + "pkill -9 someipd 2>/dev/null || true; " + "pkill -9 gatewayd 2>/dev/null || true; " + "pkill -9 tc8_ets_stub 2>/dev/null || true" + ) + except Exception: # noqa: BLE001 + _logger.warning("force-kill of someipd/gatewayd on QEMU guest failed; continuing teardown") + try: + self._proc.stop() # type: ignore[attr-defined] + except RuntimeError as exc: + _logger.warning("AsyncProcess.stop() raised during teardown (ignored): %s", exc) + if self._secondary_proc is not None: + try: + self._secondary_proc.stop() # type: ignore[attr-defined] + except RuntimeError as exc: + _logger.warning( + "AsyncProcess.stop() for secondary proc raised during teardown (ignored): %s", + exc, + ) + if self._stub_proc is not None: + try: + self._stub_proc.stop() # type: ignore[attr-defined] + except RuntimeError as exc: + _logger.warning( + "AsyncProcess.stop() for stub proc raised during teardown (ignored): %s", + exc, + ) + + def kill(self) -> None: + """Alias for terminate — no separate SIGKILL equivalent in ITF.""" + self.terminate() + + def wait(self, timeout: Optional[float] = None) -> int: # noqa: ARG002 + """No-op: ``stop()`` is already synchronous.""" + return 0 + + @property + def stdout(self) -> None: + return None + + @property + def stderr(self) -> None: + return None + + @property + def returncode(self) -> Optional[int]: + return 0 if not self._proc.is_running() else None # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Config helpers +# --------------------------------------------------------------------------- + + +def render_someip_config( + config_name: str, + host_ip: str, + dest_dir: Path, + service_id: str = "", + instance_id: str = "", +) -> Path: + """Replace ``__TC8_HOST_IP__``, ``__TC8_SERVICE_ID__``, + ``__TC8_INSTANCE_ID__``, ``__TC8_SD_PORT__``, ``__TC8_SVC_PORT__``, + ``__TC8_SVC_TCP_PORT__``, and ``__TC8_LOG_DIR__`` in a config template. + + Writes the rendered config to *dest_dir* and returns the path. + + In ITF mode this function is called but the resulting file is not used + (the QEMU guest already has its configs rendered by ``tc8_itf_config_setup`` + via sed). The call is kept so that the ``sd_client_config`` fixture + signature remains unchanged. + """ + sd_port = os.environ.get("TC8_SD_PORT", "30490") + svc_port = os.environ.get("TC8_SVC_PORT", "30509") + svc_tcp_port = os.environ.get("TC8_SVC_TCP_PORT", "30510") + if not service_id: + service_id = os.environ.get("TC8_SERVICE_ID", "0x1234") + if not instance_id: + instance_id = os.environ.get("TC8_INSTANCE_ID", "0x5678") + template_path = Path(__file__).parent.parent / "config" / config_name + rendered = ( + template_path.read_text(encoding="utf-8") + .replace("__TC8_HOST_IP__", host_ip) + .replace("__TC8_SERVICE_ID__", service_id) + .replace("__TC8_INSTANCE_ID__", instance_id) + .replace("__TC8_SD_PORT__", sd_port) + .replace("__TC8_SVC_PORT__", svc_port) + .replace("__TC8_SVC_TCP_PORT__", svc_tcp_port) + .replace("__TC8_LOG_DIR__", str(dest_dir)) + ) + config_path = dest_dir / config_name + config_path.write_text(rendered, encoding="utf-8") + return config_path + + +# --------------------------------------------------------------------------- +# DUT launch / teardown +# --------------------------------------------------------------------------- + + +def launch_dut( + config_path: Union[Path, str], + target_init: object = None, +) -> object: + """Start the full DUT stack (someipd, tc8_ets_stub, gatewayd) on the QEMU guest. + + *target_init* is a ``QemuTarget`` provided by the ITF framework. The + *config_path* filename selects the pre-rendered guest vsomeip config + (written to ``/tmp`` by ``tc8_itf_config_setup`` via sed, session-scoped). + + someipd is started first so it becomes the vsomeip routing manager. + gatewayd is started immediately after; it retries the IPC handshake + internally until someipd is ready. + + Returns a ``_TargetProcess`` adapter whose ``.terminate()`` / ``.wait()`` + interface is compatible with :func:`terminate_dut`. Calling + ``.terminate()`` kills all three binaries and stops their async process handles. + """ + if target_init is not None: + # ITF path: production stack runs on the QEMU guest. Configs are pre-rendered. + name = Path(config_path).name if isinstance(config_path, Path) else str(config_path) + guest_config = _GUEST_CONFIG_MAP.get(name, "tc8_sd.json") + + # 1. Start someipd — it becomes the vsomeip routing manager. + someipd_proc = target_init.execute_async( # type: ignore[attr-defined] + f"LD_LIBRARY_PATH=/ " + f"VSOMEIP_CONFIGURATION=/tmp/{guest_config} " + f"MW_LOG_CONFIG_FILE=/tc8_logging.json " + f"/someipd -c /tc8_someipd_config.bin" + ) + + # 2. Start the ETS stub — provides the mw::com skeleton so gatewayd's + # StartFindService callback fires and gatewayd calls offer_event() in vsomeip. + stub_proc = target_init.execute_async( # type: ignore[attr-defined] + f"MW_LOG_CONFIG_FILE=/tc8_logging.json /tc8_ets_stub -s /tc8_ets_stub_mw_com_config.json" + ) + + # 3. Start gatewayd — connects to someipd IPC server, discovers the stub, + # and calls offer_event() so vsomeip advertises the service. + gatewayd_proc = target_init.execute_async( # type: ignore[attr-defined] + f"MW_LOG_CONFIG_FILE=/tc8_logging.json " + f"/gatewayd -c /tc8_someipd_config.bin -s /tc8_gatewayd_mw_com_config.json" + ) + + return _TargetProcess( + someipd_proc, + target_init=target_init, + secondary_proc=gatewayd_proc, + stub_proc=stub_proc, + ) + + raise RuntimeError("launch_dut: target_init must be provided (ITF mode only)") + + +def terminate_dut(proc: object) -> None: + """Terminate the DUT stack and close its pipes.""" + proc.terminate() # type: ignore[attr-defined] + proc.wait(timeout=5) # type: ignore[attr-defined] + if proc.stdout: # type: ignore[attr-defined] + proc.stdout.close() # type: ignore[attr-defined] + if proc.stderr: # type: ignore[attr-defined] + proc.stderr.close() # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# vsomeip socket cleanup +# --------------------------------------------------------------------------- + + +def cleanup_vsomeip_sockets( + base_path: str = "/tmp", + target_init: object = None, +) -> None: + """Remove stale vsomeip routing-manager sockets. + + In **legacy mode** (*target_init* is ``None``) removes + ``/vsomeip-*`` socket files on the host. + + In **ITF mode** (*target_init* is a ``QemuTarget``) runs + ``rm -f /tmp/vsomeip-*`` on the QEMU guest via SSH. + """ + if target_init is not None: + # Remove stale sockets from both paths: + # Linux QEMU : /tmp/vsomeip-* + # QNX8 QEMU : /var/run/vsomeip-* (vsomeip-qnx8.patch sets VSOMEIP_BASE_PATH="/var/run") + target_init.execute("rm -f /tmp/vsomeip-* /var/run/vsomeip-*") # type: ignore[attr-defined] + return + for stale in glob.glob(f"{base_path}/vsomeip-*"): + try: + os.unlink(stale) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# SD readiness gate (host-side — works in both legacy and ITF modes) +# --------------------------------------------------------------------------- + + +def wait_for_sd_readiness( + host_ip: str, + timeout_secs: float = 10.0, +) -> bool: + """Wait until the DUT sends at least one multicast OfferService. + + Opens a short-lived multicast socket on *host_ip* (host TAP interface), + joins the SD multicast group, and returns ``True`` as soon as a SOME/IP-SD + OfferService entry is received. Returns ``False`` on timeout. + + Port and multicast address are read from ``helpers.constants`` so they + stay in sync with the vsomeip config templates. + """ + from helpers.constants import SD_MULTICAST_ADDR, SD_PORT # noqa: PLC0415 + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + pass + sock.bind(("", SD_PORT)) + group_bytes = socket.inet_aton(SD_MULTICAST_ADDR) + iface_bytes = socket.inet_aton(host_ip) + mreq = struct.pack("4s4s", group_bytes, iface_bytes) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + deadline = time.monotonic() + timeout_secs + try: + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 1.0)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + if len(data) < 20: + continue + service_id = int.from_bytes(data[0:2], "big") + if service_id != 0xFFFF: + continue + sd_offset = 16 + if len(data) < sd_offset + 12: + continue + entries_len = int.from_bytes(data[sd_offset + 4 : sd_offset + 8], "big") + entry_start = sd_offset + 8 + pos = entry_start + while pos + 16 <= entry_start + entries_len and pos + 16 <= len(data): + entry_type = data[pos] + if entry_type == 0x01: # OfferService + return True + pos += 16 + return False + finally: + sock.close() diff --git a/tests/tc8_conformance/helpers/event_helpers.py b/tests/tc8_conformance/helpers/event_helpers.py new file mode 100644 index 000000000..62691d6a0 --- /dev/null +++ b/tests/tc8_conformance/helpers/event_helpers.py @@ -0,0 +1,225 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Event subscription and notification capture for TC8 conformance tests. + +Subscribes to eventgroups via SD and captures NOTIFICATION messages. +""" + +import socket +import time +from typing import List + +from someip.header import SOMEIPHeader, SOMEIPMessageType, L4Protocols + +from helpers.sd_sender import ( + open_sender_socket, + send_subscribe_eventgroup, + capture_unicast_sd_entries, + SOMEIPSDEntryType, +) + + +def subscribe_and_wait_ack( + tester_ip: str, + host_ip: str, + sd_port: int, + service_id: int, + instance_id: int, + eventgroup_id: int, + major_version: int, + notif_port: int, + timeout_secs: float = 5.0, + ttl: int = 3, +) -> socket.socket: + """Subscribe to an eventgroup and wait for the Ack. + + Returns the SD socket (still open). Caller must close it. + Raises AssertionError if no Ack is received. + + *ttl* controls the SD SubscribeEventgroup entry TTL (seconds). Use a + larger value (e.g. 30) when the test collects notifications over an + interval longer than the default 3-second window. + """ + sd_sock = open_sender_socket(tester_ip) + try: + + def _send_sub() -> None: + send_subscribe_eventgroup( + sd_sock, + (host_ip, sd_port), + service_id, + instance_id, + eventgroup_id, + major_version, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ttl=ttl, + ) + + _send_sub() + entries = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=timeout_secs, + resend=_send_sub, + max_results=1, # Return as soon as first ACK arrives; preserves subscription TTL. + ) + acks = [e for e in entries if e.eventgroup_id == eventgroup_id and e.ttl > 0] + assert acks, f"No SubscribeEventgroupAck received for eventgroup 0x{eventgroup_id:04x}" + except Exception: + sd_sock.close() + raise + return sd_sock + + +def _parse_datagram(data: bytes) -> List[SOMEIPHeader]: + """Return all SOME/IP messages packed into a single UDP datagram. + + vsomeip bundles multiple SOME/IP NOTIFICATIONs in one UDP datagram. + SOMEIPHeader.parse() returns (message, remaining_bytes); looping over + the remainder ensures every message in the datagram is inspected. + """ + messages: List[SOMEIPHeader] = [] + buf = data + while buf: + try: + msg, buf = SOMEIPHeader.parse(buf) + messages.append(msg) + except Exception: + break + return messages + + +def capture_notifications( + sock: socket.socket, + event_id: int, + service_id: int, + count: int = 1, + timeout_secs: float = 5.0, +) -> List[SOMEIPHeader]: + """Capture NOTIFICATION messages for a specific event on *sock*. + + Returns up to *count* matching notifications within *timeout_secs*. + Each UDP datagram may carry multiple bundled SOME/IP messages; + all of them are inspected. + """ + collected: List[SOMEIPHeader] = [] + deadline = time.monotonic() + timeout_secs + + while time.monotonic() < deadline and len(collected) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 0.5)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + + for msg in _parse_datagram(data): + if msg.service_id == service_id and msg.method_id == event_id: + collected.append(msg) + if len(collected) >= count: + break + + return collected + + +def capture_any_notifications( + sock: socket.socket, + service_id: int, + timeout_secs: float = 5.0, +) -> List[SOMEIPHeader]: + """Capture any SOME/IP messages for *service_id* on *sock*. + + Each UDP datagram may carry multiple bundled SOME/IP messages; + all of them are inspected. + """ + collected: List[SOMEIPHeader] = [] + deadline = time.monotonic() + timeout_secs + + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 0.5)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + + for msg in _parse_datagram(data): + if msg.service_id == service_id: + collected.append(msg) + + return collected + + +def assert_notification_header(msg: SOMEIPHeader, expected_event_id: int) -> None: + """Assert a SOME/IP message is a valid NOTIFICATION with the expected event_id.""" + assert msg.message_type == SOMEIPMessageType.NOTIFICATION, ( + f"TC8-EVT: message_type mismatch: got 0x{msg.message_type:02x}, " + f"expected NOTIFICATION (0x{SOMEIPMessageType.NOTIFICATION:02x})" + ) + assert msg.method_id == expected_event_id, ( + f"TC8-EVT: event_id mismatch: got 0x{msg.method_id:04x}, expected 0x{expected_event_id:04x}" + ) + + +def subscribe_and_wait_ack_tcp( + tester_ip: str, + host_ip: str, + sd_port: int, + service_id: int, + instance_id: int, + eventgroup_id: int, + major_version: int, + notif_port: int, + timeout_secs: float = 5.0, +) -> socket.socket: + """Subscribe to an eventgroup with a TCP endpoint and wait for the Ack. + + Like subscribe_and_wait_ack() but the subscription advertises a TCP + endpoint (L4Proto=TCP) so the DUT delivers notifications over TCP. + Returns the SD socket (still open). Caller must close it. + """ + sd_sock = open_sender_socket(tester_ip) + try: + + def _send_sub() -> None: + send_subscribe_eventgroup( + sd_sock, + (host_ip, sd_port), + service_id, + instance_id, + eventgroup_id, + major_version, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + l4proto=L4Protocols.TCP, + ) + + _send_sub() + entries = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=timeout_secs, + resend=_send_sub, + max_results=1, + ) + acks = [e for e in entries if e.eventgroup_id == eventgroup_id and e.ttl > 0] + assert acks, f"No SubscribeEventgroupAck received for TCP eventgroup 0x{eventgroup_id:04x}" + except Exception: + sd_sock.close() + raise + return sd_sock diff --git a/tests/tc8_conformance/helpers/field_helpers.py b/tests/tc8_conformance/helpers/field_helpers.py new file mode 100644 index 000000000..33d2905c4 --- /dev/null +++ b/tests/tc8_conformance/helpers/field_helpers.py @@ -0,0 +1,147 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Field GET/SET request helpers for TC8-FLD conformance tests. + +Provides thin wrappers around the message_builder and raw socket primitives +already established in test_someip_message_format.py, tailored for field +getter/setter interactions (TC8-FLD-003 and TC8-FLD-004). +""" + +from someip.header import SOMEIPHeader + +from helpers.message_builder import build_request +from helpers.sd_helpers import create_udp_socket +from helpers.tcp_helpers import tcp_connect, tcp_receive_response, tcp_send_request + + +def send_get_field( + host_ip: str, + service_id: int, + get_method_id: int, + dut_port: int, + client_id: int = 0x0020, + session_id: int = 0x0001, + timeout_secs: float = 3.0, +) -> SOMEIPHeader: + """Send a GET field request and return the RESPONSE. + + Raises ``socket.timeout`` if no response arrives within *timeout_secs*. + """ + request_bytes = build_request( + service_id, + get_method_id, + client_id=client_id, + session_id=session_id, + ) + sock = create_udp_socket(port=0) + try: + sock.sendto(request_bytes, (host_ip, dut_port)) + sock.settimeout(timeout_secs) + data, _ = sock.recvfrom(65535) + resp, _ = SOMEIPHeader.parse(data) + return resp + finally: + sock.close() + + +def send_set_field( + host_ip: str, + service_id: int, + set_method_id: int, + new_value: bytes, + dut_port: int, + client_id: int = 0x0020, + session_id: int = 0x0002, + timeout_secs: float = 3.0, +) -> SOMEIPHeader: + """Send a SET field request with *new_value* as payload and return the RESPONSE. + + Raises ``socket.timeout`` if no response arrives within *timeout_secs*. + """ + request_bytes = build_request( + service_id, + set_method_id, + client_id=client_id, + session_id=session_id, + payload=new_value, + ) + sock = create_udp_socket(port=0) + try: + sock.sendto(request_bytes, (host_ip, dut_port)) + sock.settimeout(timeout_secs) + data, _ = sock.recvfrom(65535) + resp, _ = SOMEIPHeader.parse(data) + return resp + finally: + sock.close() + + +# --------------------------------------------------------------------------- +# TCP variants — SOMEIPSRV_RPC_17 (reliable transport) +# --------------------------------------------------------------------------- + + +def send_get_field_tcp( + host_ip: str, + service_id: int, + get_method_id: int, + dut_port: int, + client_id: int = 0x0040, + session_id: int = 0x0010, + timeout_secs: float = 3.0, +) -> SOMEIPHeader: + """Send a GET field request over TCP and return the RESPONSE. + + TCP variant of send_get_field() for SOMEIPSRV_RPC_17 testing. + """ + request_bytes = build_request( + service_id, + get_method_id, + client_id=client_id, + session_id=session_id, + ) + sock = tcp_connect(host_ip, dut_port, timeout_secs=timeout_secs) + try: + tcp_send_request(sock, request_bytes) + return tcp_receive_response(sock, timeout_secs=timeout_secs) + finally: + sock.close() + + +def send_set_field_tcp( + host_ip: str, + service_id: int, + set_method_id: int, + new_value: bytes, + dut_port: int, + client_id: int = 0x0040, + session_id: int = 0x0011, + timeout_secs: float = 3.0, +) -> SOMEIPHeader: + """Send a SET field request over TCP with *new_value* and return the RESPONSE. + + TCP variant of send_set_field() for SOMEIPSRV_RPC_17 testing. + """ + request_bytes = build_request( + service_id, + set_method_id, + client_id=client_id, + session_id=session_id, + payload=new_value, + ) + sock = tcp_connect(host_ip, dut_port, timeout_secs=timeout_secs) + try: + tcp_send_request(sock, request_bytes) + return tcp_receive_response(sock, timeout_secs=timeout_secs) + finally: + sock.close() diff --git a/tests/tc8_conformance/helpers/message_builder.py b/tests/tc8_conformance/helpers/message_builder.py new file mode 100644 index 000000000..bd854d2bb --- /dev/null +++ b/tests/tc8_conformance/helpers/message_builder.py @@ -0,0 +1,184 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""SOME/IP message construction helpers for TC8 conformance tests. + +Builds REQUEST, REQUEST_NO_RETURN, and intentionally malformed packets. +""" + +from someip.header import ( + SOMEIPHeader, + SOMEIPMessageType, + SOMEIPReturnCode, +) +from helpers.constants import MAJOR_VERSION + + +def build_request( + service_id: int, + method_id: int, + client_id: int = 0x0001, + session_id: int = 0x0001, + interface_version: int = MAJOR_VERSION, + payload: bytes = b"", +) -> bytes: + """Build a SOME/IP REQUEST message.""" + return SOMEIPHeader( + service_id=service_id, + method_id=method_id, + client_id=client_id, + session_id=session_id, + interface_version=interface_version, + message_type=SOMEIPMessageType.REQUEST, + return_code=SOMEIPReturnCode.E_OK, + payload=payload, + ).build() + + +def build_request_no_return( + service_id: int, + method_id: int, + client_id: int = 0x0001, + session_id: int = 0x0001, + interface_version: int = MAJOR_VERSION, + payload: bytes = b"", +) -> bytes: + """Build a SOME/IP REQUEST_NO_RETURN (fire-and-forget) message.""" + return SOMEIPHeader( + service_id=service_id, + method_id=method_id, + client_id=client_id, + session_id=session_id, + interface_version=interface_version, + message_type=SOMEIPMessageType.REQUEST_NO_RETURN, + return_code=SOMEIPReturnCode.E_OK, + payload=payload, + ).build() + + +# --------------------------------------------------------------------------- +# Malformed message builders — TC8-MSG-007 +# --------------------------------------------------------------------------- + + +def build_truncated_message() -> bytes: + """Return 7 raw bytes — one byte shorter than the minimum 8-byte SOME/IP header. + + The DUT must not crash when it receives this (TC8-MSG-007). + """ + # service_id=0x1234, method_id=0x0421, length=0x000000 (3 bytes, truncated) + return b"\x12\x34\x04\x21\x00\x00\x00" + + +def build_wrong_protocol_version_request( + service_id: int, + method_id: int, + client_id: int = 0x0001, + session_id: int = 0x0001, + interface_version: int = MAJOR_VERSION, +) -> bytes: + """Build a valid REQUEST but with protocol_version patched to 0xFF. + + SOME/IP wire layout: byte 12 is protocol_version. + The DUT should reject or drop this message (TC8-MSG-007). + """ + raw = build_request( + service_id, + method_id, + client_id=client_id, + session_id=session_id, + interface_version=interface_version, + ) + # Byte 12 = protocol_version; patch it to an invalid value. + return raw[:12] + b"\xff" + raw[13:] + + +def build_oversized_message( + service_id: int, + method_id: int, + client_id: int = 0x0001, + session_id: int = 0x0001, + interface_version: int = MAJOR_VERSION, +) -> bytes: + """Build a 16-byte packet whose length field claims 0x7FF3 bytes of payload. + + The actual UDP payload is only 16 bytes so the DUT will receive a + packet far shorter than advertised. The DUT must not crash (TC8-MSG-007). + """ + raw = build_request( + service_id, + method_id, + client_id=client_id, + session_id=session_id, + interface_version=interface_version, + ) + # SOME/IP length field = bytes 4–7; it counts bytes from byte 8 onward. + # A claim of 0x7FF3 means the message body should be 32755 bytes but is only 8. + return raw[:4] + b"\x00\x00\x7f\xf3" + raw[8:] + + +# --------------------------------------------------------------------------- +# Group 3 message builders — protocol behaviour tests +# --------------------------------------------------------------------------- + + +def build_notification_as_request( + service_id: int, + method_id: int, + client_id: int = 0x0001, + session_id: int = 0x0001, + interface_version: int = MAJOR_VERSION, + payload: bytes = b"", +) -> bytes: + """Build a SOME/IP packet with message_type=NOTIFICATION (0x02). + + A NOTIFICATION sent in the client→server direction is invalid per the + SOME/IP spec. Used by ETS_075: the DUT must not send a RESPONSE. + """ + return SOMEIPHeader( + service_id=service_id, + method_id=method_id, + client_id=client_id, + session_id=session_id, + interface_version=interface_version, + message_type=SOMEIPMessageType.NOTIFICATION, + return_code=SOMEIPReturnCode.E_OK, + payload=payload, + ).build() + + +def build_request_with_return_code( + service_id: int, + method_id: int, + return_code: int, + client_id: int = 0x0001, + session_id: int = 0x0001, + interface_version: int = MAJOR_VERSION, + payload: bytes = b"", +) -> bytes: + """Build a REQUEST with an explicit return_code byte value. + + Per SOME/IP spec the return_code in a REQUEST must be E_OK (0x00). + Setting it to a non-zero value tests DUT robustness (RPC_06/07/08). + The return_code byte is byte 15 in the SOME/IP header wire layout. + """ + raw = build_request( + service_id, + method_id, + client_id=client_id, + session_id=session_id, + interface_version=interface_version, + payload=payload, + ) + # Byte 15 = return_code; patch directly since SOMEIPReturnCode enum + # does not accept arbitrary integer values. + return raw[:15] + bytes([return_code & 0xFF]) + raw[16:] diff --git a/tests/tc8_conformance/helpers/sd_helpers.py b/tests/tc8_conformance/helpers/sd_helpers.py new file mode 100644 index 000000000..18bf6d03f --- /dev/null +++ b/tests/tc8_conformance/helpers/sd_helpers.py @@ -0,0 +1,138 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +""" +SD multicast capture and OFFER parsing for TC8 tests. + +Single source of truth for SD parsing and multicast sockets. +Other helpers (``timing.py``, ``sd_sender.py``) import from here. + +Uses blocking sockets (no asyncio). + +Note: Loopback multicast needs ``bazel test --config=tc8`` (auto-configures +a private network namespace). Alternatively, set ``TC8_HOST_IP`` to a +non-loopback interface address to avoid this. +""" + +import socket +import struct +import time +from typing import List + +from helpers.constants import SD_MULTICAST_ADDR, SD_PORT +from someip.header import ( + SOMEIPHeader, + SOMEIPSDEntry, + SOMEIPSDEntryType, + SOMEIPSDHeader, +) + +# SOME/IP SD messages are identified by service ID 0xFFFF. +SD_SERVICE_ID: int = 0xFFFF + + +def create_udp_socket(bind_addr: str = "", port: int = 0) -> socket.socket: + """Create a UDP socket, optionally bound to *bind_addr*:*port*.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if bind_addr or port: + sock.bind((bind_addr, port)) + return sock + + +def open_multicast_socket( + host_ip: str, + multicast_group: str = SD_MULTICAST_ADDR, + port: int = SD_PORT, +) -> socket.socket: + """Open a UDP socket and join *multicast_group*. Caller must close it.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + pass + sock.bind(("", port)) + group_bytes = socket.inet_aton(multicast_group) + iface_bytes = socket.inet_aton(host_ip) + mreq = struct.pack("4s4s", group_bytes, iface_bytes) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + return sock + + +def parse_sd_offers(data: bytes) -> List[SOMEIPSDEntry]: + """Parse a UDP payload and return all OfferService entries. + + Returns ``[]`` if *data* is not a valid SOME/IP-SD message. + """ + try: + someip_msg, _ = SOMEIPHeader.parse(data) + except Exception: + return [] + + if someip_msg.service_id != SD_SERVICE_ID: + return [] + + try: + sd_header, _ = SOMEIPSDHeader.parse(someip_msg.payload) + except Exception: + return [] + + sd_header = sd_header.resolve_options() + + return [entry for entry in sd_header.entries if entry.sd_type == SOMEIPSDEntryType.OfferService] + + +def capture_sd_offers( + host_ip: str, + multicast_group: str = SD_MULTICAST_ADDR, + port: int = SD_PORT, + min_count: int = 1, + timeout_secs: float = 5.0, +) -> List[SOMEIPSDEntry]: + """Join the SD multicast group and collect OfferService entries. + + Returns as soon as *min_count* entries are captured. + Raises ``TimeoutError`` if not enough entries arrive within *timeout_secs*. + Raises ``OSError`` if the multicast socket setup fails. + """ + sock = open_multicast_socket(host_ip, multicast_group, port) + try: + deadline = time.monotonic() + timeout_secs + collected: List[SOMEIPSDEntry] = [] + + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 1.0)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + + offers = parse_sd_offers(data) + collected.extend(offers) + + if len(collected) >= min_count: + return collected + + finally: + sock.close() + + if len(collected) < min_count: + raise TimeoutError( + f"Captured only {len(collected)} SD OFFER entries within " + f"{timeout_secs:.1f}s (expected at least {min_count})" + ) + + return collected # pragma: no cover — reached only if min_count == 0 diff --git a/tests/tc8_conformance/helpers/sd_malformed.py b/tests/tc8_conformance/helpers/sd_malformed.py new file mode 100644 index 000000000..1d6992310 --- /dev/null +++ b/tests/tc8_conformance/helpers/sd_malformed.py @@ -0,0 +1,820 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Malformed SD packet builders for TC8 Group 4 robustness tests. + +Each public function builds or sends a SOME/IP-SD UDP datagram with a +deliberate protocol violation. The DUT must survive receipt of any of +these packets without crashing, hanging, or entering an incorrect state. + +Wire format reminder +-------------------- +SOME/IP header (16 bytes): + [0-1] service_id = 0xFFFF + [2-3] method_id = 0x8100 + [4-7] length (big-endian, bytes from byte 8 onward) + [8-9] client_id = 0x0001 + [10-11] session_id (big-endian) + [12] protocol_version = 0x01 + [13] interface_version = 0x01 + [14] message_type = 0x02 (NOTIFICATION) + [15] return_code = 0x00 + +SD payload (starts at byte 16): + [0] flags (0xC0 = reboot | unicast) + [1-3] reserved (0x000000) + [4-7] entries_array_length (big-endian) + [8+] entry bytes (entries_array_length bytes) + [...] options_array_length (4 bytes big-endian) + [...] option bytes + +SOME/IP length = 8 + len(SD payload). +""" + +import ipaddress +import itertools +import socket +import struct +from typing import Optional, Tuple + +from helpers.constants import SD_PORT +from someip.header import ( + IPv4EndpointOption, + L4Protocols, + SOMEIPSDEntry, + SOMEIPSDEntryType, +) + +# --------------------------------------------------------------------------- +# Module-level session counter (independent of sd_sender's counter) +# --------------------------------------------------------------------------- + +_malformed_session: itertools.count = itertools.count(start=200) + + +def _next_session() -> int: + """Return next session ID in range 1-65535 (skip 0).""" + val = next(_malformed_session) & 0xFFFF + return val or 200 # skip 0 + + +# --------------------------------------------------------------------------- +# Core raw-packet builder +# --------------------------------------------------------------------------- + +_SOMEIP_SD_HEADER_FMT = ">HHIHHBBBB" # 16 bytes total +_SOMEIP_SD_SERVICE_ID = 0xFFFF +_SOMEIP_SD_METHOD_ID = 0x8100 +_SD_FLAGS_REBOOT_UNICAST = 0xC0 +_SD_FLAGS_NONE = 0x00 + + +def _build_someip_header(session_id: int, payload_len: int) -> bytes: + """Build the 16-byte SOME/IP header for an SD notification.""" + length = 8 + payload_len + return struct.pack( + _SOMEIP_SD_HEADER_FMT, + _SOMEIP_SD_SERVICE_ID, # service_id + _SOMEIP_SD_METHOD_ID, # method_id + length, # length + 0x0001, # client_id + session_id, # session_id + 0x01, # protocol_version + 0x01, # interface_version (SD uses 0x01) + 0x02, # message_type = NOTIFICATION + 0x00, # return_code = E_OK + ) + + +def _build_sd_payload( + flags: int, + entries_bytes: bytes, + options_bytes: bytes, + entries_length_override: Optional[int] = None, + options_length_override: Optional[int] = None, +) -> bytes: + """Build the SD payload section (flags + array lengths + entries + options).""" + entries_len = entries_length_override if entries_length_override is not None else len(entries_bytes) + options_len = options_length_override if options_length_override is not None else len(options_bytes) + return ( + struct.pack(">B3xI", flags, entries_len) # flags(1)+reserved(3)+entries_len(4) + + entries_bytes + + struct.pack(">I", options_len) + + options_bytes + ) + + +def build_raw_sd_packet( + flags: int = _SD_FLAGS_REBOOT_UNICAST, + entries_bytes: bytes = b"", + options_bytes: bytes = b"", + entries_length_override: Optional[int] = None, + options_length_override: Optional[int] = None, + session_id: int = 0, + someip_length_override: Optional[int] = None, + someip_service_id_override: Optional[int] = None, + someip_method_id_override: Optional[int] = None, +) -> bytes: + """Build a complete SOME/IP+SD packet with optional field overrides. + + Use *_override* parameters to inject specific protocol violations. + """ + if session_id == 0: + session_id = _next_session() + sd_payload = _build_sd_payload( + flags, + entries_bytes, + options_bytes, + entries_length_override, + options_length_override, + ) + raw = bytearray(_build_someip_header(session_id, len(sd_payload)) + sd_payload) + if someip_length_override is not None: + struct.pack_into(">I", raw, 4, someip_length_override) + if someip_service_id_override is not None: + struct.pack_into(">H", raw, 0, someip_service_id_override) + if someip_method_id_override is not None: + struct.pack_into(">H", raw, 2, someip_method_id_override) + return bytes(raw) + + +# --------------------------------------------------------------------------- +# SD entry byte builders +# --------------------------------------------------------------------------- + + +def _find_service_entry_bytes( + service_id: int, + instance_id: int = 0xFFFF, + major_version: int = 0xFF, + ttl: int = 3, + minor_version: int = 0xFFFFFFFF, +) -> bytes: + """Build a 16-byte FindService (Type 0) SD entry.""" + ttl_3b = struct.pack(">I", ttl)[1:] # 3 bytes big-endian + return ( + bytes([0x00, 0x00, 0x00, 0x00]) + + struct.pack(">HH", service_id, instance_id) + + bytes([major_version]) + + ttl_3b + + struct.pack(">I", minor_version) + ) + + +def _subscribe_entry_bytes( + service_id: int, + instance_id: int, + eventgroup_id: int, + major_version: int = 0x00, + ttl: int = 3, + num_opts: int = 1, + idx_opt: int = 0, + reserved: bytes = b"\x00\x00", +) -> bytes: + """Build a 16-byte SubscribeEventgroup (Type 0x06) SD entry. + + Wire layout (big-endian): + byte 0: type = 0x06 + byte 1: index_first_option_run + byte 2: (num_options_1 << 4) | num_options_2 + byte 3: 0 (reserved/service_type) + bytes 4-5: service_id + bytes 6-7: instance_id + byte 8: major_version + bytes 9-11: TTL (3 bytes) + bytes 12-13: reserved (should be 0) + bytes 14-15: eventgroup_id + """ + ttl_3b = struct.pack(">I", ttl)[1:] + return ( + bytes([0x06, idx_opt, (num_opts << 4), 0x00]) + + struct.pack(">HH", service_id, instance_id) + + bytes([major_version]) + + ttl_3b + + reserved + + struct.pack(">H", eventgroup_id & 0xFFFF) + ) + + +def _endpoint_option_bytes( + ip: str, + port: int, + l4proto: int = 0x11, # 0x11 = UDP, 0x06 = TCP + length_override: Optional[int] = None, +) -> bytes: + """Build a 12-byte IPv4 Endpoint Option. + + Wire layout: + [0-1] length = 0x0009 + [2] type = 0x04 + [3] reserved = 0x00 + [4-7] IPv4 address + [8] reserved = 0x00 + [9] l4proto + [10-11] port + """ + length_field = length_override if length_override is not None else 0x0009 + addr_bytes = socket.inet_aton(ip) + return struct.pack(">HBB", length_field, 0x04, 0x00) + addr_bytes + struct.pack(">BBH", 0x00, l4proto, port) + + +def _unknown_option_bytes(option_type: int = 0x77, content_len: int = 4) -> bytes: + """Build an SD option with an unknown type byte.""" + length_field = content_len + 1 # type byte counts in length + padding = bytes(content_len) + return struct.pack(">HBB", length_field, option_type, 0x00) + padding + + +# --------------------------------------------------------------------------- +# Public malformed-packet senders +# --------------------------------------------------------------------------- + + +def send_sd_empty_entries( + sock: socket.socket, + dest: Tuple[str, int], +) -> None: + """ETS_111: SD packet with entries_array_length=0 (no entries, no options). + + DUT must not crash or send a spurious OfferService. + """ + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=b"", + options_bytes=b"", + ) + sock.sendto(pkt, dest) + + +def send_sd_find_with_options( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + host_ip: str, + subscriber_port: int, +) -> None: + """ETS_118: FindService entry with an endpoint option attached. + + SOME/IP-SD spec says options on FindService entries shall be ignored. + DUT must still respond to FindService normally. + """ + # Build FindService entry with num_options_1=1 so the DUT sees an option reference. + ttl_3b = struct.pack(">I", 3)[1:] + entry_bytes = ( + bytes([0x00, 0x00, 0x10, 0x00]) # type=Find, idx=0, num_1=1, num_2=0, service_type=0 + + struct.pack(">HH", service_id, 0xFFFF) + + bytes([0xFF]) + + ttl_3b + + struct.pack(">I", 0xFFFFFFFF) + ) + opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) + + +def send_sd_entries_length_wrong( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + entries_length_override: int, +) -> None: + """ETS_114/123/124/125: SD packet where entries_array_length mismatches actual entry bytes. + + DUT must discard and remain alive. + """ + entry_bytes = _find_service_entry_bytes(service_id=service_id) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=b"", + entries_length_override=entries_length_override, + ) + sock.sendto(pkt, dest) + + +def send_sd_entry_refs_more_options( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, +) -> None: + """ETS_115: SubscribeEventgroup entry num_options_1=3 but options array has only 1. + + DUT must discard the subscribe (and may send NAck) but must not crash. + """ + # Build entry with num_options_1=3 (bits [7:4] of byte 2) + ttl_3b = struct.pack(">I", 3)[1:] + entry_bytes = ( + bytes([0x06, 0x00, 0x30, 0x00]) # type=Subscribe, idx=0, num_1=3, num_2=0 + + struct.pack(">HH", service_id, instance_id) + + bytes([0x00]) + + ttl_3b + + b"\x00\x00" + + struct.pack(">H", eventgroup_id & 0xFFFF) + ) + opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) + + +def send_sd_entry_unknown_option_type( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, +) -> None: + """ETS_116/174: SubscribeEventgroup with an option of unknown type 0x77. + + DUT may send NAck or silently discard — must not crash. + """ + ttl_3b = struct.pack(">I", 3)[1:] + entry_bytes = ( + bytes([0x06, 0x00, 0x10, 0x00]) # num_1=1 + + struct.pack(">HH", service_id, instance_id) + + bytes([0x00]) + + ttl_3b + + b"\x00\x00" + + struct.pack(">H", eventgroup_id & 0xFFFF) + ) + opt_bytes = _unknown_option_bytes(option_type=0x77, content_len=4) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) + + +def send_sd_entry_same_option_twice( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, +) -> None: + """ETS_117: Two entries pointing to the same endpoint option via option index overlap. + + Builds two SubscribeEventgroup entries both referencing option index 0. + DUT must not crash. + """ + ttl_3b = struct.pack(">I", 3)[1:] + entry1 = ( + bytes([0x06, 0x00, 0x10, 0x00]) + + struct.pack(">HH", service_id, instance_id) + + bytes([0x00]) + + ttl_3b + + b"\x00\x00" + + struct.pack(">H", eventgroup_id & 0xFFFF) + ) + entry2 = ( + bytes([0x06, 0x00, 0x10, 0x00]) # also references option index 0 + + struct.pack(">HH", service_id, instance_id) + + bytes([0x00]) + + ttl_3b + + b"\x00\x00" + + struct.pack(">H", eventgroup_id & 0xFFFF) + ) + opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry1 + entry2, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) + + +def send_sd_option_length_too_long( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, + option_length_override: int, +) -> None: + """ETS_134/135: IPv4EndpointOption with oversize length field. + + The option length field claims more bytes than the options array contains. + DUT must discard and remain alive. + """ + ttl_3b = struct.pack(">I", 3)[1:] + entry_bytes = ( + bytes([0x06, 0x00, 0x10, 0x00]) + + struct.pack(">HH", service_id, instance_id) + + bytes([0x00]) + + ttl_3b + + b"\x00\x00" + + struct.pack(">H", eventgroup_id & 0xFFFF) + ) + opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port, length_override=option_length_override) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) + + +def send_sd_option_length_too_short( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, +) -> None: + """ETS_136: IPv4EndpointOption with length field = 1 (too short for actual content). + + DUT must discard and remain alive. + """ + send_sd_option_length_too_long( + sock, + dest, + service_id, + instance_id, + eventgroup_id, + host_ip, + subscriber_port, + option_length_override=0x0001, + ) + + +def send_sd_option_length_unaligned( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, +) -> None: + """ETS_137: IPv4EndpointOption with odd length that doesn't align to option boundary. + + Uses length=0x000A (10) instead of 9; points one byte past the type+reserved into + the next field. DUT must discard and remain alive. + """ + send_sd_option_length_too_long( + sock, + dest, + service_id, + instance_id, + eventgroup_id, + host_ip, + subscriber_port, + option_length_override=0x000A, + ) + + +def send_sd_options_array_length_too_long( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, +) -> None: + """ETS_138: options_array_length claims more bytes than actually present. + + DUT must discard and remain alive. + """ + ttl_3b = struct.pack(">I", 3)[1:] + entry_bytes = ( + bytes([0x06, 0x00, 0x10, 0x00]) + + struct.pack(">HH", service_id, instance_id) + + bytes([0x00]) + + ttl_3b + + b"\x00\x00" + + struct.pack(">H", eventgroup_id & 0xFFFF) + ) + opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port) + # Override options_array_length to claim 100 bytes, but actual opt_bytes is 12 + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + options_length_override=100, + ) + sock.sendto(pkt, dest) + + +def send_sd_options_array_length_too_short( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, +) -> None: + """ETS_139: options_array_length claims fewer bytes than actually present. + + DUT must discard and remain alive. + """ + ttl_3b = struct.pack(">I", 3)[1:] + entry_bytes = ( + bytes([0x06, 0x00, 0x10, 0x00]) + + struct.pack(">HH", service_id, instance_id) + + bytes([0x00]) + + ttl_3b + + b"\x00\x00" + + struct.pack(">H", eventgroup_id & 0xFFFF) + ) + opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port) + # Override options_array_length to 2 (far fewer bytes than 12 actual) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + options_length_override=2, + ) + sock.sendto(pkt, dest) + + +def send_sd_subscribe_no_endpoint( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, +) -> None: + """ETS_109: SubscribeEventgroup with num_options_1=0 (no endpoint option). + + DUT must send NAck (SubscribeAck with TTL=0) or silently discard. + Must not crash. + """ + entry_bytes = _subscribe_entry_bytes( + service_id=service_id, + instance_id=instance_id, + eventgroup_id=eventgroup_id, + num_opts=0, + ) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=b"", + ) + sock.sendto(pkt, dest) + + +def send_sd_subscribe_zero_ip( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + subscriber_port: int, +) -> None: + """ETS_110: SubscribeEventgroup with endpoint IP = 0.0.0.0 (unspecified). + + DUT must send NAck or silently discard. Must not crash. + """ + entry_bytes = _subscribe_entry_bytes( + service_id=service_id, + instance_id=instance_id, + eventgroup_id=eventgroup_id, + num_opts=1, + ) + opt_bytes = _endpoint_option_bytes("0.0.0.0", subscriber_port) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) + + +def send_sd_subscribe_wrong_l4proto( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, + l4proto: int = 0x00, +) -> None: + """ETS_119: SubscribeEventgroup with unknown L4 protocol byte in endpoint option. + + Uses l4proto=0x00 (neither UDP=0x11 nor TCP=0x06). + DUT must send NAck or silently discard. Must not crash. + """ + entry_bytes = _subscribe_entry_bytes( + service_id=service_id, + instance_id=instance_id, + eventgroup_id=eventgroup_id, + num_opts=1, + ) + opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port, l4proto=l4proto) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) + + +def send_sd_subscribe_reserved_option( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, +) -> None: + """ETS_144: SubscribeEventgroup with a reserved option type (0x20). + + DUT must send NAck or silently discard. Must not crash. + """ + entry_bytes = _subscribe_entry_bytes( + service_id=service_id, + instance_id=instance_id, + eventgroup_id=eventgroup_id, + num_opts=1, + ) + opt_bytes = _unknown_option_bytes(option_type=0x20, content_len=8) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) + + +def send_sd_wrong_someip_length( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + length_override: int, +) -> None: + """ETS_153: SOME/IP SD packet where the SOME/IP length field is incorrect. + + DUT must discard and remain alive. + """ + entry_bytes = _find_service_entry_bytes(service_id=service_id) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=b"", + someip_length_override=length_override, + ) + sock.sendto(pkt, dest) + + +def send_sd_high_session_id( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + session_id: int, +) -> None: + """ETS_152: SD FindService with a high/wrapped session ID (e.g., 0xFFFE, 0xFFFF). + + DUT must not reject or misinterpret packets based on tester's session ID. + """ + entry_bytes = _find_service_entry_bytes(service_id=service_id) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=b"", + session_id=session_id, + ) + sock.sendto(pkt, dest) + + +def send_sd_wrong_someip_message_id( + sock: socket.socket, + dest: Tuple[str, int], + service_id_override: int = 0x1234, +) -> None: + """ETS_178: SD packet with wrong SOME/IP service_id (not 0xFFFF). + + DUT must silently discard (not SD traffic) and remain alive. + """ + entry_bytes = _find_service_entry_bytes(service_id=0x1234) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=b"", + someip_service_id_override=service_id_override, + ) + sock.sendto(pkt, dest) + + +def send_sd_truncated_entry( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, +) -> None: + """ETS_125: SD packet with entries_array_length=16 but only 8 bytes of entry data. + + The entry is incomplete (truncated). DUT must discard and remain alive. + """ + entry_bytes = _find_service_entry_bytes(service_id=service_id)[:8] # truncate to 8 bytes + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=b"", + entries_length_override=16, # claims 16 bytes but only 8 provided + ) + sock.sendto(pkt, dest) + + +def send_sd_oversized_entries_length( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, +) -> None: + """ETS_123/124: entries_array_length far exceeds packet size. + + DUT must discard and remain alive. + """ + entry_bytes = _find_service_entry_bytes(service_id=service_id) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=b"", + entries_length_override=0xFFFF, # wildly too large + ) + sock.sendto(pkt, dest) + + +def send_sd_empty_option( + sock: socket.socket, + dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, +) -> None: + """ETS_112/113: SubscribeEventgroup with an option whose length field is 0 or 1. + + An option with length < 2 is malformed (type byte cannot fit). + DUT must discard and remain alive. + """ + entry_bytes = _subscribe_entry_bytes( + service_id=service_id, + instance_id=instance_id, + eventgroup_id=eventgroup_id, + num_opts=1, + ) + # Build an option with length=0x0001 (only 1 content byte after length field — invalid) + opt_bytes = struct.pack(">HBB", 0x0001, 0x04, 0x00) + b"\x00" * 8 + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) + + +def send_sd_subscribe_nonexistent_service( + sock: socket.socket, + dest: Tuple[str, int], + unknown_service_id: int, + instance_id: int, + eventgroup_id: int, + host_ip: str, + subscriber_port: int, +) -> None: + """ETS_140-143: SubscribeEventgroup for a service_id not offered by DUT. + + DUT must send no SubscribeAck or a NAck. Must not crash. + """ + entry_bytes = _subscribe_entry_bytes( + service_id=unknown_service_id, + instance_id=instance_id, + eventgroup_id=eventgroup_id, + num_opts=1, + ) + opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port) + pkt = build_raw_sd_packet( + flags=_SD_FLAGS_REBOOT_UNICAST, + entries_bytes=entry_bytes, + options_bytes=opt_bytes, + ) + sock.sendto(pkt, dest) diff --git a/tests/tc8_conformance/helpers/sd_sender.py b/tests/tc8_conformance/helpers/sd_sender.py new file mode 100644 index 000000000..74b127c4e --- /dev/null +++ b/tests/tc8_conformance/helpers/sd_sender.py @@ -0,0 +1,376 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""SD packet building, sending, and unicast capture for TC8 tests. + +Helpers to send FindService / SubscribeEventgroup messages and +capture unicast SD responses and SOME/IP notifications. +""" + +import ipaddress +import itertools +import socket +import struct +import time +from typing import Callable, List, Optional, Tuple + +from helpers.constants import SD_PORT +from someip.header import ( + IPv4EndpointOption, + L4Protocols, + SD_INTERFACE_VERSION, + SD_METHOD, + SD_SERVICE, + SOMEIPHeader, + SOMEIPMessageType, + SOMEIPReturnCode, + SOMEIPSDEntry, + SOMEIPSDEntryType, + SOMEIPSDHeader, + SOMEIPSDOption, +) + + +# --------------------------------------------------------------------------- +# Socket management +# --------------------------------------------------------------------------- + + +def open_sender_socket(local_ip: str) -> socket.socket: + """Open a UDP socket at ``(local_ip, SD_PORT)`` for SD send/receive. + + Binds to ``SD_PORT`` because the SOME/IP stack drops SD messages from other ports. + ``local_ip`` must differ from the DUT address (use the ``tester_ip`` fixture). + Multicast loopback is enabled so the local DUT receives our packets. + Caller must close the socket. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + pass # SO_REUSEPORT not available on all platforms + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(local_ip)) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) + sock.bind((local_ip, SD_PORT)) + return sock + + +# --------------------------------------------------------------------------- +# SD packet serialisation helpers +# --------------------------------------------------------------------------- + + +# SD session counter — incremented per message to avoid DUT duplicate-detection. +# PRS_SOMEIPSD_00154 requires session_id to start at 0x0001 (0x0000 is reserved) +# and to increment with each SD message. +# NOTE: Assumes serial test execution (Bazel "exclusive" tag). Not thread-safe. +_session_counter = itertools.count(start=1) + + +def _next_session_id() -> int: + """Return the next SD session ID (wraps at 16-bit).""" + return next(_session_counter) & 0xFFFF or 1 # skip 0 (reserved) + + +def _build_sd_packet(entry: SOMEIPSDEntry, session_id: int = 0) -> bytes: + """Wrap a single SD entry into a SOME/IP-SD UDP datagram. + + When *session_id* is 0 (default), an auto-incrementing counter is used. + """ + if session_id == 0: + session_id = _next_session_id() + options: List[SOMEIPSDOption] = [] + indexed = entry.assign_option_index(options) + sd_hdr = SOMEIPSDHeader(entries=(indexed,), options=tuple(options)) + return SOMEIPHeader( + service_id=SD_SERVICE, + method_id=SD_METHOD, + client_id=0x0001, + session_id=session_id, + interface_version=SD_INTERFACE_VERSION, + message_type=SOMEIPMessageType.NOTIFICATION, + return_code=SOMEIPReturnCode.E_OK, + payload=sd_hdr.build(), + ).build() + + +# --------------------------------------------------------------------------- +# Send helpers +# --------------------------------------------------------------------------- + + +def send_find_service( + sock: socket.socket, + sd_dest: Tuple[str, int], + service_id: int, + instance_id: int = 0xFFFF, + major_version: int = 0xFF, + minor_version: int = 0xFFFFFFFF, + session_id: int = 0, +) -> None: + """Send a FindService SD message to *sd_dest*.""" + entry = SOMEIPSDEntry( + sd_type=SOMEIPSDEntryType.FindService, + service_id=service_id, + instance_id=instance_id, + major_version=major_version, + ttl=3, + minver_or_counter=minor_version, + ) + sock.sendto(_build_sd_packet(entry, session_id), sd_dest) + + +def send_subscribe_eventgroup( + sock: socket.socket, + sd_dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + major_version: int, + subscriber_ip: str, + subscriber_port: int = 0, + ttl: int = 3, + session_id: int = 0, + l4proto: L4Protocols = L4Protocols.UDP, + tcp_port: Optional[int] = None, + udp_port: Optional[int] = None, +) -> None: + """Send a SubscribeEventgroup (or StopSubscribe when ``ttl=0``). + + Endpoint Configuration: + - Standard (Single Protocol): Use `subscriber_port` and `l4proto` to subscribe + to an eventgroup that contains only UDP or only TCP events. + - Mixed (Multi-Protocol): Some eventgroups are configured to contain both TCP + and UDP events. Strict SOME/IP implementations require BOTH endpoints to be + provided in the subscription request. Pass `tcp_port` and/or `udp_port` to + explicitly attach multiple IPv4 Endpoint Options to the SD entry. + """ + endpoint_opt = [] + + if tcp_port is None and udp_port is None: + # Standard behavior: Attach a single IPv4 Endpoint Option. + # This relies on the fallback 'l4proto' and 'subscriber_port' + endpoint_opt.append( + IPv4EndpointOption( + address=ipaddress.IPv4Address(subscriber_ip), + l4proto=l4proto, + port=subscriber_port, + ) + ) + else: + # Mixed behavior: The caller explicitly defined protocol-specific ports. + # We ignore 'subscriber_port' and 'l4proto', and instead attach up to two + # separate IPv4 Endpoint Options. This informs the provider exactly where + # to route UDP events and where to route TCP events for this eventgroup. + if udp_port is not None: + endpoint_opt.append( + IPv4EndpointOption( + address=ipaddress.IPv4Address(subscriber_ip), + l4proto=L4Protocols.UDP, + port=udp_port, + ) + ) + if tcp_port is not None: + endpoint_opt.append( + IPv4EndpointOption( + address=ipaddress.IPv4Address(subscriber_ip), + l4proto=L4Protocols.TCP, + port=tcp_port, + ) + ) + + # Build the SOME/IP-SD Entry using the configured endpoint options + entry = SOMEIPSDEntry( + sd_type=SOMEIPSDEntryType.Subscribe, + service_id=service_id, + instance_id=instance_id, + major_version=major_version, + ttl=ttl, + minver_or_counter=eventgroup_id & 0xFFFF, + options_1=tuple(endpoint_opt), + ) + sock.sendto(_build_sd_packet(entry, session_id), sd_dest) + + +def send_subscribe_eventgroup_reserved_set( + sock: socket.socket, + sd_dest: Tuple[str, int], + service_id: int, + instance_id: int, + eventgroup_id: int, + major_version: int, + subscriber_ip: str, + subscriber_port: int, + ttl: int = 3, + reserved_value: int = 0x0F, +) -> None: + """Send a SubscribeEventgroup with the reserved counter bits in the entry set non-zero. + + The SubscribeEventgroup SD entry encodes a 4-bit counter nibble and a 12-bit + reserved field in the upper 16 bits of the ``minver_or_counter`` word (wire bytes + 12-15 of the entry). This function sets the 12 reserved bits to ``reserved_value`` + to exercise SOMEIPSRV_SD_MESSAGE_19: the DUT shall send a NAck or silently ignore + the subscribe. + + The packet is built by: + 1. Constructing a normal subscribe via ``_build_sd_packet()`` (library path). + 2. Locating the entry's ``minver_or_counter`` bytes in the serialised buffer. + 3. OR-ing the reserved bits (bits [31:20] of the 32-bit counter word) with + ``reserved_value << 20``. + + Offset derivation (all big-endian): + - SOME/IP header: 16 bytes (bytes 0-15) + - SD flags + 3 reserved: 4 bytes (bytes 16-19) + - Entries-array length: 4 bytes (bytes 20-23) + - Entry starts at byte 24; ``minver_or_counter`` is the last 4 bytes of the + 16-byte entry, at entry offset 12 → absolute offset 36. + """ + session_id = _next_session_id() + endpoint_opt = IPv4EndpointOption( + address=ipaddress.IPv4Address(subscriber_ip), + l4proto=L4Protocols.UDP, + port=subscriber_port, + ) + entry = SOMEIPSDEntry( + sd_type=SOMEIPSDEntryType.Subscribe, + service_id=service_id, + instance_id=instance_id, + major_version=major_version, + ttl=ttl, + minver_or_counter=eventgroup_id & 0xFFFF, + options_1=(endpoint_opt,), + ) + raw = bytearray(_build_sd_packet(entry, session_id)) + + # Patch bytes 36-39: the 4-byte ``minver_or_counter`` in the first SD entry. + # Structure at those bytes is: [reserved(12 bits) | counter(4 bits) | eventgroup_id(16 bits)]. + # Set the 12 reserved bits (bits 31-20) to a non-zero pattern. + _MINVER_OFFSET = 36 + original_word: int = struct.unpack_from("!I", raw, _MINVER_OFFSET)[0] + patched_word: int = original_word | ((reserved_value & 0x0FFF) << 20) + struct.pack_into("!I", raw, _MINVER_OFFSET, patched_word) + + sock.sendto(bytes(raw), sd_dest) + + +# --------------------------------------------------------------------------- +# Capture helpers +# --------------------------------------------------------------------------- + + +def capture_unicast_sd_entries( + sock: socket.socket, + filter_types: Optional[Tuple[SOMEIPSDEntryType, ...]] = None, + timeout_secs: float = 5.0, + resend: Optional[Callable[[], None]] = None, + resend_interval_secs: float = 1.5, + max_results: Optional[int] = None, +) -> List[SOMEIPSDEntry]: + """Receive SD entries on *sock* within *timeout_secs*. + + If *filter_types* is set, only matching entry types are returned. + + When *resend* is provided it is called every *resend_interval_secs* + while no matching entries have been captured. This mirrors real-world + SD client behaviour where FindService / Subscribe messages are sent + periodically. + + When *max_results* is set, the function returns as soon as that many + matching entries have been collected (early-exit). This avoids + consuming the full *timeout_secs* when the desired entries arrive quickly, + which matters for tests where the subscription TTL must stay alive. + """ + collected: List[SOMEIPSDEntry] = [] + deadline = time.monotonic() + timeout_secs + next_resend = (time.monotonic() + resend_interval_secs) if resend else None + + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + + # Early-exit once max_results matching entries are collected. + if max_results is not None and len(collected) >= max_results: + break + + # Resend if no matching entries yet and the interval has elapsed. + if resend and next_resend and not collected and time.monotonic() >= next_resend: + resend() + next_resend = time.monotonic() + resend_interval_secs + + sock.settimeout(min(remaining, 0.5)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + + for entry in _parse_sd_entries(data): + if filter_types is None or entry.sd_type in filter_types: + collected.append(entry) + + return collected + + +def capture_some_ip_messages( + sock: socket.socket, + service_id: int, + timeout_secs: float, +) -> List[SOMEIPHeader]: + """Receive SOME/IP messages for *service_id* on *sock* within *timeout_secs*.""" + collected: List[SOMEIPHeader] = [] + deadline = time.monotonic() + timeout_secs + + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 0.5)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + + try: + msg, _ = SOMEIPHeader.parse(data) + except Exception: + continue + + if msg.service_id == service_id: + collected.append(msg) + + return collected + + +# --------------------------------------------------------------------------- +# Internal parsing +# --------------------------------------------------------------------------- + + +def _parse_sd_entries(data: bytes) -> List[SOMEIPSDEntry]: + """Parse a UDP payload and return all SD entries (any type).""" + try: + someip_msg, _ = SOMEIPHeader.parse(data) + except Exception: + return [] + if someip_msg.service_id != SD_SERVICE: + return [] + try: + sd_header, _ = SOMEIPSDHeader.parse(someip_msg.payload) + except Exception: + return [] + # resolve_options() links each entry's options_1/options_2 tuples to the + # actual SOMEIPSDOption objects from sd_header.options. Without this call + # options_1 and options_2 on every SOMEIPSDEntry remain empty tuples even + # when the wire packet carries options (e.g. the multicast IPv4Endpoint + # option in a SubscribeEventgroupAck for TC8-SD-013). + return list(sd_header.resolve_options().entries) diff --git a/tests/tc8_conformance/helpers/someip_assertions.py b/tests/tc8_conformance/helpers/someip_assertions.py new file mode 100644 index 000000000..74a5d22a1 --- /dev/null +++ b/tests/tc8_conformance/helpers/someip_assertions.py @@ -0,0 +1,153 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +""" +SOME/IP assertion helpers for TC8 conformance tests. + +Reusable assertion functions for SD entry fields and SOME/IP message headers. +""" + +from someip.header import ( + IPv4EndpointOption, + L4Protocols, + SOMEIPHeader, + SOMEIPMessageType, + SOMEIPReturnCode, + SOMEIPSDEntry, + SOMEIPSDEntryType, +) + + +def assert_sd_offer_entry( + entry: SOMEIPSDEntry, + expected_service_id: int, + expected_instance_id: int, + expected_major_version: int = 0x00, + expected_minor_version: int = 0x00000000, +) -> None: + """Assert that an SD OFFER entry has the expected fields (TC8-SD-002).""" + # TC8-SD-002: entry type must be OfferService + assert entry.sd_type == SOMEIPSDEntryType.OfferService, ( + f"TC8-SD-002: expected OfferService entry, got {entry.sd_type.name}" + ) + + # TC8-SD-002: service ID must match configuration + assert entry.service_id == expected_service_id, ( + f"TC8-SD-002: service_id mismatch: got 0x{entry.service_id:04x}, expected 0x{expected_service_id:04x}" + ) + + # TC8-SD-002: instance ID must match configuration + assert entry.instance_id == expected_instance_id, ( + f"TC8-SD-002: instance_id mismatch: got 0x{entry.instance_id:04x}, expected 0x{expected_instance_id:04x}" + ) + + # TC8-SD-002: major version must match service definition + assert entry.major_version == expected_major_version, ( + f"TC8-SD-002: major_version mismatch: got 0x{entry.major_version:02x}, expected 0x{expected_major_version:02x}" + ) + + # TC8-SD-002: minor version must match service definition + assert entry.service_minor_version == expected_minor_version, ( + f"TC8-SD-002: minor_version mismatch: " + f"got 0x{entry.service_minor_version:08x}, " + f"expected 0x{expected_minor_version:08x}" + ) + + # TC8-SD-002: TTL must be > 0 (TTL=0 means StopOffer) + assert entry.ttl > 0, f"TC8-SD-002: OFFER TTL must be > 0; got {entry.ttl}" + + +def assert_valid_response(resp: SOMEIPHeader, req_service_id: int, req_method_id: int) -> None: + """Assert a SOME/IP RESPONSE has correct header fields.""" + assert resp.protocol_version == 1, f"TC8-MSG: protocol_version mismatch: got {resp.protocol_version}, expected 1" + assert resp.message_type == SOMEIPMessageType.RESPONSE, ( + f"TC8-MSG: message_type mismatch: got {resp.message_type}, " + f"expected RESPONSE (0x{SOMEIPMessageType.RESPONSE:02x})" + ) + assert resp.service_id == req_service_id, ( + f"TC8-MSG: service_id mismatch: got 0x{resp.service_id:04x}, expected 0x{req_service_id:04x}" + ) + assert resp.method_id == req_method_id, ( + f"TC8-MSG: method_id mismatch: got 0x{resp.method_id:04x}, expected 0x{req_method_id:04x}" + ) + + +def assert_return_code(resp: SOMEIPHeader, expected: SOMEIPReturnCode) -> None: + """Assert a SOME/IP message has the expected return code.""" + assert resp.return_code == expected, ( + f"TC8-MSG: return_code mismatch: got 0x{resp.return_code:02x}, " + f"expected {expected.name} (0x{expected.value:02x})" + ) + + +def assert_session_echo(resp: SOMEIPHeader, expected_session_id: int) -> None: + """Assert RESPONSE session_id matches the REQUEST session_id.""" + assert resp.session_id == expected_session_id, ( + f"TC8-MSG: session_id mismatch: got 0x{resp.session_id:04x}, expected 0x{expected_session_id:04x}" + ) + + +def assert_client_echo(resp: SOMEIPHeader, expected_client_id: int) -> None: + """Assert RESPONSE client_id matches the REQUEST client_id.""" + assert resp.client_id == expected_client_id, ( + f"TC8-MSG: client_id mismatch: got 0x{resp.client_id:04x}, expected 0x{expected_client_id:04x}" + ) + + +def assert_offer_has_ipv4_endpoint_option( + entry: SOMEIPSDEntry, + expected_ip: str, + expected_port: int, +) -> None: + """TC8-SD-011: OfferService entry must include an IPv4EndpointOption (UDP). + + Checks that the SD OFFER carries the correct unicast endpoint so a client + can reach the service. The address and port must match the DUT configuration. + """ + options = list(getattr(entry, "options_1", ())) + list(getattr(entry, "options_2", ())) + ipv4_opts = [o for o in options if isinstance(o, IPv4EndpointOption)] + assert ipv4_opts, ( + "TC8-SD-011: No IPv4EndpointOption found in OfferService entry options. " + f"Entry has {len(options)} option(s): {options}" + ) + opt = ipv4_opts[0] + assert str(opt.address) == expected_ip, ( + f"TC8-SD-011: endpoint address mismatch: got {opt.address}, expected {expected_ip}" + ) + assert opt.port == expected_port, f"TC8-SD-011: endpoint port mismatch: got {opt.port}, expected {expected_port}" + assert opt.l4proto == L4Protocols.UDP, f"TC8-SD-011: endpoint protocol mismatch: got {opt.l4proto}, expected UDP" + + +def assert_offer_has_tcp_endpoint_option( + entry: SOMEIPSDEntry, + expected_ip: str, + expected_port: int, +) -> None: + """Assert OfferService includes an IPv4EndpointOption with L4Proto=TCP. + + SOMEIPSRV_OPTIONS_15: the SD OfferService entry must advertise a TCP + endpoint option when the service is configured with a reliable port. + """ + options = list(getattr(entry, "options_1", ())) + list(getattr(entry, "options_2", ())) + ipv4_opts = [o for o in options if isinstance(o, IPv4EndpointOption)] + tcp_opts = [o for o in ipv4_opts if o.l4proto == L4Protocols.TCP] + assert tcp_opts, ( + "SOMEIPSRV_OPTIONS_15: No IPv4EndpointOption with L4Proto=TCP found in " + f"OfferService entry. Options present: {options}" + ) + opt = tcp_opts[0] + assert str(opt.address) == expected_ip, ( + f"SOMEIPSRV_OPTIONS_15: TCP endpoint address mismatch: got {opt.address}, expected {expected_ip}" + ) + assert opt.port == expected_port, ( + f"SOMEIPSRV_OPTIONS_15: TCP endpoint port mismatch: got {opt.port}, expected {expected_port}" + ) diff --git a/tests/tc8_conformance/helpers/tcp_helpers.py b/tests/tc8_conformance/helpers/tcp_helpers.py new file mode 100644 index 000000000..6606f69db --- /dev/null +++ b/tests/tc8_conformance/helpers/tcp_helpers.py @@ -0,0 +1,176 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TCP transport helpers for SOME/IP over TCP (reliable binding). + +SOME/IP over TCP uses stream framing: the 4-byte length field in the header +(bytes 4–7) indicates how many bytes follow after byte 7. The receiver reads +8 bytes (service_id + method_id + length), then reads `length` more bytes. +""" + +import socket +import struct +import time + +from someip.header import SOMEIPHeader + +_MAX_SOMEIP_MSG_SIZE: int = 65536 # 64 KB safety bound + + +def _recv_exact(sock: socket.socket, nbytes: int, deadline: float) -> bytes: + """Read exactly *nbytes* from *sock*, looping on partial reads. + + Raises socket.timeout if deadline passes before all bytes are received. + Raises ConnectionError if the peer closes the connection. + """ + buf = bytearray() + while len(buf) < nbytes: + remaining_time = deadline - time.monotonic() + if remaining_time <= 0: + raise socket.timeout("tcp_receive_response: deadline exceeded during recv") + sock.settimeout(remaining_time) + chunk = sock.recv(nbytes - len(buf)) + if not chunk: + raise ConnectionError("TCP peer closed connection before all bytes received") + buf.extend(chunk) + return bytes(buf) + + +def tcp_connect(host_ip: str, port: int, timeout_secs: float = 5.0) -> socket.socket: + """Establish a TCP connection to the DUT. + + Returns the connected socket. Caller must close it. + Raises ConnectionRefusedError or socket.timeout on failure. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout_secs) + sock.connect((host_ip, port)) + return sock + + +def tcp_send_request(sock: socket.socket, request_bytes: bytes) -> None: + """Send a complete SOME/IP message over a TCP connection. + + Uses sendall() to ensure all bytes are transmitted. + """ + sock.sendall(request_bytes) + + +def tcp_send_concatenated(sock: socket.socket, messages: list[bytes]) -> None: + """Send multiple SOME/IP messages concatenated into a single TCP write. + + SOME/IP PRS_SOMEIP_00142 requires TCP receivers to handle multiple + SOME/IP messages arriving in a single TCP segment (unaligned packing). + This helper concatenates all *messages* and delivers them as one + ``sendall()`` call so the DUT receives them in one segment. + + Used by: SOMEIP_ETS_068. + """ + sock.sendall(b"".join(messages)) + + +def tcp_receive_n_responses( + sock: socket.socket, + count: int, + timeout_secs: float = 5.0, +) -> list[SOMEIPHeader]: + """Receive exactly *count* SOME/IP responses from a TCP stream. + + Uses a single shared deadline across all *count* receive calls so the + total wait never exceeds *timeout_secs*. Raises ``socket.timeout`` if + not all responses arrive in time. + + Used by: SOMEIP_ETS_068. + """ + deadline = time.monotonic() + timeout_secs + responses: list[SOMEIPHeader] = [] + while len(responses) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout(f"tcp_receive_n_responses: deadline exceeded after {len(responses)}/{count} responses") + responses.append(tcp_receive_response(sock, timeout_secs=remaining)) + return responses + + +def tcp_receive_response(sock: socket.socket, timeout_secs: float = 3.0) -> SOMEIPHeader: + """Receive and frame one complete SOME/IP message from a TCP stream. + + Framing: + 1. Read 8 bytes (service_id[2] + method_id[2] + length[4]). + 2. Extract length from bytes 4–7 (big-endian). + 3. Read exactly `length` more bytes. + 4. Parse via SOMEIPHeader.parse(header + body). + + Raises socket.timeout if the complete message does not arrive in time. + Raises ValueError if length exceeds the 64 KB safety bound. + """ + deadline = time.monotonic() + timeout_secs + header_prefix = _recv_exact(sock, 8, deadline) + length = struct.unpack("!I", header_prefix[4:8])[0] + if length > _MAX_SOMEIP_MSG_SIZE: + raise ValueError(f"SOME/IP length field {length} exceeds safety bound {_MAX_SOMEIP_MSG_SIZE}") + body = _recv_exact(sock, length, deadline) + resp, _ = SOMEIPHeader.parse(header_prefix + body) + return resp + + +def tcp_listen(host_ip: str, port: int = 0, backlog: int = 1) -> socket.socket: + """Create a TCP server socket and start listening. + + If *port* is 0, the OS assigns an ephemeral port. Use + ``sock.getsockname()[1]`` to retrieve the assigned port. + + Returns the listening socket. Caller must close it. + """ + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((host_ip, port)) + srv.listen(backlog) + return srv + + +def tcp_accept_and_receive_notification( + srv_sock: socket.socket, + event_id: int, + service_id: int, + timeout_secs: float = 8.0, +) -> SOMEIPHeader: + """Accept a TCP connection and receive one SOME/IP notification. + + The DUT connects to our listening socket to deliver event notifications. + Uses the same SOME/IP TCP framing as tcp_receive_response(). + + Returns the first notification matching *service_id* and *event_id*. + Raises socket.timeout or AssertionError if no matching notification arrives. + """ + srv_sock.settimeout(timeout_secs) + conn, _ = srv_sock.accept() + try: + deadline = time.monotonic() + timeout_secs + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + header_prefix = _recv_exact(conn, 8, deadline) + length = struct.unpack("!I", header_prefix[4:8])[0] + if length > _MAX_SOMEIP_MSG_SIZE: + raise ValueError(f"SOME/IP length field {length} exceeds safety bound {_MAX_SOMEIP_MSG_SIZE}") + body = _recv_exact(conn, length, deadline) + msg, _ = SOMEIPHeader.parse(header_prefix + body) + if msg.service_id == service_id and msg.method_id == event_id: + return msg + raise socket.timeout( + f"No SOME/IP notification for service 0x{service_id:04x} " + f"event 0x{event_id:04x} received via TCP within {timeout_secs}s" + ) + finally: + conn.close() diff --git a/tests/tc8_conformance/helpers/timing.py b/tests/tc8_conformance/helpers/timing.py new file mode 100644 index 000000000..ccbbbef19 --- /dev/null +++ b/tests/tc8_conformance/helpers/timing.py @@ -0,0 +1,79 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Timestamped SD capture for TC8 phase timing tests. + +Captures OfferService entries with monotonic timestamps to verify +SD phase timing (initial wait, repetition, main phase). + +Delegates parsing and socket setup to :mod:`helpers.sd_helpers`. +""" + +import socket +import time +from typing import List, Tuple + +from someip.header import SOMEIPSDEntry + +from helpers.constants import SD_MULTICAST_ADDR, SD_PORT +from helpers.sd_helpers import open_multicast_socket, parse_sd_offers + + +def collect_sd_offers_from_socket( + sock: socket.socket, + count: int, + timeout_secs: float, +) -> List[Tuple[float, SOMEIPSDEntry]]: + """Collect *count* OfferService entries with timestamps from *sock*. + + Raises ``TimeoutError`` if fewer than *count* entries arrive in time. + """ + deadline = time.monotonic() + timeout_secs + collected: List[Tuple[float, SOMEIPSDEntry]] = [] + + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 0.5)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + + recv_ts = time.monotonic() + for entry in parse_sd_offers(data): + collected.append((recv_ts, entry)) + + if len(collected) >= count: + return collected + + if len(collected) < count: + raise TimeoutError( + f"Captured only {len(collected)} SD OFFER entries within {timeout_secs:.1f}s (expected at least {count})" + ) + return collected # pragma: no cover + + +def capture_sd_offers_with_timestamps( + host_ip: str, + multicast_group: str = SD_MULTICAST_ADDR, + port: int = SD_PORT, + count: int = 3, + timeout_secs: float = 20.0, +) -> List[Tuple[float, SOMEIPSDEntry]]: + """Open a multicast socket and capture *count* OfferService entries with timestamps.""" + sock = open_multicast_socket(host_ip, multicast_group, port) + try: + return collect_sd_offers_from_socket(sock, count, timeout_secs) + finally: + sock.close() diff --git a/tests/tc8_conformance/helpers/udp_helpers.py b/tests/tc8_conformance/helpers/udp_helpers.py new file mode 100644 index 000000000..1cd988f5d --- /dev/null +++ b/tests/tc8_conformance/helpers/udp_helpers.py @@ -0,0 +1,68 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""UDP transport helpers for SOME/IP over UDP (unreliable binding). + +SOME/IP PRS_SOMEIP_00142 and PRS_SOMEIP_00569 require the receiver to +parse each SOME/IP message within a UDP datagram sequentially using the +length field as the sole framing indicator. +""" + +import socket +import time +from someip.header import SOMEIPHeader + + +def udp_send_concatenated( + sock: socket.socket, + addr: tuple[str, int], + messages: list[bytes], +) -> None: + """Send multiple SOME/IP messages concatenated into ONE UDP datagram. + + SOME/IP PRS_SOMEIP_00142 and PRS_SOMEIP_00569 require the DUT to parse + multiple SOME/IP messages packed into a single UDP datagram. This helper + concatenates all *messages* and delivers them as one ``sendto()`` call + so the DUT receives them in a single datagram. + + Used by: SOMEIP_ETS_069. + """ + sock.sendto(b"".join(messages), addr) + + +def udp_receive_responses( + sock: socket.socket, + count: int, + timeout_secs: float = 5.0, +) -> list[SOMEIPHeader]: + """Receive exactly *count* SOME/IP responses from a UDP socket. + + Uses a single shared deadline across all *count* receive calls so the + total wait never exceeds *timeout_secs*. Each ``recvfrom()`` call returns + one complete SOME/IP message (the DUT sends one response datagram per + request processed from the concatenated datagram). + + Raises ``socket.timeout`` if not all responses arrive in time. + + Used by: SOMEIP_ETS_069. + """ + deadline = time.monotonic() + timeout_secs + responses: list[SOMEIPHeader] = [] + while len(responses) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout(f"udp_receive_responses: deadline exceeded after {len(responses)}/{count} responses") + sock.settimeout(remaining) + data, _ = sock.recvfrom(65535) + msg, _ = SOMEIPHeader.parse(data) + responses.append(msg) + return responses diff --git a/tests/tc8_conformance/tc8_itf_conftest.py b/tests/tc8_conformance/tc8_itf_conftest.py new file mode 100644 index 000000000..d4f9b09c8 --- /dev/null +++ b/tests/tc8_conformance/tc8_itf_conftest.py @@ -0,0 +1,288 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""ITF conftest for TC8 conformance tests running inside QEMU via score_itf. + +Loaded as a pytest plugin by the integration_test() Bazel target; provides +session-scoped fixtures for DUT IP addressing, DUT stack lifecycle, and vsomeip +config rendering on the QEMU guest. +""" + +import logging +import os +import socket +import struct +import subprocess +import time +from typing import Generator + +import pytest + +from capture import stop_capture, tcpdump_capture +from helpers.dut_lifecycle import _TargetProcess, launch_dut + +_logger = logging.getLogger(__name__) + + +def pytest_configure(config: pytest.Config) -> None: + """Register TC8 markers (mirrors conftest.py).""" + config.addinivalue_line("markers", "tc8: mark test as a TC8 conformance test") + config.addinivalue_line("markers", "conformance: mark test as a protocol conformance test") + config.addinivalue_line("markers", "network: mark test as requiring a non-loopback network interface") + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Auto-mark all collected items as tc8 and conformance.""" + for item in items: + item.add_marker(pytest.mark.tc8) + item.add_marker(pytest.mark.conformance) + + +def _wait_for_sd_readiness( + tester_ip: str, + timeout_secs: float = 10.0, +) -> bool: + """Block until the DUT sends at least one multicast OfferService, or the + timeout expires. Returns True on success, False on timeout. + """ + from helpers.constants import SD_MULTICAST_ADDR, SD_PORT # noqa: PLC0415 + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + pass + sock.bind(("", SD_PORT)) + group_bytes = socket.inet_aton(SD_MULTICAST_ADDR) + iface_bytes = socket.inet_aton(tester_ip) + mreq = struct.pack("4s4s", group_bytes, iface_bytes) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + deadline = time.monotonic() + timeout_secs + try: + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 1.0)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + if len(data) < 20: + continue + service_id = int.from_bytes(data[0:2], "big") + if service_id != 0xFFFF: + continue + sd_offset = 16 + if len(data) < sd_offset + 12: + continue + entries_len = int.from_bytes(data[sd_offset + 4 : sd_offset + 8], "big") + entry_start = sd_offset + 8 + pos = entry_start + while pos + 16 <= entry_start + entries_len and pos + 16 <= len(data): + entry_type = data[pos] + if entry_type == 0x01: # OfferService + return True + pos += 16 + return False + finally: + sock.close() + + +def _cleanup_vsomeip_sockets_on_target(target_init: object) -> None: + """Remove stale vsomeip routing-manager sockets on the QEMU guest. Must be + called before each DUT restart, or the new vsomeip instance will fail to + become routing manager and send no SD messages. + """ + for vsomeip_socket_glob in ("/tmp/vsomeip-*", "/var/run/vsomeip-*"): + exit_code, output = target_init.execute(f"rm -f {vsomeip_socket_glob}") + if exit_code != 0: + _logger.warning( + "vsomeip socket cleanup at %s returned %d: %s", + vsomeip_socket_glob, + exit_code, + output.decode(errors="replace"), + ) + + +@pytest.fixture(scope="session", autouse=True) +def someip_pcap_capture() -> Generator[None, None, None]: + """Capture SOME/IP traffic on the host TAP interface for the whole session. + A missing or permission-denied tcpdump becomes a warning, not a failure. + """ + _tc8_dut_ip_key = "TC8_DUT_IP" + _tc8_dut_ip_default = "169.254.158.190" + if _tc8_dut_ip_key not in os.environ: + _logger.info( + "someip_pcap_capture: TC8_DUT_IP env var is not set; " + "defaulting to %s (the QEMU TAP bridge IP). " + "Override via: export TC8_DUT_IP=", + _tc8_dut_ip_default, + ) + dut_ip = os.environ.get(_tc8_dut_ip_key, _tc8_dut_ip_default) + sd_port = os.environ.get("TC8_SD_PORT", "30490") + svc_tcp_port = os.environ.get("TC8_SVC_TCP_PORT", "30510") + + # Multicast event group port (fixed in tc8_someipd_sd.json eventgroup 0x4465). + _multicast_event_port = "40490" + + # BPF filter breakdown: + # udp port : SOME/IP-SD (multicast and unicast) + # udp port 40490: multicast event data (eventgroup 0x4465) + # host udp: all UDP to and from DUT (service and dynamic ports) + # host tcp: SOME/IP TCP only, excludes SSH port 22 + bpf = ( + f"(udp port {sd_port})" + f" or (udp port {_multicast_event_port})" + f" or (host {dut_ip} and (udp or (tcp and port {svc_tcp_port})))" + ) + + output_dir = os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR", ".") + output_file = os.path.join(output_dir, "someip_capture.pcap") + + proc: subprocess.Popen[bytes] | None = None + try: + proc = tcpdump_capture(bpf, output_file=output_file) + _logger.info( + "someip_pcap_capture: tcpdump started (filter=%r output=%s)", + bpf, + output_file, + ) + except (RuntimeError, OSError) as exc: + _logger.warning( + "someip_pcap_capture: tcpdump unavailable - continuing without pcap. %s", + exc, + ) + + try: + yield + finally: + if proc is not None: + clean = stop_capture(proc, timeout=5.0) + proc.__exit__(None, None, None) + if clean: + _logger.info( + "someip_pcap_capture: tcpdump stopped cleanly, pcap complete. output=%s", + output_file, + ) + else: + _logger.warning( + "someip_pcap_capture: tcpdump did not respond to SIGINT within " + "5 s; SIGKILL used. All captured packets are present (written " + "per-packet via -U) but pcap trailer may be absent. output=%s", + output_file, + ) + + +@pytest.fixture(scope="session") +def dut_ip() -> str: + """QEMU guest IP used as the unicast destination for all SD and data sends. + Overrideable via TC8_DUT_IP. + """ + return os.environ.get("TC8_DUT_IP", "169.254.158.190") + + +@pytest.fixture(scope="session") +def host_ip() -> str: + """Host TAP interface IP for multicast group joins; pass to multicast + socket helpers, not to unicast send helpers. Overrideable via + TC8_TESTER_IP. + """ + return os.environ.get("TC8_TESTER_IP", "169.254.21.88") + + +@pytest.fixture(scope="session") +def tester_ip(host_ip: str) -> str: + """Tester-side socket bind IP; equals host_ip under QEMU.""" + return host_ip + + +@pytest.fixture(scope="session") +def tc8_itf_config_setup(target_init: object, dut_ip: str) -> None: + """Render vsomeip config templates on the QEMU guest using the DUT IP and + port env vars. Runs once per session; all per-class DUT restarts share the + rendered configs. + """ + sd_port = os.environ.get("TC8_SD_PORT", "30490") + svc_port = os.environ.get("TC8_SVC_PORT", "30509") + svc_tcp_port = os.environ.get("TC8_SVC_TCP_PORT", "30510") + service_id = os.environ.get("TC8_SERVICE_ID", "0x1234") + instance_id = os.environ.get("TC8_INSTANCE_ID", "0x5678") + + _logger.info( + "Rendering TC8 vsomeip configs on QEMU guest: DUT=%s SD=%s SVC=%s TCP=%s", + dut_ip, + sd_port, + svc_port, + svc_tcp_port, + ) + + for tmpl_name, out_name in [ + ("tc8_sd.json.tmpl", "tc8_sd.json"), + ("tc8_service.json.tmpl", "tc8_service.json"), + ("tc8_multi.json.tmpl", "tc8_multi.json"), + ]: + # Use | as sed delimiter to avoid conflicts with / in path substitutions. + # Output goes to /tmp/ because the QNX8 IFS root is read-only at runtime. + cmd = ( + f"sed" + f" -e 's/__TC8_HOST_IP__/{dut_ip}/g'" + f" -e 's/__TC8_SERVICE_ID__/{service_id}/g'" + f" -e 's/__TC8_INSTANCE_ID__/{instance_id}/g'" + f" -e 's/__TC8_SD_PORT__/{sd_port}/g'" + f" -e 's/__TC8_SVC_PORT__/{svc_port}/g'" + f" -e 's/__TC8_SVC_TCP_PORT__/{svc_tcp_port}/g'" + f" -e 's|__TC8_LOG_DIR__|/tmp|g'" + f" /{tmpl_name} > /tmp/{out_name}" + ) + exit_code, output = target_init.execute(cmd) + if exit_code != 0: + pytest.fail( + f"Failed to render {tmpl_name} on QEMU guest (exit {exit_code}): {output.decode(errors='replace')}" + ) + _logger.info("Rendered /%s -> /tmp/%s on QEMU guest", tmpl_name, out_name) + + +@pytest.fixture(scope="class") +def dut( + target_init: object, + tc8_itf_config_setup: None, # noqa: ARG001 (ensures configs are rendered first) + request: pytest.FixtureRequest, + host_ip: str, +) -> Generator[_TargetProcess, None, None]: + """Launch the full DUT stack on the QEMU guest and yield a .poll() adapter. + + Delegates process launch to :func:`helpers.dut_lifecycle.launch_dut`. The + fixture skips the test class if the DUT does not send an OfferService within 10 s. + """ + config_name: str = getattr(request.module, "SOMEIP_CONFIG", "tc8_someipd_sd.json") + + _cleanup_vsomeip_sockets_on_target(target_init) + + proc: _TargetProcess = launch_dut(config_name, target_init=target_init) # type: ignore[assignment] + + if not _wait_for_sd_readiness(host_ip): + proc.terminate() + pytest.skip( + "DUT did not reach SD main phase within 10 s (QEMU/ITF). " + "Check TAP bridge, multicast route on guest, and vsomeip config." + ) + + _logger.info("DUT reached SD main phase on QEMU guest") + + try: + yield proc + finally: + proc.terminate() + _cleanup_vsomeip_sockets_on_target(target_init) diff --git a/tests/tc8_conformance/test_event_notification.py b/tests/tc8_conformance/test_event_notification.py new file mode 100644 index 000000000..1eaa14e87 --- /dev/null +++ b/tests/tc8_conformance/test_event_notification.py @@ -0,0 +1,638 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 Event Notification tests: TC8-EVT-001 through TC8-EVT-006. + +See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture. +""" + +import socket +import subprocess +import time +import pytest + +from attribute_plugin import add_test_properties + +from helpers.event_helpers import ( + assert_notification_header, + capture_any_notifications, + capture_notifications, + subscribe_and_wait_ack, +) +from helpers.sd_helpers import capture_sd_offers, open_multicast_socket +from helpers.sd_sender import ( + L4Protocols, + SOMEIPSDEntryType, + capture_unicast_sd_entries, + open_sender_socket, + send_subscribe_eventgroup, +) +from helpers.constants import ( + DUT_RELIABLE_PORT, + EVENT_FIELD_UINT8, + EVENT_TEST_UINT8, + EVENT_TEST_UINT8_RELIABLE, + EVENTGROUP_TCP_RELIABLE, + EVENTGROUP_UDP_MULTICAST, + EVENTGROUP_UDP_UNICAST, + INSTANCE_ID, + MAJOR_VERSION, + MULTICAST_ADDR, + MULTICAST_EVENT_PORT, + SD_PORT, + SERVICE_ID, +) +from helpers.tcp_helpers import tcp_receive_response +from someip.header import SOMEIPMessageType + +pytestmark = pytest.mark.skip(reason="Production stack does not forward events without mw::com ETS app (2026-08-11)") + +SOMEIP_CONFIG: str = "tc8_someipd_service.json" + + +class TestEventNotificationFormat: + """TC8-EVT-001/002, SOMEIPSRV_RPC_15/16: Notification format and delivery strategy.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__evt_subscription"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_evt_001_notification_message_type( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-EVT-001: Event notification has message_type = NOTIFICATION (0x02).""" + assert dut.poll() is None, "DUT is not running" + + try: + capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0) + except (TimeoutError, OSError): + pytest.skip("DUT did not offer service within timeout") + + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port = notif_sock.getsockname()[1] + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + notif_port=notif_port, + ) + # DUT notifies every 500 ms (tc8_someipd_service.json update cycle); wait for at least one + notifs = capture_notifications( + notif_sock, + EVENT_TEST_UINT8, + SERVICE_ID, + count=1, + timeout_secs=5.0, + ) + assert notifs, "TC8-EVT-001: No NOTIFICATION received after subscription" + assert notifs[0].message_type == SOMEIPMessageType.NOTIFICATION, ( + f"TC8-EVT-001: message_type = 0x{notifs[0].message_type:02x}, " + f"expected NOTIFICATION (0x{SOMEIPMessageType.NOTIFICATION:02x})" + ) + finally: + if sd_sock: + sd_sock.close() + notif_sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__evt_subscription"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_evt_002_correct_event_id( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-EVT-002: Notification carries the correct event_id in the method_id field.""" + assert dut.poll() is None, "DUT is not running" + + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port = notif_sock.getsockname()[1] + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + notif_port=notif_port, + ) + notifs = capture_notifications( + notif_sock, + EVENT_TEST_UINT8, + SERVICE_ID, + count=1, + timeout_secs=5.0, + ) + assert notifs, "TC8-EVT-002: No NOTIFICATION received after subscription" + assert_notification_header(notifs[0], EVENT_TEST_UINT8) + finally: + if sd_sock: + sd_sock.close() + notif_sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__evt_subscription"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_15_cyclic_notification_rate( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_RPC_15: Cyclic event notifications arrive at the configured cycle period.""" + assert dut.poll() is None, "DUT is not running" + + _CYCLE_MS = 500 + _TOLERANCE_FACTOR = 3.0 + _MIN_INTERVAL = (_CYCLE_MS / 1000.0) / _TOLERANCE_FACTOR + _MAX_INTERVAL = (_CYCLE_MS / 1000.0) * _TOLERANCE_FACTOR + + try: + capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0) + except (TimeoutError, OSError): + pytest.skip("DUT did not offer service within timeout") + + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port = notif_sock.getsockname()[1] + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + notif_port=notif_port, + ttl=30, # 30 s > 10 s observation window; keeps subscription alive. + ) + + timestamps: list = [] + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline and len(timestamps) < 4: + remaining = deadline - time.monotonic() + notif_sock.settimeout(min(remaining, 2.0)) + try: + data, _ = notif_sock.recvfrom(65535) + except socket.timeout: + continue + try: + from someip.header import SOMEIPHeader as _HDR + + msg, _ = _HDR.parse(data) + if msg.service_id == SERVICE_ID and msg.method_id == EVENT_TEST_UINT8: + timestamps.append(time.monotonic()) + except Exception: + continue + + assert len(timestamps) >= 4, ( + f"SOMEIPSRV_RPC_15: received only {len(timestamps)} notification(s) " + f"within 10 s; expected at least 4 at ~{_CYCLE_MS} ms cycle" + ) + + # Check intervals between consecutive notifications (skip first gap + # which may be shorter due to initial-event delivery). + intervals = [timestamps[i + 1] - timestamps[i] for i in range(1, len(timestamps) - 1)] + for idx, interval in enumerate(intervals): + assert _MIN_INTERVAL <= interval <= _MAX_INTERVAL, ( + f"SOMEIPSRV_RPC_15: notification interval {idx + 1} = " + f"{interval * 1000:.0f} ms; expected [{_MIN_INTERVAL * 1000:.0f} ms, " + f"{_MAX_INTERVAL * 1000:.0f} ms] " + f"(configured cycle: {_CYCLE_MS} ms)" + ) + finally: + if sd_sock: + sd_sock.close() + notif_sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__evt_subscription"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_16_field_notifies_only_on_change( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_RPC_16: Field event sends one initial notification then stays silent.""" + assert dut.poll() is None, "DUT is not running" + + try: + from helpers.sd_helpers import capture_sd_offers as _capture_sd_offers + + _capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0) + except (TimeoutError, OSError): + pytest.skip("DUT did not offer service within timeout") + + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port = notif_sock.getsockname()[1] + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + notif_port=notif_port, + ) + + initial = capture_notifications( + notif_sock, + EVENT_FIELD_UINT8, + SERVICE_ID, + count=1, + timeout_secs=5.0, + ) + assert initial, ( + "SOMEIPSRV_RPC_16: No initial-value notification received after " + f"subscribing to eventgroup 0x{EVENTGROUP_UDP_UNICAST:04x}" + ) + + # Wait 3 seconds. No further 0x8006 notifications should arrive because + # TestFieldUINT8 is not sent periodically (cycle_ms=0) and the field value + # does not change during the test. Filter to EVENT_FIELD_UINT8 only: other + # members of eventgroup 0x0002 (e.g. 0x8001) send cyclic events which are + # irrelevant to this field-change semantics check. + subsequent = capture_notifications( + notif_sock, + EVENT_FIELD_UINT8, + SERVICE_ID, + count=1, + timeout_secs=3.0, + ) + assert not subsequent, ( + f"SOMEIPSRV_RPC_16: {len(subsequent)} unexpected notification(s) of " + f"TestFieldUINT8 (0x{EVENT_FIELD_UINT8:04x}) received within 3 s; " + "cycle_ms=0 and the field value has not changed — no further " + "notifications expected" + ) + finally: + if sd_sock: + sd_sock.close() + notif_sock.close() + + +class TestEventSubscriptionGating: + """TC8-EVT-003/004: Notifications only to subscribed endpoints.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__evt_subscription"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_evt_003_notification_only_to_subscriber( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-EVT-003: Subscribed endpoint receives notifications; unsubscribed does not.""" + assert dut.poll() is None, "DUT is not running" + + sub_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sub_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sub_sock.bind((tester_ip, 0)) + sub_port = sub_sock.getsockname()[1] + + unsub_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + unsub_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + unsub_sock.bind((tester_ip, 0)) + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + notif_port=sub_port, + ) + + notifs = capture_notifications( + sub_sock, + EVENT_TEST_UINT8, + SERVICE_ID, + count=1, + timeout_secs=5.0, + ) + assert notifs, "TC8-EVT-003: No notification on subscribed socket" + + stray = capture_any_notifications(unsub_sock, SERVICE_ID, timeout_secs=2.0) + assert not stray, f"TC8-EVT-003: {len(stray)} notification(s) on unsubscribed socket" + finally: + if sd_sock: + sd_sock.close() + sub_sock.close() + unsub_sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__evt_subscription"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_evt_004_no_notification_before_subscribe( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-EVT-004: No notifications arrive before subscribing.""" + assert dut.poll() is None, "DUT is not running" + + # Listen without subscribing. DUT notifies every 2000 ms, so 3 s window is sufficient. + listen_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + listen_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listen_sock.bind((tester_ip, 0)) + + try: + stray = capture_any_notifications(listen_sock, SERVICE_ID, timeout_secs=3.0) + assert not stray, f"TC8-EVT-004: {len(stray)} notification(s) received without subscription" + finally: + listen_sock.close() + + +class TestEventStopSubscribe: + """TC8-EVT-006: StopSubscribeEventgroup ceases notifications.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__evt_subscription"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_evt_006_stop_subscribe_ceases_notifications( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-EVT-006: Notifications stop after StopSubscribeEventgroup (TTL=0).""" + assert dut.poll() is None, "DUT is not running" + + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port = notif_sock.getsockname()[1] + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + notif_port=notif_port, + ) + notifs = capture_notifications( + notif_sock, + EVENT_TEST_UINT8, + SERVICE_ID, + count=1, + timeout_secs=5.0, + ) + assert notifs, "TC8-EVT-006: pre-condition failed — no notifications before StopSubscribe" + + # StopSubscribe (TTL=0) + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ttl=0, + ) + + # DUT sends every 500 ms (tc8_someipd_service.json update cycle), so a 4 s window catches any leaks + post = capture_any_notifications(notif_sock, SERVICE_ID, timeout_secs=4.0) + assert not post, f"TC8-EVT-006: {len(post)} notification(s) after StopSubscribeEventgroup" + finally: + if sd_sock: + sd_sock.close() + notif_sock.close() + + +class TestMulticastEventDelivery: + """TC8-EVT-005: Notifications for a multicast eventgroup arrive on the multicast address.""" + + @pytest.mark.network + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__evt_subscription"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_evt_005_multicast_notification_delivery( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-EVT-005: Notifications arrive on the multicast group after subscribing to 0x4465.""" + assert dut.poll() is None, "DUT is not running" + + try: + capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0) + except (TimeoutError, OSError): + pytest.skip("DUT did not offer service within timeout") + + # Open multicast notification socket BEFORE subscribing so we don't miss + # the first notification the DUT sends to the multicast group. + try: + mcast_sock = open_multicast_socket( + host_ip, + multicast_group=MULTICAST_ADDR, + port=MULTICAST_EVENT_PORT, + ) + except OSError as exc: + pytest.skip(f"Cannot join multicast group {MULTICAST_ADDR}:{MULTICAST_EVENT_PORT} on {host_ip}: {exc}") + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_MULTICAST, + MAJOR_VERSION, + notif_port=mcast_sock.getsockname()[1], + ) + # DUT sends notifications every 500 ms (tc8_someipd_service.json update cycle). + # Allow up to 5 s for the first notification to arrive on the multicast socket. + notifs = capture_any_notifications(mcast_sock, SERVICE_ID, timeout_secs=5.0) + assert notifs, ( + f"TC8-EVT-005: No SOME/IP notification received on multicast " + f"{MULTICAST_ADDR}:{MULTICAST_EVENT_PORT} within 5 s " + f"after subscribing to eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + ) + assert notifs[0].message_type == SOMEIPMessageType.NOTIFICATION, ( + f"TC8-EVT-005: message_type mismatch on multicast socket: " + f"got 0x{notifs[0].message_type:02x}, expected NOTIFICATION (0x02)" + ) + finally: + if sd_sock: + sd_sock.close() + mcast_sock.close() + + +class TestEventTcpNotification: + """SOMEIPSRV_RPC_17: Event notification delivery via TCP (reliable transport). + + When a subscriber specifies a TCP endpoint in SubscribeEventgroup, + the DUT must deliver event notifications over a TCP connection to + the subscriber's TCP port. + """ + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__tcp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_rpc_17_tcp_event_notification_delivery( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_RPC_17: Notification arrives on TCP after subscribing with TCP endpoint.""" + assert dut.poll() is None, "DUT is not running" + + try: + capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0) + except (TimeoutError, OSError): + pytest.skip("DUT did not offer service within timeout") + + # Connect TCP to DUT's reliable port. PRS_SOMEIPSD_00362 requires a + # client-initiated TCP connection before the DUT will accept a reliable + # subscription. Bind to tester_ip so the source address matches the + # subscriber_ip in the SubscribeEventgroup entry (DUT validates the exact + # subscriber endpoint). + tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_sock.settimeout(5.0) + tcp_sock.bind((tester_ip, 0)) + tcp_sock.connect((dut_ip, DUT_RELIABLE_PORT)) + local_tcp_port = tcp_sock.getsockname()[1] + time.sleep(0.5) # give the DUT time to register the accepted TCP endpoint + + # Dummy UDP socket setup to satisfy vsomeip's mixed-group requirement + udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_sock.bind((tester_ip, 0)) + local_udp_port = udp_sock.getsockname()[1] + + sd_sock = open_sender_socket(tester_ip) + try: + # Subscribe to the TCP-only eventgroup using the TCP connection's + # source port so the DUT's TCP-connected check passes. + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_TCP_RELIABLE, + MAJOR_VERSION, + subscriber_ip=tester_ip, + tcp_port=local_tcp_port, + udp_port=local_udp_port, + ) + + entries = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=10.0, + resend=lambda: send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_TCP_RELIABLE, + MAJOR_VERSION, + subscriber_ip=tester_ip, + tcp_port=local_tcp_port, + udp_port=local_udp_port, + ), + max_results=1, + ) + acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_TCP_RELIABLE and e.ttl > 0] + assert acks, f"No SubscribeEventgroupAck for TCP eventgroup 0x{EVENTGROUP_TCP_RELIABLE:04x}" + + # Eventgroup 0x0005 contains TestEventUINT8Reliable (0x8003, TCP-only). + # Drain until we see the expected event ID. + deadline = time.monotonic() + 8.0 + matched = None + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + msg = tcp_receive_response(tcp_sock, timeout_secs=remaining) + if msg.message_type == SOMEIPMessageType.NOTIFICATION and msg.method_id == EVENT_TEST_UINT8_RELIABLE: + matched = msg + break + assert matched is not None, ( + f"SOMEIPSRV_RPC_17: No NOTIFICATION with event_id=0x{EVENT_TEST_UINT8_RELIABLE:04x} " + f"received over TCP within 8 s" + ) + finally: + sd_sock.close() + tcp_sock.close() diff --git a/tests/tc8_conformance/test_field_conformance.py b/tests/tc8_conformance/test_field_conformance.py new file mode 100644 index 000000000..87e610639 --- /dev/null +++ b/tests/tc8_conformance/test_field_conformance.py @@ -0,0 +1,376 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 Field Conformance tests: TC8-FLD-001 through TC8-FLD-004. + +Fields extend events with three properties: + 1. Initial value: the DUT sends the last known value to a new subscriber immediately. + 2. Getter: a REQUEST to method 0x26 returns the current field value. + 3. Setter: a REQUEST to method 0x27 updates the field, notifies subscribers, and + returns a RESPONSE. + +The production DUT is ``someipd``, which embeds the SOME/IP and SOME/IP-SD +protocol stack (vsomeip). It is configured via ``tc8_someipd_config.bin`` +(FlatBuffer binary generated from ``tc8_someipd_config.json`` at build time). +TestFieldUINT8 uses notify ID 0x8006, getter 0x26, setter 0x27, eventgroup +0x0002 (EVENTGROUP_UDP_UNICAST). The DUT caches the field value and delivers +it as an initial-event notification to each new subscriber (is_field=true). +TestFieldUINT8 is not sent periodically (cycle_ms=0). + +See ``docs/tc8_conformance/requirements.rst`` for requirement traceability. +""" + +import socket +import subprocess +import time +import pytest + +from attribute_plugin import add_test_properties + +from helpers.event_helpers import ( + capture_notifications, + subscribe_and_wait_ack, +) +from helpers.field_helpers import ( + send_get_field, + send_get_field_tcp, + send_set_field, + send_set_field_tcp, +) +from helpers.constants import ( + DUT_RELIABLE_PORT, + DUT_UNRELIABLE_PORT, + EVENT_FIELD_UINT8, + EVENTGROUP_UDP_UNICAST, + INSTANCE_ID, + MAJOR_VERSION, + METHOD_FIELD_UINT8_GET, + METHOD_FIELD_UINT8_SET, + SD_PORT, + SERVICE_ID, +) +from helpers.sd_helpers import capture_sd_offers +from someip.header import SOMEIPReturnCode + +pytestmark = pytest.mark.skip( + reason="Production stack does not deliver initial field values or handle getter/setter methods without mw::com ETS app (2026-08-11)" +) + +#: SOME/IP stack config template (is_field=true, eventgroup 0x0002 UDP unicast). +SOMEIP_CONFIG: str = "tc8_someipd_service.json" + + +class TestFieldInitialValue: + """TC8-FLD-001/002: DUT sends the cached field value to a new subscriber.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__fld_initial_value"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_fld_001_initial_notification_on_subscribe( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-FLD-001: Subscribing to a field eventgroup triggers an immediate NOTIFICATION. + + The DUT caches the last notified value for ``is_field: true`` events and + re-sends it to each new subscriber without waiting for the next notify cycle. + """ + assert dut.poll() is None, "DUT is not running" + + # Wait until the DUT has sent at least one SD offer. + try: + capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0) + except (TimeoutError, OSError): + pytest.skip("DUT did not offer service within timeout") + + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port = notif_sock.getsockname()[1] + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + notif_port=notif_port, + ) + # Wait actively for the DUT to send the initial-event notification. + notifs = capture_notifications( + notif_sock, + EVENT_FIELD_UINT8, + SERVICE_ID, + count=1, + timeout_secs=1.5, + ) + assert notifs, ( + "TC8-FLD-001: No initial NOTIFICATION received after subscribing to " + "a field eventgroup. Verify tc8_dut is running with is_field=true " + "for TestFieldUINT8 (0x8006)." + ) + finally: + if sd_sock: + sd_sock.close() + notif_sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__fld_initial_value"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_fld_002_is_field_sends_initial_value_within_one_second( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-FLD-002: Initial notification for a field arrives within 1 s of subscribe ACK. + + Contrast with ``is_field: false`` events which only deliver notifications + on the next regular notify cycle. A field delivers the cached value + immediately, so the first notification should arrive well within 1 second. + """ + assert dut.poll() is None, "DUT is not running" + + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port = notif_sock.getsockname()[1] + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + notif_port=notif_port, + ) + subscribe_time = time.monotonic() + # A field (is_field=true) delivers the cached value immediately after ACK. + notifs = capture_notifications( + notif_sock, + EVENT_FIELD_UINT8, + SERVICE_ID, + count=1, + timeout_secs=1.5, + ) + elapsed_ms = (time.monotonic() - subscribe_time) * 1000.0 + + assert notifs, ( + "TC8-FLD-002: No initial NOTIFICATION received within 1.5 s of subscribe. " + f"Elapsed: {elapsed_ms:.0f} ms. " + "A field (is_field=true) must deliver the cached value immediately." + ) + finally: + if sd_sock: + sd_sock.close() + notif_sock.close() + + +class TestFieldGetSet: + """TC8-FLD-003/004: Field getter returns current value; setter updates and notifies.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__fld_get_set"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_fld_003_getter_returns_current_value( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-FLD-003: GET request (method 0x0001) returns a RESPONSE with the current value.""" + assert dut.poll() is None, "DUT is not running" + + resp = send_get_field( + dut_ip, + SERVICE_ID, + METHOD_FIELD_UINT8_GET, + DUT_UNRELIABLE_PORT, + client_id=0x0030, + session_id=0x0001, + ) + + assert resp.return_code == SOMEIPReturnCode.E_OK, ( + f"TC8-FLD-003: GET returned code 0x{resp.return_code.value:02x} ({resp.return_code.name}), expected E_OK" + ) + assert resp.payload, "TC8-FLD-003: GET response has empty payload — expected current field value" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__fld_get_set"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_fld_004_setter_updates_value_and_notifies( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-FLD-004: SET request (method 0x0002) updates the field and notifies subscribers.""" + assert dut.poll() is None, "DUT is not running" + + new_value = b"\xca\xfe" + + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port = notif_sock.getsockname()[1] + + sd_sock = None + try: + sd_sock = subscribe_and_wait_ack( + tester_ip, + dut_ip, + SD_PORT, + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + notif_port=notif_port, + ) + + # Drain any initial value notification that arrived due to is_field=true. + capture_notifications(notif_sock, EVENT_FIELD_UINT8, SERVICE_ID, count=1, timeout_secs=1.5) + + set_resp = send_set_field( + dut_ip, + SERVICE_ID, + METHOD_FIELD_UINT8_SET, + new_value, + DUT_UNRELIABLE_PORT, + client_id=0x0030, + session_id=0x0002, + ) + assert set_resp.return_code == SOMEIPReturnCode.E_OK, ( + f"TC8-FLD-004: SET returned code 0x{set_resp.return_code.value:02x} " + f"({set_resp.return_code.name}), expected E_OK" + ) + + notifs = capture_notifications( + notif_sock, + EVENT_FIELD_UINT8, + SERVICE_ID, + count=1, + timeout_secs=3.0, + ) + assert notifs, ( + "TC8-FLD-004: No NOTIFICATION received after SET — " + "DUT must notify all subscribers when a field value changes" + ) + received_payload = bytes(notifs[0].payload) if notifs[0].payload else b"" + assert received_payload == new_value, ( + f"TC8-FLD-004: Notification payload mismatch: got {received_payload!r}, expected {new_value!r}" + ) + finally: + if sd_sock: + sd_sock.close() + notif_sock.close() + + +class TestFieldTcpTransport: + """Field GET/SET over TCP (SOMEIPSRV_RPC_17). + + These tests verify that someipd correctly handles field GET and SET + requests over TCP (reliable transport binding). The DUT is configured + with both unreliable (UDP ``DUT_UNRELIABLE_PORT``) and reliable (TCP ``DUT_RELIABLE_PORT``) ports. + """ + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__tcp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_rpc_17_tcp_field_getter( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_17: GET field request over TCP returns the current value.""" + assert dut.poll() is None, "DUT is not running" + + resp = send_get_field_tcp( + dut_ip, + SERVICE_ID, + METHOD_FIELD_UINT8_GET, + DUT_RELIABLE_PORT, + client_id=0x0040, + session_id=0x0010, + ) + + assert resp.return_code == SOMEIPReturnCode.E_OK, ( + f"SOMEIPSRV_RPC_17: TCP GET returned code 0x{resp.return_code.value:02x} " + f"({resp.return_code.name}), expected E_OK" + ) + assert resp.payload, "SOMEIPSRV_RPC_17: TCP GET response has empty payload — expected current field value" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__tcp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_rpc_17_tcp_field_setter( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_17: SET field request over TCP updates the value.""" + assert dut.poll() is None, "DUT is not running" + + new_value = b"\xbe\xef" + set_resp = send_set_field_tcp( + dut_ip, + SERVICE_ID, + METHOD_FIELD_UINT8_SET, + new_value, + DUT_RELIABLE_PORT, + client_id=0x0040, + session_id=0x0011, + ) + + assert set_resp.return_code == SOMEIPReturnCode.E_OK, ( + f"SOMEIPSRV_RPC_17: TCP SET returned code 0x{set_resp.return_code.value:02x} " + f"({set_resp.return_code.name}), expected E_OK" + ) + + get_resp = send_get_field_tcp( + dut_ip, + SERVICE_ID, + METHOD_FIELD_UINT8_GET, + DUT_RELIABLE_PORT, + client_id=0x0040, + session_id=0x0012, + ) + received_payload = bytes(get_resp.payload) if get_resp.payload else b"" + assert received_payload == new_value, ( + f"SOMEIPSRV_RPC_17: TCP GET after SET payload mismatch: got {received_payload!r}, expected {new_value!r}" + ) diff --git a/tests/tc8_conformance/test_multi_service.py b/tests/tc8_conformance/test_multi_service.py new file mode 100644 index 000000000..46c566852 --- /dev/null +++ b/tests/tc8_conformance/test_multi_service.py @@ -0,0 +1,256 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 Multi-service and multi-instance config tests. + +SOMEIPSRV_RPC_13: Multi-service config validity: + Verify that the DUT's configuration supports multiple service entries and + that the DUT correctly offers its configured service with a stable SD stream. + The ``tc8_someipd_multi.json`` config declares two services; the DUT validates + both at startup. The DUT (``--tc8-standalone``) offers service 0x1234/0x5678. + +SOMEIPSRV_RPC_14: Instance port isolation: + Verify that each service instance in the config is assigned a distinct UDP + port and that the offered service's SD endpoint option matches its configured + port, confirming port routing correctness at the SD layer. + +See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture. +""" + +import socket +import subprocess +import time +from typing import Dict, Optional, Set + +import pytest + +from attribute_plugin import add_test_properties + +from helpers.constants import DUT_UNRELIABLE_PORT, SD_PORT +from helpers.sd_helpers import open_multicast_socket, parse_sd_offers +from someip.header import IPv4EndpointOption, L4Protocols, SOMEIPSDEntry + +#: Use the multi-service DUT config with two service entries. +#: Only service A is offered by --tc8-standalone; the config itself must load +#: successfully with both service definitions present (tests RPC_13 config path). +SOMEIP_CONFIG: str = "tc8_someipd_multi.json" + +#: Service A: the service offered by --tc8-standalone mode. +_SERVICE_A_ID: int = 0x1234 +_SERVICE_A_INSTANCE: int = 0x5678 + +#: Service B: declared in config but not offered by current standalone mode. +_SERVICE_B_ID: int = 0x5678 +_SERVICE_B_INSTANCE: int = 0x0001 + +_DUT_READY_TIMEOUT_SECS: float = 10.0 + +_SD_OFFER_COLLECTION_WINDOW_SECS: float = 6.0 + + +def _collect_offers_for_service( + host_ip: str, + service_id: int, + timeout_secs: float = _DUT_READY_TIMEOUT_SECS, +) -> list[SOMEIPSDEntry]: + """Collect all OfferService entries for *service_id* within *timeout_secs*. + + Returns as soon as the first entry for the target service is found, or an + empty list if the timeout elapses. Skips the calling test if the multicast + socket cannot be opened. + """ + try: + sock = open_multicast_socket(host_ip, port=SD_PORT) + except OSError as exc: + pytest.skip( + f"Multicast socket setup failed on {host_ip}: {exc}. " + "Set TC8_HOST_IP to a non-loopback interface IP or add a multicast " + "route: sudo ip route add 224.0.0.0/4 dev lo" + ) + + results = [] + deadline = time.monotonic() + timeout_secs + try: + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 1.0)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + for entry in parse_sd_offers(data): + if entry.service_id == service_id: + results.append(entry) + return results + finally: + sock.close() + + return results + + +def _collect_all_offers( + host_ip: str, + window_secs: float = _SD_OFFER_COLLECTION_WINDOW_SECS, +) -> Dict[int, SOMEIPSDEntry]: + """Collect all OfferService entries within *window_secs*. + + Returns a dict mapping service_id to the most-recently-seen SOMEIPSDEntry. + Skips the calling test if the multicast socket cannot be opened. + """ + try: + sock = open_multicast_socket(host_ip, port=SD_PORT) + except OSError as exc: + pytest.skip( + f"Multicast socket setup failed on {host_ip}: {exc}. " + "Set TC8_HOST_IP to a non-loopback interface IP or add a multicast " + "route: sudo ip route add 224.0.0.0/4 dev lo" + ) + + seen: Dict[int, SOMEIPSDEntry] = {} + deadline = time.monotonic() + window_secs + try: + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 1.0)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + for entry in parse_sd_offers(data): + seen[entry.service_id] = entry + finally: + sock.close() + return seen + + +def _get_udp_endpoint_port(entry: SOMEIPSDEntry) -> Optional[int]: + """Return the UDP endpoint port in an SD OfferService entry, or None.""" + options = list(getattr(entry, "options_1", ())) + list(getattr(entry, "options_2", ())) + for opt in options: + if isinstance(opt, IPv4EndpointOption) and opt.l4proto == L4Protocols.UDP: + return int(opt.port) + return None + + +class TestMultiServiceInstanceRouting: + """SOMEIPSRV_RPC_13/14: Multi-service config and instance port routing.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__multi_service"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_13_multi_service_config_loads_and_primary_service_offered( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """RPC_13: DUT loads a multi-service config and offers service A. + + When tc8_someipd_multi.json declares two services, the DUT must parse + both service entries at startup without error. The DUT then offers the + primary service (0x1234/0x5678) via SD; this confirms the multi-service + config was accepted by the DUT. + """ + assert dut.poll() is None, "RPC_13: DUT crashed — multi-service config may have caused an initialisation error" + + entries = _collect_offers_for_service(host_ip, _SERVICE_A_ID) + assert entries, ( + f"RPC_13: Service A (0x{_SERVICE_A_ID:04X}/0x{_SERVICE_A_INSTANCE:04X}) " + f"not offered within {_DUT_READY_TIMEOUT_SECS}s. " + "DUT may have rejected the multi-service config." + ) + + offered = entries[0] + assert offered.service_id == _SERVICE_A_ID, f"RPC_13: Unexpected service_id: 0x{offered.service_id:04X}" + assert offered.instance_id == _SERVICE_A_INSTANCE, ( + f"RPC_13: Unexpected instance_id: 0x{offered.instance_id:04X}" + ) + assert offered.ttl > 0, f"RPC_13: OfferService TTL is 0 (StopOffer) — service not active" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__multi_service"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_14_service_a_advertises_configured_udp_port( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """RPC_14: Service A's OfferService endpoint option matches configured port. + + Per SOMEIPSRV_RPC_14, each service instance must be reachable on its + configured UDP port. The SD OfferService IPv4EndpointOption must carry + the DUT's configured unreliable port (DUT_UNRELIABLE_PORT / + TC8_SVC_PORT = 30512 for the tc8_multi_service Bazel target). + + This verifies that the DUT correctly maps the multi-service config entry + to a UDP server endpoint on the right port. + """ + assert dut.poll() is None, "DUT is not running" + + entries = _collect_offers_for_service(host_ip, _SERVICE_A_ID) + assert entries, ( + f"RPC_14: No OfferService for service A (0x{_SERVICE_A_ID:04X}) received within {_DUT_READY_TIMEOUT_SECS}s." + ) + + entry = entries[0] + port = _get_udp_endpoint_port(entry) + assert port is not None, ( + "RPC_14: Service A OfferService carries no IPv4 UDP endpoint option. " + "DUT may not have bound the unreliable port from the multi-service config." + ) + assert port == DUT_UNRELIABLE_PORT, ( + f"RPC_14: Service A UDP endpoint port mismatch: got {port}, " + f"expected {DUT_UNRELIABLE_PORT} (TC8_SVC_PORT from tc8_someipd_multi.json)." + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__multi_service"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_14_no_unexpected_service_ids_in_offers( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """RPC_14: Only configured services appear in SD OfferService messages. + + Collect all SD OfferService entries across a time window and verify + that any offered service ID is one of the two configured IDs (0x1234 + or 0x5678). No phantom service IDs should appear. + + This verifies that the multi-service config does not cause the DUT to + offer unexpected services or corrupt the SD entry list. + """ + assert dut.poll() is None, "DUT is not running" + + seen = _collect_all_offers(host_ip) + + assert _SERVICE_A_ID in seen, ( + f"RPC_14: No OfferService for service A (0x{_SERVICE_A_ID:04X}) " + f"observed in {_SD_OFFER_COLLECTION_WINDOW_SECS}s window." + ) + + allowed_ids: Set[int] = {_SERVICE_A_ID, _SERVICE_B_ID} + unexpected = set(seen.keys()) - allowed_ids + assert not unexpected, ( + f"RPC_14: Unexpected service IDs in SD offers: " + f"{[hex(s) for s in unexpected]}. " + f"Only {[hex(s) for s in allowed_ids]} are configured." + ) diff --git a/tests/tc8_conformance/test_sd_client.py b/tests/tc8_conformance/test_sd_client.py new file mode 100644 index 000000000..9f6b36f2b --- /dev/null +++ b/tests/tc8_conformance/test_sd_client.py @@ -0,0 +1,397 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 SD Client lifecycle tests: ETS_081/082/084 and skipped ETS_096/097. + +This module manages its own someipd lifecycle (launch/terminate per test) +and must NOT share the module-scoped ``dut`` fixture from conftest.py. +Running a private DUT avoids routing-manager conflicts with other +TC8 targets that bind the same SD port. + +Port assignment (from BUILD.bazel env): + TC8_SD_PORT = 30498 (SD traffic) + TC8_SVC_PORT = 30511 (service UDP traffic) + +See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture. +""" + +import socket +import time +from pathlib import Path +from typing import List, Tuple + +import pytest + +from attribute_plugin import add_test_properties + +from helpers.dut_lifecycle import ( + cleanup_vsomeip_sockets, + launch_dut, + render_someip_config, + terminate_dut, + wait_for_sd_readiness, +) +from helpers.constants import ( + EVENTGROUP_UDP_UNICAST, + INSTANCE_ID, + MAJOR_VERSION, + SD_PORT, + SERVICE_ID, +) +from helpers.sd_helpers import open_multicast_socket +from helpers.sd_sender import ( + SOMEIPSDEntryType, + capture_some_ip_messages, + capture_unicast_sd_entries, + open_sender_socket, + send_subscribe_eventgroup, +) +from someip.header import SOMEIPHeader, SOMEIPSDHeader + +#: Uses the SD config (service 0x1234/0x5678, eventgroup 0x4455 UDP). +SOMEIP_CONFIG: str = "tc8_someipd_sd.json" + +#: Maximum wait time for SD messages, derived from TC8 spec Sec. 4 IUT parameters: +#: Listen Time (10 s) + Tolerance Time (1 s) + Process Time (2 s) = 13 s, +#: plus 2 s buffer for container/CI overhead. +_SD_CAPTURE_TIMEOUT_SECS: float = 15.0 + + +def _collect_sd_messages( + capture_sock: socket.socket, + count: int, + timeout_secs: float, +) -> List[Tuple[SOMEIPHeader, SOMEIPSDHeader]]: + """Receive up to *count* SOME/IP-SD messages within *timeout_secs*.""" + collected: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = [] + deadline = time.monotonic() + timeout_secs + while time.monotonic() < deadline and len(collected) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + capture_sock.settimeout(min(remaining, 1.0)) + try: + data, _ = capture_sock.recvfrom(65535) + except socket.timeout: + continue + try: + outer, _ = SOMEIPHeader.parse(data) + if outer.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload) + collected.append((outer, sd_hdr)) + except Exception: # noqa: BLE001 + continue + return collected + + +def _wait_port_free(port: int, retries: int = 20, delay: float = 0.1) -> None: + """Spin until *port* can be bound (i.e. previous someipd has released it).""" + for _ in range(retries): + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + probe.bind(("", port)) + return + except OSError: + time.sleep(delay) + + +@pytest.fixture(scope="module") +def sd_client_config( + tmp_path_factory: pytest.TempPathFactory, + host_ip: str, + request: pytest.FixtureRequest, +) -> Path: + """Render the DUT config template and return the path. + + Triggers ``tc8_itf_config_setup`` (session-scoped) to render vsomeip JSON + templates on the QEMU guest before any ``launch_dut`` call. Without + this the guest ``/tc8_sd.json`` is absent or has literal ``__TC8_*__`` + placeholders, causing vsomeip to bind loopback and SD multicast to never + egress the TAP interface. + + ``launch_dut`` uses the path's filename to select the correct + pre-rendered guest config; the host-side file is harmless. + """ + request.getfixturevalue("tc8_itf_config_setup") + tmp_dir = tmp_path_factory.mktemp("tc8_sd_client_config") + return render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir) + + +@add_test_properties( + partially_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_ets_096_tcp_connection_before_subscribe() -> None: + """ETS_096: TCP connection established before SubscribeEventgroup for TCP eventgroup.""" + pytest.skip( + "ETS_096 requires a TCP eventgroup config (tc8_someipd_service.json) and a " + "dedicated TC8_SVC_TCP_PORT. The tc8_sd_client target allocates only " + "TC8_SD_PORT=30498 and TC8_SVC_PORT=30511 (no TCP port). Implement once " + "a dedicated tc8_sd_client_tcp target with TC8_SVC_TCP_PORT is added." + ) + + +@add_test_properties( + partially_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_ets_097_tcp_reconnect() -> None: + """ETS_097: TCP reconnection after disconnect yields a new SubscribeAck.""" + pytest.skip( + "ETS_097 requires a TCP eventgroup config (tc8_someipd_service.json) and a " + "dedicated TC8_SVC_TCP_PORT. The tc8_sd_client target allocates only " + "TC8_SD_PORT=30498 and TC8_SVC_PORT=30511 (no TCP port). Implement once " + "a dedicated tc8_sd_client_tcp target with TC8_SVC_TCP_PORT is added." + ) + + +class TestSDClientStopSubscribe: + """ETS_084: Client-initiated StopSubscribeEventgroup stops event delivery.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_084_stop_subscribe_ceases_events( + self, + sd_client_config: Path, + host_ip: str, + dut_ip: str, + tester_ip: str, + request: pytest.FixtureRequest, + ) -> None: + """ETS_084: After StopSubscribeEventgroup (TTL=0) the DUT stops sending events. + + This test has its own DUT lifecycle so that the StopSubscribe is verified + on a fresh subscription with a known notification history. + """ + target_init = request.getfixturevalue("target_init") + + proc = launch_dut(sd_client_config, target_init=target_init) + try: + # Wait for DUT to reach SD main phase (first OfferService multicast). + if not wait_for_sd_readiness(host_ip, timeout_secs=15.0): + terminate_dut(proc) + pytest.skip("someipd DUT did not reach SD main phase within timeout") + + sd_sock = open_sender_socket(tester_ip) + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port: int = notif_sock.getsockname()[1] + + try: + + def _subscribe() -> None: + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ) + + _subscribe() + acks = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_subscribe, + ) + assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), ( + "ETS_084: Prerequisite failed — no SubscribeAck received" + ) + + pre_notifs = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0) + assert pre_notifs, "ETS_084: No notifications received after subscribe (prerequisite)" + + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ttl=0, + ) + + post_notifs = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0) + assert not post_notifs, ( + f"ETS_084: {len(post_notifs)} notification(s) received after " + "StopSubscribeEventgroup (TTL=0). DUT must cease sending events." + ) + finally: + sd_sock.close() + notif_sock.close() + finally: + terminate_dut(proc) + + +class TestSDClientReboot: + """ETS_081/082: DUT reboot flag and session reset across restarts.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_reboot"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_081_reboot_flag_set_after_first_restart( + self, + sd_client_config: Path, + host_ip: str, + request: pytest.FixtureRequest, + ) -> None: + """ETS_081: After restart the first SD message has the reboot flag (bit 7) set.""" + target_init = request.getfixturevalue("target_init") + + try: + pre_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip(f"Multicast socket unavailable on {host_ip}") + + try: + proc1 = launch_dut(sd_client_config, target_init=target_init) + except Exception: + pre_sock.close() + raise + + try: + _collect_sd_messages(pre_sock, count=3, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS) + finally: + pre_sock.close() + terminate_dut(proc1) + + cleanup_vsomeip_sockets(target_init=target_init) + + try: + post_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip("Multicast socket unavailable for post-reboot capture") + + try: + proc2 = launch_dut(sd_client_config, target_init=target_init) + except Exception: + post_sock.close() + raise + + try: + post_messages = _collect_sd_messages(post_sock, count=2, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS) + finally: + post_sock.close() + terminate_dut(proc2) + + assert post_messages, "ETS_081: No SD messages captured after restart" + + outer, sd_hdr = post_messages[0] + + reboot_flag = getattr(sd_hdr, "flag_reboot", None) + if reboot_flag is None: + raw_flags = getattr(sd_hdr, "flags", 0) + reboot_flag = bool(raw_flags & 0x80) + + assert reboot_flag, ( + "ETS_081: Reboot flag not set in first SD message after restart. " + "The DUT must reset the reboot flag when restarted (PRS_SOMEIPSD_00385)." + ) + assert outer.session_id <= 2, ( + f"ETS_081: session_id after restart = {outer.session_id}; " + "expected ≤ 2 (session counter must reset on reboot)" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_reboot"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_082_reboot_flag_set_after_second_restart( + self, + sd_client_config: Path, + host_ip: str, + request: pytest.FixtureRequest, + ) -> None: + """ETS_082: Reboot flag and session reset hold across a second consecutive restart. + + This verifies that the DUT correctly resets SD state on every cold start, + not just the first one. + """ + target_init = request.getfixturevalue("target_init") + + def _drain_and_stop(pre_sock: socket.socket) -> None: + """Launch DUT, drain 3 messages, terminate, clean up sockets.""" + try: + proc = launch_dut(sd_client_config, target_init=target_init) + except Exception: + pre_sock.close() + raise + try: + _collect_sd_messages(pre_sock, count=3, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS) + finally: + pre_sock.close() + terminate_dut(proc) + cleanup_vsomeip_sockets(target_init=target_init) + + try: + sock1 = open_multicast_socket(host_ip) + except OSError: + pytest.skip(f"Multicast socket unavailable on {host_ip}") + _drain_and_stop(sock1) + + try: + sock2 = open_multicast_socket(host_ip) + except OSError: + pytest.skip("Multicast socket unavailable for second run") + _drain_and_stop(sock2) + + try: + post_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip("Multicast socket unavailable for post-reboot capture") + + try: + proc3 = launch_dut(sd_client_config, target_init=target_init) + except Exception: + post_sock.close() + raise + + try: + post_messages = _collect_sd_messages(post_sock, count=2, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS) + finally: + post_sock.close() + terminate_dut(proc3) + + assert post_messages, "ETS_082: No SD messages captured after second restart" + + outer, sd_hdr = post_messages[0] + + reboot_flag = getattr(sd_hdr, "flag_reboot", None) + if reboot_flag is None: + raw_flags = getattr(sd_hdr, "flags", 0) + reboot_flag = bool(raw_flags & 0x80) + + assert reboot_flag, ( + "ETS_082: Reboot flag not set after second consecutive restart. " + "The DUT must reset the reboot flag on every cold start (PRS_SOMEIPSD_00385)." + ) + assert outer.session_id <= 2, ( + f"ETS_082: session_id after second restart = {outer.session_id}; " + "expected ≤ 2 (session counter must reset on every reboot)" + ) diff --git a/tests/tc8_conformance/test_sd_format_compliance.py b/tests/tc8_conformance/test_sd_format_compliance.py new file mode 100644 index 000000000..c5c74ef62 --- /dev/null +++ b/tests/tc8_conformance/test_sd_format_compliance.py @@ -0,0 +1,1450 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 SD Format & Options Field Assertions: FORMAT_01 through OPTIONS_14. + +Verifies byte-level field values in SD OfferService messages and +SubscribeEventgroup Ack responses from someipd. No DUT behaviour is +triggered beyond normal SD operation; tests only observe what the DUT +already sends. + +Test classes: + TestSdHeaderFieldsOfferService -- FORMAT_01/02/04/05/06/09/10 + TestSdOfferEntryFields -- FORMAT_11/12/13/15/16/18 + TestSdHeaderFieldsSubscribeAck -- FORMAT_19/20/21/23/24/25/26/27/28 + TestSdOptionsEndpoint -- OPTIONS_01/02/03/05/06 + TestSdOptionsMulticast -- OPTIONS_08/09/10/11/12/13/14 +""" + +import ipaddress +import socket +import subprocess +import time +from typing import Optional, Tuple + +import pytest + +from attribute_plugin import add_test_properties + +from helpers.constants import ( + DUT_UNRELIABLE_PORT, + EVENTGROUP_UDP_MULTICAST, + EVENTGROUP_UDP_UNICAST, + INSTANCE_ID, + MAJOR_VERSION, + MINOR_VERSION, + MULTICAST_ADDR, + MULTICAST_EVENT_PORT, + SD_PORT, + SERVICE_ID, +) +from helpers.sd_helpers import open_multicast_socket +from helpers.sd_sender import ( + capture_unicast_sd_entries, + open_sender_socket, + send_subscribe_eventgroup, +) +from someip.header import ( + IPv4EndpointOption, + IPv4MulticastOption, + L4Protocols, + SD_INTERFACE_VERSION, + SD_METHOD, + SD_SERVICE, + SOMEIPHeader, + SOMEIPMessageType, + SOMEIPReturnCode, + SOMEIPSDEntry, + SOMEIPSDEntryType, + SOMEIPSDHeader, +) + +SOMEIP_CONFIG: str = "tc8_someipd_sd.json" + + +def _capture_raw_sd_offer( + host_ip: str, + timeout_secs: float = 5.0, +) -> Tuple[SOMEIPHeader, SOMEIPSDHeader, SOMEIPSDEntry, SOMEIPSDEntry]: + """Capture the first raw OfferService for ``SERVICE_ID`` on the multicast group. + + Returns a 4-tuple: + - raw SOME/IP header + - resolved SD header (options populated in entries) + - resolved offer entry (``options_1`` / ``options_2`` populated) + - unresolved offer entry (``num_options_1`` / ``option_index_1`` populated) + + Callers that only need the resolved entry may use ``result[2]``; callers + that need raw option-count fields should use ``result[3]``. + + Raises ``TimeoutError`` when nothing arrives within *timeout_secs*. + """ + sock = open_multicast_socket(host_ip) + try: + deadline = time.monotonic() + timeout_secs + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 1.0)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + try: + someip_msg, _ = SOMEIPHeader.parse(data) + if someip_msg.service_id != SD_SERVICE: + continue + sd_hdr_raw, _ = SOMEIPSDHeader.parse(someip_msg.payload) + sd_hdr_resolved = sd_hdr_raw.resolve_options() + for raw_entry, resolved_entry in zip(sd_hdr_raw.entries, sd_hdr_resolved.entries): + if ( + resolved_entry.sd_type == SOMEIPSDEntryType.OfferService + and resolved_entry.service_id == SERVICE_ID + ): + return someip_msg, sd_hdr_resolved, resolved_entry, raw_entry + except Exception: # noqa: BLE001 + continue + finally: + sock.close() + + raise TimeoutError( + f"No OfferService for service 0x{SERVICE_ID:04x} received within {timeout_secs:.1f}s on {host_ip}:{SD_PORT}" + ) + + +def _capture_subscribe_ack( + dut_ip: str, + tester_ip: str, + eventgroup_id: int, + timeout_secs: float = 5.0, +) -> SOMEIPSDEntry: + """Subscribe to *eventgroup_id* and capture the SubscribeEventgroupAck entry. + + Raises ``AssertionError`` when no matching ack arrives. + """ + sock = open_sender_socket(tester_ip) + try: + sender_port: int = sock.getsockname()[1] + + def _send_sub() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + eventgroup_id, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send_sub() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=timeout_secs, + resend=_send_sub, + max_results=1, + ) + acks = [e for e in entries if e.eventgroup_id == eventgroup_id and e.ttl > 0] + assert acks, ( + f"No SubscribeEventgroupAck received for eventgroup 0x{eventgroup_id:04x} within {timeout_secs:.1f}s" + ) + return acks[0] + finally: + sock.close() + + +def _capture_subscribe_ack_with_options( + dut_ip: str, + tester_ip: str, + eventgroup_id: int, + timeout_secs: float = 5.0, +) -> Tuple[SOMEIPSDEntry, SOMEIPSDEntry]: + """Subscribe to *eventgroup_id* and return (unresolved_ack, resolved_ack). + + The unresolved entry comes from ``capture_unicast_sd_entries``; + the resolved entry is from a second pass that calls ``resolve_options()``. + """ + sock = open_sender_socket(tester_ip) + try: + sender_port: int = sock.getsockname()[1] + + def _send_sub() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + eventgroup_id, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send_sub() + deadline = time.monotonic() + timeout_secs + next_resend = time.monotonic() + 1.5 + + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + if time.monotonic() >= next_resend: + _send_sub() + next_resend = time.monotonic() + 1.5 + sock.settimeout(min(remaining, 0.5)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + try: + someip_msg, _ = SOMEIPHeader.parse(data) + if someip_msg.service_id != SD_SERVICE: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload) + sd_hdr_resolved = sd_hdr.resolve_options() + for unresolved_entry, resolved_entry in zip(sd_hdr.entries, sd_hdr_resolved.entries): + if ( + unresolved_entry.sd_type == SOMEIPSDEntryType.SubscribeAck + and unresolved_entry.eventgroup_id == eventgroup_id + and unresolved_entry.ttl > 0 + ): + return unresolved_entry, resolved_entry + except Exception: # noqa: BLE001 + continue + finally: + sock.close() + + raise AssertionError( + f"No SubscribeEventgroupAck with options received for eventgroup " + f"0x{eventgroup_id:04x} within {timeout_secs:.1f}s" + ) + + +class TestSdHeaderFieldsOfferService: + """FORMAT_01/02/04/05/06/09/10: SOME/IP header fields of an OfferService SD message.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_01_client_id_is_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_01: SOME/IP SD header client_id must be 0x0000.""" + assert dut.poll() is None, "DUT is not running" + + someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip) + + assert someip_msg.client_id == 0x0000, f"FORMAT_01: client_id must be 0x0000; got 0x{someip_msg.client_id:04x}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_02_session_id_is_nonzero_and_in_range( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_02: SD session_id must be non-zero and fit in 16 bits. + + Per PRS_SOMEIPSD_00154 the session_id starts at 0x0001 and + increments with each SD message; the value 0x0000 is reserved. Because + the module-scoped DUT may have already sent earlier SD messages (during + preceding test classes), the captured value may be greater than 1, but + it must never be 0 and must not exceed 0xFFFF. + """ + assert dut.poll() is None, "DUT is not running" + + someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip, timeout_secs=5.0) + + assert someip_msg.session_id != 0x0000, "FORMAT_02: session_id must never be 0x0000 (reserved)" + assert someip_msg.session_id <= 0xFFFF, ( + f"FORMAT_02: session_id must fit in 16 bits; got 0x{someip_msg.session_id:08x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_04_interface_version_is_one( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_04: SOME/IP SD interface_version must be 0x01.""" + assert dut.poll() is None, "DUT is not running" + + someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip) + + assert someip_msg.interface_version == SD_INTERFACE_VERSION, ( + f"FORMAT_04: interface_version must be {SD_INTERFACE_VERSION}; got {someip_msg.interface_version}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_05_message_type_is_notification( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_05: SOME/IP SD message_type must be NOTIFICATION (0x02).""" + assert dut.poll() is None, "DUT is not running" + + someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip) + + assert someip_msg.message_type == SOMEIPMessageType.NOTIFICATION, ( + f"FORMAT_05: message_type must be NOTIFICATION " + f"(0x{SOMEIPMessageType.NOTIFICATION:02x}); " + f"got 0x{someip_msg.message_type:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_06_return_code_is_e_ok( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_06: SOME/IP SD return_code must be E_OK (0x00).""" + assert dut.poll() is None, "DUT is not running" + + someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip) + + assert someip_msg.return_code == SOMEIPReturnCode.E_OK, ( + f"FORMAT_06: return_code must be E_OK (0x{SOMEIPReturnCode.E_OK:02x}); got 0x{someip_msg.return_code:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_09_sd_flags_reserved_bits_are_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_09: Reserved bits (5-0) of the SD Flags byte must be 0.""" + assert dut.poll() is None, "DUT is not running" + + _, sd_hdr, _, _ = _capture_raw_sd_offer(host_ip) + + # flags_unknown captures bits 5-0 (the reserved/undefined bits). + assert sd_hdr.flags_unknown == 0, ( + f"FORMAT_09: SD Flags reserved bits (5-0) must be 0; flags_unknown=0x{sd_hdr.flags_unknown:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_10_sd_entry_reserved_bytes_are_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_10: Reserved bytes in the 16-byte OfferService SD entry must be 0. + + SOME/IP-SD entry layout (16 bytes): + [0] type + [1] index_first_option_run (0 when no options before type-1 entries) + [2] index_second_option_run (reserved in OfferService, must be 0) + [3] high nibble = num_options_run1, low nibble = num_options_run2 + [4-5] service_id + [6-7] instance_id + [8] major_version + [9-11] TTL (3 bytes) + [12-15] minor_version (4 bytes) + + Byte [2] (index_second_option_run) is reserved for Type-1 OfferService + entries and must be transmitted as 0. + """ + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + # Assign option index so the entry can be built. + assigned = entry.assign_option_index([]) + raw = assigned.build() + + reserved_byte = raw[2] + assert reserved_byte == 0, ( + f"FORMAT_10: Reserved byte [2] in OfferService SD entry must be 0; got 0x{reserved_byte:02x}" + ) + + +class TestSdOfferEntryFields: + """FORMAT_11/12/13/15/16/18: OfferService SD entry field assertions.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_11_entry_is_16_bytes( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_11: Each SD entry must be exactly 16 bytes.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + assigned = entry.assign_option_index([]) + entry_bytes = assigned.build() + + assert len(entry_bytes) == 16, f"FORMAT_11: SD entry must be 16 bytes; got {len(entry_bytes)}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_12_first_option_run_index_is_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_12: OfferService first option run index must be 0.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + assigned = entry.assign_option_index([]) + raw = assigned.build() + + option_index_1 = raw[1] + assert option_index_1 == 0, f"FORMAT_12: option_index_1 (byte [1]) must be 0; got 0x{option_index_1:02x}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_13_num_options_matches_options_list( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_13: num_options_1 field must equal the number of resolved options.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, raw_entry = _capture_raw_sd_offer(host_ip) + + # After resolve_options() the ``num_options_1`` field is None (the library + # replaces the counter with the actual option objects). Use the raw entry's + # counter (preserved before resolve) against the resolved entry's options list. + assert raw_entry.num_options_1 == len(entry.options_1), ( + f"FORMAT_13: num_options_1 ({raw_entry.num_options_1}) must equal len(options_1) ({len(entry.options_1)})" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_15_instance_id_matches_config( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_15: OfferService instance_id must match the configured value.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + assert entry.instance_id == INSTANCE_ID, ( + f"FORMAT_15: instance_id must be 0x{INSTANCE_ID:04x}; got 0x{entry.instance_id:04x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_16_major_version_matches_config( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_16: OfferService major_version must match the configured value.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + assert entry.major_version == MAJOR_VERSION, ( + f"FORMAT_16: major_version must be 0x{MAJOR_VERSION:02x}; got 0x{entry.major_version:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_18_minor_version_matches_config( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_18: OfferService minor_version must match the configured value.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + assert entry.service_minor_version == MINOR_VERSION, ( + f"FORMAT_18: minor_version must be 0x{MINOR_VERSION:08x}; got 0x{entry.service_minor_version:08x}" + ) + + +class TestSdHeaderFieldsSubscribeAck: + """FORMAT_19/20/21/23/24/25/26/27/28: SubscribeEventgroupAck entry field assertions.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_19_ack_entry_type_is_subscribe_ack( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_19: SubscribeEventgroupAck SD entry type must be 0x07.""" + assert dut.poll() is None, "DUT is not running" + + ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + assert ack.sd_type == SOMEIPSDEntryType.SubscribeAck, ( + f"FORMAT_19: sd_type must be SubscribeAck (0x07); got {ack.sd_type!r}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_20_ack_entry_is_16_bytes( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_20: SubscribeEventgroupAck SD entry must be exactly 16 bytes.""" + assert dut.poll() is None, "DUT is not running" + + ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + assigned = ack.assign_option_index([]) + entry_bytes = assigned.build() + + assert len(entry_bytes) == 16, f"FORMAT_20: SubscribeAck SD entry must be 16 bytes; got {len(entry_bytes)}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_21_ack_option_run_index_is_zero_when_no_options( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_21: SubscribeAck option_index_1 matches the attached options.""" + assert dut.poll() is None, "DUT is not running" + + ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + assigned = ack.assign_option_index([]) + raw = assigned.build() + option_index_1 = raw[1] + expected_option_count = ack.num_options_1 + + if expected_option_count == 0: + assert option_index_1 == 0, ( + f"FORMAT_21: option_index_1 must be 0 when no options; got 0x{option_index_1:02x}" + ) + else: + # Non-zero index is valid only when the SD packet carries options. + assert option_index_1 < 16, ( + f"FORMAT_21: option_index_1 must be a valid index (< 16); got 0x{option_index_1:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_23_ack_service_id_matches_config( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_23: SubscribeAck service_id must match the subscribed service.""" + assert dut.poll() is None, "DUT is not running" + + ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + assert ack.service_id == SERVICE_ID, ( + f"FORMAT_23: service_id must be 0x{SERVICE_ID:04x}; got 0x{ack.service_id:04x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_24_ack_instance_id_matches_config( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_24: SubscribeAck instance_id must match the subscribed instance.""" + assert dut.poll() is None, "DUT is not running" + + ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + assert ack.instance_id == INSTANCE_ID, ( + f"FORMAT_24: instance_id must be 0x{INSTANCE_ID:04x}; got 0x{ack.instance_id:04x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_25_ack_major_version_matches_config( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_25: SubscribeAck major_version must match the service definition.""" + assert dut.poll() is None, "DUT is not running" + + ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + assert ack.major_version == MAJOR_VERSION, ( + f"FORMAT_25: major_version must be 0x{MAJOR_VERSION:02x}; got 0x{ack.major_version:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_26_ack_ttl_is_nonzero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_26: SubscribeEventgroupAck TTL must be > 0 (TTL=0 means Nack).""" + assert dut.poll() is None, "DUT is not running" + + ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + assert ack.ttl > 0, f"FORMAT_26: SubscribeAck TTL must be > 0; got {ack.ttl}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_27_ack_reserved_field_is_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_27: Reserved bits (high 12 bits of last 4 bytes) of SubscribeAck must be 0. + + In the 16-byte SD entry for SubscribeEventgroupAck: + bytes [12-13]: high 4 bits = reserved counter (must be 0) + bytes [14-15]: eventgroup_id + + The ``minver_or_counter`` field encodes ``(reserved << 16) | eventgroup_id``. + The high 16 bits must be 0. + """ + assert dut.poll() is None, "DUT is not running" + + ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + reserved_high = (ack.minver_or_counter >> 16) & 0xFFFF + assert reserved_high == 0, ( + f"FORMAT_27: High 16 bits of SubscribeAck minver_or_counter must be 0; " + f"got 0x{reserved_high:04x} (minver_or_counter=0x{ack.minver_or_counter:08x})" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_28_ack_eventgroup_id_matches_subscribe( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_28: SubscribeAck eventgroup_id must match the subscribed eventgroup.""" + assert dut.poll() is None, "DUT is not running" + + ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + assert (ack.minver_or_counter & 0xFFFF) == EVENTGROUP_UDP_UNICAST, ( + f"FORMAT_28: eventgroup_id in SubscribeAck must be " + f"0x{EVENTGROUP_UDP_UNICAST:04x}; " + f"got 0x{ack.minver_or_counter & 0xFFFF:04x}" + ) + + +class TestSdOptionsEndpoint: + """OPTIONS_01/02/03/05/06: IPv4EndpointOption field assertions from OfferService.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_01_endpoint_option_length_is_nine( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """OPTIONS_01: IPv4EndpointOption length field must be 0x0009.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)] + assert ipv4_opts, "OPTIONS_01: No IPv4EndpointOption found in OfferService entry" + opt = ipv4_opts[0] + raw = opt.build() + + # Wire format: [0-1] length field (big-endian). + length_field = int.from_bytes(raw[0:2], "big") + assert length_field == 0x0009, ( + f"OPTIONS_01: IPv4EndpointOption length field must be 0x0009; got 0x{length_field:04x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_02_endpoint_option_type_is_0x04( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """OPTIONS_02: IPv4EndpointOption type byte must be 0x04.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)] + assert ipv4_opts, "OPTIONS_02: No IPv4EndpointOption found in OfferService entry" + opt = ipv4_opts[0] + raw = opt.build() + + # Wire format: [2] type byte. + type_byte = raw[2] + assert type_byte == 0x04, f"OPTIONS_02: IPv4EndpointOption type byte must be 0x04; got 0x{type_byte:02x}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_03_endpoint_option_reserved_after_type_is_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """OPTIONS_03: Reserved byte at offset [3] of IPv4EndpointOption must be 0x00.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)] + assert ipv4_opts, "OPTIONS_03: No IPv4EndpointOption found in OfferService entry" + opt = ipv4_opts[0] + raw = opt.build() + + # Wire format: [3] reserved byte after type. + reserved_byte = raw[3] + assert reserved_byte == 0x00, ( + f"OPTIONS_03: IPv4EndpointOption reserved byte [3] must be 0x00; got 0x{reserved_byte:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_05_endpoint_option_reserved_before_protocol_is_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """OPTIONS_05: Reserved byte at offset [8] of IPv4EndpointOption must be 0x00.""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)] + assert ipv4_opts, "OPTIONS_05: No IPv4EndpointOption found in OfferService entry" + opt = ipv4_opts[0] + raw = opt.build() + + # Wire format: [8] reserved byte before protocol byte. + reserved_byte = raw[8] + assert reserved_byte == 0x00, ( + f"OPTIONS_05: IPv4EndpointOption reserved byte [8] must be 0x00; got 0x{reserved_byte:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_06_endpoint_option_protocol_is_udp( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """OPTIONS_06: IPv4EndpointOption L4 protocol must be UDP (0x11).""" + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)] + assert ipv4_opts, "OPTIONS_06: No IPv4EndpointOption found in OfferService entry" + opt = ipv4_opts[0] + + assert opt.l4proto == L4Protocols.UDP, ( + f"OPTIONS_06: IPv4EndpointOption l4proto must be UDP (0x{L4Protocols.UDP:02x}); got {opt.l4proto!r}" + ) + + +class TestSdOptionsMulticast: + """OPTIONS_08/09/10/11/12/13/14: IPv4MulticastOption field assertions from SubscribeAck.""" + + @pytest.mark.network + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_08_multicast_option_length_is_nine( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """OPTIONS_08: IPv4MulticastOption length field must be 0x0009.""" + assert dut.poll() is None, "DUT is not running" + + if ipaddress.ip_address(host_ip).is_loopback: + pytest.skip( + "OPTIONS_08: Multicast endpoint option in SubscribeAck requires a non-loopback interface. " + "Set TC8_HOST_IP to a non-loopback address." + ) + + _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST) + + all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2) + mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)] + assert mcast_opts, ( + f"OPTIONS_08: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + ) + opt = mcast_opts[0] + raw = opt.build() + + length_field = int.from_bytes(raw[0:2], "big") + assert length_field == 0x0009, ( + f"OPTIONS_08: IPv4MulticastOption length field must be 0x0009; got 0x{length_field:04x}" + ) + + @pytest.mark.network + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_09_multicast_option_type_is_0x14( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """OPTIONS_09: IPv4MulticastOption type byte must be 0x14 (decimal 20).""" + assert dut.poll() is None, "DUT is not running" + + if ipaddress.ip_address(host_ip).is_loopback: + pytest.skip( + "OPTIONS_09: Multicast endpoint option in SubscribeAck requires a non-loopback interface. " + "Set TC8_HOST_IP to a non-loopback address." + ) + + _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST) + + all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2) + mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)] + assert mcast_opts, ( + f"OPTIONS_09: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + ) + opt = mcast_opts[0] + raw = opt.build() + + type_byte = raw[2] + assert type_byte == 0x14, f"OPTIONS_09: IPv4MulticastOption type byte must be 0x14; got 0x{type_byte:02x}" + + @pytest.mark.network + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_10_multicast_option_reserved_is_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """OPTIONS_10: Reserved byte at offset [3] of IPv4MulticastOption must be 0x00.""" + assert dut.poll() is None, "DUT is not running" + + if ipaddress.ip_address(host_ip).is_loopback: + pytest.skip( + "OPTIONS_10: Multicast endpoint option in SubscribeAck requires a non-loopback interface. " + "Set TC8_HOST_IP to a non-loopback address." + ) + + _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST) + + all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2) + mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)] + assert mcast_opts, ( + f"OPTIONS_10: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + ) + opt = mcast_opts[0] + raw = opt.build() + + reserved_byte = raw[3] + assert reserved_byte == 0x00, ( + f"OPTIONS_10: IPv4MulticastOption reserved byte [3] must be 0x00; got 0x{reserved_byte:02x}" + ) + + @pytest.mark.network + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_11_multicast_address_matches_config( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """OPTIONS_11: IPv4MulticastOption address must match the configured multicast address.""" + assert dut.poll() is None, "DUT is not running" + + if ipaddress.ip_address(host_ip).is_loopback: + pytest.skip( + "OPTIONS_11: Multicast endpoint option in SubscribeAck requires a non-loopback interface. " + "Set TC8_HOST_IP to a non-loopback address." + ) + + _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST) + + all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2) + mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)] + assert mcast_opts, ( + f"OPTIONS_11: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + ) + opt = mcast_opts[0] + + expected_addr = ipaddress.IPv4Address(MULTICAST_ADDR) + assert opt.address == expected_addr, ( + f"OPTIONS_11: multicast address must be {MULTICAST_ADDR}; got {opt.address}" + ) + + @pytest.mark.network + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_12_multicast_option_reserved_before_port_is_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """OPTIONS_12: Reserved byte at offset [8] of IPv4MulticastOption must be 0x00.""" + assert dut.poll() is None, "DUT is not running" + + if ipaddress.ip_address(host_ip).is_loopback: + pytest.skip( + "OPTIONS_12: Multicast endpoint option in SubscribeAck requires a non-loopback interface. " + "Set TC8_HOST_IP to a non-loopback address." + ) + + _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST) + + all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2) + mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)] + assert mcast_opts, ( + f"OPTIONS_12: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + ) + opt = mcast_opts[0] + raw = opt.build() + + reserved_byte = raw[8] + assert reserved_byte == 0x00, ( + f"OPTIONS_12: IPv4MulticastOption reserved byte [8] must be 0x00; got 0x{reserved_byte:02x}" + ) + + @pytest.mark.network + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_13_multicast_option_protocol_is_udp( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """OPTIONS_13: IPv4MulticastOption L4 protocol must be UDP (0x11).""" + assert dut.poll() is None, "DUT is not running" + + if ipaddress.ip_address(host_ip).is_loopback: + pytest.skip( + "OPTIONS_13: Multicast endpoint option in SubscribeAck requires a non-loopback interface. " + "Set TC8_HOST_IP to a non-loopback address." + ) + + _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST) + + all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2) + mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)] + assert mcast_opts, ( + f"OPTIONS_13: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + ) + opt = mcast_opts[0] + + assert opt.l4proto == L4Protocols.UDP, ( + f"OPTIONS_13: IPv4MulticastOption l4proto must be UDP (0x{L4Protocols.UDP:02x}); got {opt.l4proto!r}" + ) + + @pytest.mark.network + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_options_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_options_14_multicast_port_matches_config( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """OPTIONS_14: IPv4MulticastOption port must match the configured multicast port.""" + assert dut.poll() is None, "DUT is not running" + + if ipaddress.ip_address(host_ip).is_loopback: + pytest.skip( + "OPTIONS_14: Multicast endpoint option in SubscribeAck requires a non-loopback interface. " + "Set TC8_HOST_IP to a non-loopback address." + ) + + _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST) + + all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2) + mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)] + assert mcast_opts, ( + f"OPTIONS_14: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + ) + opt = mcast_opts[0] + + assert opt.port == MULTICAST_EVENT_PORT, ( + f"OPTIONS_14: multicast port must be {MULTICAST_EVENT_PORT}; got {opt.port}" + ) + + +class TestSdMissingFormatFields: + """FORMAT_03/07/14/17/22: previously unverified SD format fields.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_03_protocol_version_is_one( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_03: SOME/IP protocol_version in the SD message header must be 1. + + The SOME/IP header byte 12 (protocol_version) must be 0x01 per + PRS_SOMEIP_00052. This applies to all SD messages including OfferService. + """ + assert dut.poll() is None, "DUT is not running" + + someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip) + + assert someip_msg.protocol_version == 1, ( + f"FORMAT_03: protocol_version must be 0x01; got 0x{someip_msg.protocol_version:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_08_unicast_flag_set( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_08: SD Flags byte, Unicast flag (bit 6) must be set in OfferService. + + PRS_SOMEIPSD_00351: The Unicast flag indicates the sender supports unicast + communication. + """ + assert dut.poll() is None, "DUT is not running" + + _, sd_hdr, _, _ = _capture_raw_sd_offer(host_ip) + + assert sd_hdr.flag_unicast, ( + f"FORMAT_08: SD Flags Unicast flag (bit 6) must be set in OfferService; flag_unicast={sd_hdr.flag_unicast}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_14_entry_type_is_offer( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_14: OfferService entry Type byte (byte[0]) must be 0x01. + + SOME/IP-SD Type 1 entry (OfferService) has type byte 0x01 per + PRS_SOMEIPSD_00306. This test reads the raw serialised entry byte + directly rather than relying on the parsed enum value. + """ + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + assigned = entry.assign_option_index([]) + raw = assigned.build() + + type_byte = raw[0] + assert type_byte == 0x01, f"FORMAT_14: OfferService entry Type byte must be 0x01; got 0x{type_byte:02x}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_17_ttl_is_nonzero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """FORMAT_17: OfferService entry TTL (3-byte big-endian, bytes[9–11]) must be > 0. + + TTL = 0 in an OfferService entry is reserved for StopOfferService + (PRS_SOMEIPSD_00137). A live service must advertise TTL > 0. + """ + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + assigned = entry.assign_option_index([]) + raw = assigned.build() + + # Bytes [9-11] are the 3-byte big-endian TTL field. + ttl_value = int.from_bytes(raw[9:12], "big") + assert ttl_value > 0, f"FORMAT_17: OfferService entry TTL (bytes[9-11]) must be > 0; got {ttl_value}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_format_22_ack_num_options_1_matches( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """FORMAT_22: SubscribeAck entry num_options_1 must equal len(options_1) list. + + After options are resolved, the original num_options_1 counter in the + raw entry must match the number of option objects in the resolved list. + This verifies the DUT serialises the options-count nibble correctly. + """ + assert dut.poll() is None, "DUT is not running" + + unresolved_ack, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST) + + # unresolved_ack.num_options_1 is the raw counter from the wire. + # resolved_ack.options_1 is the list populated by resolve_options(). + assert unresolved_ack.num_options_1 == len(resolved_ack.options_1), ( + f"FORMAT_22: SubscribeAck num_options_1 ({unresolved_ack.num_options_1}) " + f"must equal len(options_1) ({len(resolved_ack.options_1)})" + ) + + +class TestSdEntryOptionFields: + """SD_MESSAGE_07-09, SD_MESSAGE_11, SD_MESSAGE_12: entry option-run fields.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_07_offer_entry_type_byte( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """SD_MESSAGE_07: OfferService entry raw Type byte must be 0x01. + + PRS_SOMEIPSD_00306 assigns Type=0x01 to OfferService. + Duplicate of FORMAT_14 at the SD_MESSAGE layer to satisfy the + SD_MESSAGE_07 traceability requirement. + """ + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + assigned = entry.assign_option_index([]) + raw = assigned.build() + + type_byte = raw[0] + assert type_byte == 0x01, f"SD_MESSAGE_07: OfferService entry Type byte must be 0x01; got 0x{type_byte:02x}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_08_offer_entry_option_run2_index_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """SD_MESSAGE_08: OfferService entry option_index_2 must be 0 (single option run). + + A standard OfferService entry uses only the first option run. + Byte[2] of the 16-byte entry holds option_index_2 (the index into + the Options Array for the second option run) and must be 0 when only + one option run is used. + """ + assert dut.poll() is None, "DUT is not running" + + _, _, entry, _ = _capture_raw_sd_offer(host_ip) + + assigned = entry.assign_option_index([]) + raw = assigned.build() + + # Byte [2] is option_index_2 (second option-run start index). + option_index_2 = raw[2] + assert option_index_2 == 0, ( + f"SD_MESSAGE_08: OfferService option_index_2 (byte[2]) must be 0; got 0x{option_index_2:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_09_offer_entry_num_options_2_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """SD_MESSAGE_09: OfferService entry num_options_2 must be 0 (single option run). + + Byte[3] of the 16-byte SD entry encodes num_options_1 in the high nibble + and num_options_2 in the low nibble. A standard OfferService with a single + option run must have num_options_2 == 0 (low nibble = 0). + """ + assert dut.poll() is None, "DUT is not running" + + _, _, entry, raw_entry = _capture_raw_sd_offer(host_ip) + + assigned = entry.assign_option_index([]) + raw = assigned.build() + + # Byte [3]: high nibble = num_options_1, low nibble = num_options_2. + num_options_2 = raw[3] & 0x0F + assert num_options_2 == 0, ( + f"SD_MESSAGE_09: OfferService num_options_2 (low nibble of byte[3]) " + f"must be 0; got 0x{num_options_2:01x} " + f"(full byte[3]: 0x{raw[3]:02x})" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_format_fields"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_11_subscribe_entry_type_byte( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SD_MESSAGE_11: A SubscribeEventgroup entry must have Type byte 0x06. + + PRS_SOMEIPSD_00306 assigns Type=0x06 to SubscribeEventgroup (Type 2 entry). + This test builds a SubscribeEventgroup entry using the same helper used + by the SD sender (``send_subscribe_eventgroup``) and verifies that the + serialised entry byte[0] == 0x06. This confirms our sender constructs + conformant SD messages. + """ + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port: int = sock.getsockname()[1] + # Build a subscribe entry directly and inspect the raw bytes. + endpoint_opt = IPv4EndpointOption( + address=ipaddress.IPv4Address(tester_ip), + l4proto=L4Protocols.UDP, + port=sender_port, + ) + subscribe_entry = SOMEIPSDEntry( + sd_type=SOMEIPSDEntryType.Subscribe, + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + major_version=MAJOR_VERSION, + ttl=3, + minver_or_counter=EVENTGROUP_UDP_UNICAST & 0xFFFF, + options_1=(endpoint_opt,), + ) + options: list = [] + assigned = subscribe_entry.assign_option_index(options) + raw = assigned.build() + finally: + sock.close() + + type_byte = raw[0] + assert type_byte == 0x06, ( + f"SD_MESSAGE_11: SubscribeEventgroup entry Type byte must be 0x06; got 0x{type_byte:02x}" + ) + + +class TestSdStopSubscribeFormat: + """SD_MESSAGE_12: StopSubscribeEventgroup entry format byte-level assertions. + + Per PRS_SOMEIPSD_00386 a StopSubscribeEventgroup entry is a + SubscribeEventgroup entry (Type 0x06) with TTL set to 0x000000. + This class verifies the byte-level layout of such an entry without + any DUT interaction (the entry is constructed in memory and inspected + directly). + """ + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_stop_sub_fmt"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_12_stop_subscribe_entry_format(self) -> None: + """SD_MESSAGE_12: StopSubscribeEventgroup entry has Type=0x06 and TTL=0. + + SOMEIPSRV_SD_MESSAGE_12 (Sec. 5.1.5.3): A StopSubscribeEventgroup message + is a SubscribeEventgroup entry (Type=0x06) with TTL=0x000000. + + This test constructs the 16-byte entry in memory and verifies: + - byte[0] == 0x06 (entry type field) + - byte[9:12] == b'\\x00\\x00\\x00' (TTL = 0) + + No DUT interaction is required. This is a pure structural assertion on + the serialised entry layout. + """ + stop_subscribe_entry = SOMEIPSDEntry( + sd_type=SOMEIPSDEntryType.Subscribe, + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + major_version=MAJOR_VERSION, + ttl=0, + minver_or_counter=EVENTGROUP_UDP_UNICAST & 0xFFFF, + options_1=(), + ) + options: list = [] + assigned = stop_subscribe_entry.assign_option_index(options) + entry = assigned.build() + + assert len(entry) == 16, f"SD_MESSAGE_12: StopSubscribeEventgroup entry must be 16 bytes; got {len(entry)}" + + type_byte = entry[0] + assert type_byte == 0x06, ( + f"SD_MESSAGE_12: StopSubscribeEventgroup Type byte must be 0x06 " + f"(same as SubscribeEventgroup); got 0x{type_byte:02x}" + ) + + ttl_bytes = entry[9:12] + assert ttl_bytes == b"\x00\x00\x00", ( + f"SD_MESSAGE_12: StopSubscribeEventgroup TTL bytes[9:12] must be b'\\x00\\x00\\x00'; got {ttl_bytes!r}" + ) diff --git a/tests/tc8_conformance/test_sd_phases_timing.py b/tests/tc8_conformance/test_sd_phases_timing.py new file mode 100644 index 000000000..97a36410e --- /dev/null +++ b/tests/tc8_conformance/test_sd_phases_timing.py @@ -0,0 +1,162 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 SD phase timing tests: TC8-SD-009 and TC8-SD-010. + +Verifies that ``someipd`` goes through the Repetition Phase (short intervals) +before entering the Main Phase (long cyclic_offer_delay intervals). + +See ``docs/architecture/tc8_conformance_testing.rst``. +""" + +from typing import Generator, List, Tuple + +import pytest + +from attribute_plugin import add_test_properties + +from helpers.dut_lifecycle import ( + launch_dut, + render_someip_config, + terminate_dut, +) +from helpers.constants import SERVICE_ID +from helpers.sd_helpers import open_multicast_socket +from helpers.timing import collect_sd_offers_from_socket +from someip.header import SOMEIPSDEntry + +SOMEIP_CONFIG: str = "tc8_someipd_sd.json" + +#: SD configuration values from ``tc8_someipd_sd.json``. +_CYCLIC_OFFER_DELAY_MS: float = 2000.0 +_REPETITIONS_BASE_DELAY_MS: float = 200.0 +_REPETITIONS_MAX: int = 3 + +#: Capture timeout accounts for DUT startup, repetition phase, and one cyclic +#: gap. Theoretical minimum ~3.5 s (100 ms initial + 1.4 s reps + 2 s cyclic), +#: with generous margin for slow CI/container environments. +_PHASE_CAPTURE_TIMEOUT_SECS: float = 20.0 + + +@pytest.fixture(scope="module") +def sd_phase_capture( + tmp_path_factory: pytest.TempPathFactory, + host_ip: str, + request: pytest.FixtureRequest, +) -> Generator[List[Tuple[float, SOMEIPSDEntry]], None, None]: + """Start a fresh someipd and capture timestamped SD offers. + + Opens the multicast socket BEFORE launching someipd to guarantee the first + offer is captured. Yields the captured data to the tests. + + someipd runs on the QEMU guest; ``host_ip`` is the host TAP interface + (169.254.21.88) used for multicast join. + """ + target_init = request.getfixturevalue("target_init") + + # Ensure guest vsomeip configs are rendered (sed on QEMU guest). + # Without this the guest /tc8_sd.json is absent or has literal placeholders, + # causing vsomeip to bind loopback and SD multicast to never reach the host. + request.getfixturevalue("tc8_itf_config_setup") + + tmp_dir = tmp_path_factory.mktemp("tc8_phase_config") + # Host-side render is harmless; launch_dut uses the filename to select + # the pre-rendered guest config. + config_path = render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir) + + # Open the socket BEFORE launching someipd so the first offer is captured. + try: + capture_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip( + f"Multicast socket setup failed on {host_ip}. " + "Set TC8_HOST_IP to a non-loopback IP or add a multicast route: " + "sudo ip route add 224.0.0.0/4 dev lo" + ) + + proc = launch_dut(config_path, target_init=target_init) + + try: + offers = collect_sd_offers_from_socket( + capture_sock, + count=5, # initial offer + 3 reps + 1 cyclic + timeout_secs=_PHASE_CAPTURE_TIMEOUT_SECS, + ) + except TimeoutError: + offers = [] # tests will handle empty capture with appropriate assertions + finally: + capture_sock.close() + + terminate_dut(proc) + + yield offers + + +class TestSDPhasesTiming: + """TC8-SD-009/010: Repetition Phase intervals and count.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_phases_timing"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_009_repetition_phase_intervals( + self, + sd_phase_capture: List[Tuple[float, SOMEIPSDEntry]], + ) -> None: + """TC8-SD-009: Repetition Phase offer gaps are shorter than cyclic_offer_delay.""" + service_offers = [(ts, e) for ts, e in sd_phase_capture if e.service_id == SERVICE_ID] + assert len(service_offers) >= 2, "TC8-SD-009: Not enough OfferService entries captured for timing analysis" + + # The first gap must be a Repetition Phase gap (< half the cyclic period). + gap_ms = (service_offers[1][0] - service_offers[0][0]) * 1000.0 + cyclic_half_ms = _CYCLIC_OFFER_DELAY_MS * 0.5 + + assert gap_ms < cyclic_half_ms, ( + f"TC8-SD-009: First inter-offer gap {gap_ms:.0f} ms >= {cyclic_half_ms:.0f} ms; " + f"expected a Repetition Phase gap (< {cyclic_half_ms:.0f} ms)" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_phases_timing"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_010_repetition_count_before_main_phase( + self, + sd_phase_capture: List[Tuple[float, SOMEIPSDEntry]], + ) -> None: + """TC8-SD-010: At least repetitions_max - 1 short-gap offers before Main Phase. + + The first offer may be missed due to a race between socket setup and + process startup. Requiring ``repetitions_max - 1 = 2`` short gaps + still proves the Repetition Phase happened with doubling intervals. + """ + service_offers = [(ts, e) for ts, e in sd_phase_capture if e.service_id == SERVICE_ID] + assert len(service_offers) >= 2, "TC8-SD-010: Not enough OfferService entries captured for phase counting" + + # Count "short" gaps (Repetition Phase) vs "long" gaps (Main Phase). + cyclic_half_ms = _CYCLIC_OFFER_DELAY_MS * 0.5 + short_gap_count = 0 + + for i in range(len(service_offers) - 1): + gap_ms = (service_offers[i + 1][0] - service_offers[i][0]) * 1000.0 + if gap_ms < cyclic_half_ms: + short_gap_count += 1 + + # Allow repetitions_max - 1 to tolerate the first-offer capture race. + min_short_gaps = _REPETITIONS_MAX - 1 + assert short_gap_count >= min_short_gaps, ( + f"TC8-SD-010: Only {short_gap_count} Repetition Phase gap(s) observed; " + f"expected at least {min_short_gaps} " + f"(repetitions_max={_REPETITIONS_MAX}, tolerance: -1 for first-offer capture race)" + ) diff --git a/tests/tc8_conformance/test_sd_reboot.py b/tests/tc8_conformance/test_sd_reboot.py new file mode 100644 index 000000000..1c7971de5 --- /dev/null +++ b/tests/tc8_conformance/test_sd_reboot.py @@ -0,0 +1,373 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 Service Discovery: Reboot Detection tests (TC8-SD-012). + +Isolated in a separate module because these tests manage their own ``someipd`` +lifecycle (start, drain, stop, restart) and must not share the +module-scoped ``dut`` fixture used by ``test_service_discovery.py``. +Running both in the same module would cause a routing-manager conflict. + +See ``docs/tc8_conformance/requirements.rst`` for requirement traceability. +""" + +import socket +import time +from typing import List, Tuple + +import pytest + +from attribute_plugin import add_test_properties + +from helpers.dut_lifecycle import ( + cleanup_vsomeip_sockets, + launch_dut, + render_someip_config, + terminate_dut, +) +from helpers.constants import SD_PORT +from helpers.sd_helpers import open_multicast_socket +from someip.header import SOMEIPHeader, SOMEIPSDHeader + +SOMEIP_CONFIG: str = "tc8_someipd_sd.json" + +#: Maximum wait time for SD messages, derived from TC8 spec Sec. 4 IUT parameters: +#: Listen Time (10 s) + Tolerance Time (1 s) + Process Time (2 s) = 13 s, +#: plus 2 s buffer for container/CI overhead. +_SD_CAPTURE_TIMEOUT_SECS: float = 15.0 + + +@pytest.fixture(scope="module") +def sd_reboot_capture( + tmp_path_factory: pytest.TempPathFactory, + host_ip: str, + request: pytest.FixtureRequest, +) -> List[Tuple[SOMEIPHeader, SOMEIPSDHeader]]: + """Start someipd, drain stable SD messages, restart, return post-reboot messages. + + Returns a list of (outer_header, sd_header) tuples captured after the restart. + The multicast socket is opened *before* the second launch so the very first + post-reboot SD packet is captured. + + someipd runs on the QEMU guest; vsomeip socket cleanup also runs on the guest. + """ + target_init = request.getfixturevalue("target_init") + + # Ensure guest vsomeip configs are rendered (sed on QEMU guest). + # Without this the guest /tc8_sd.json is absent or has literal placeholders, + # causing vsomeip to bind loopback and SD multicast to never reach the host. + request.getfixturevalue("tc8_itf_config_setup") + + tmp_dir = tmp_path_factory.mktemp("tc8_reboot_config") + # Host-side render is harmless; launch_dut uses the filename to select + # the pre-rendered guest config. + config_path = render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir) + + def _collect_sd_messages( + capture_sock: socket.socket, + count: int, + timeout_secs: float, + ) -> List[Tuple[SOMEIPHeader, SOMEIPSDHeader]]: + """Receive up to *count* SOME/IP-SD messages within *timeout_secs*.""" + collected: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = [] + deadline = time.monotonic() + timeout_secs + while time.monotonic() < deadline and len(collected) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + capture_sock.settimeout(min(remaining, 1.0)) + try: + data, _ = capture_sock.recvfrom(65535) + except socket.timeout: + continue + try: + outer, _ = SOMEIPHeader.parse(data) + if outer.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload) + collected.append((outer, sd_hdr)) + except Exception: # noqa: BLE001 + continue + return collected + + try: + pre_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip(f"Multicast socket setup failed on {host_ip}. Set TC8_HOST_IP to a non-loopback IP.") + + try: + proc1 = launch_dut(config_path, target_init=target_init) + except Exception: + pre_sock.close() + raise + + try: + # Drain 3 SD messages so the DUT has cleared the reboot flag. + _collect_sd_messages(pre_sock, count=3, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS) + finally: + pre_sock.close() + terminate_dut(proc1) + + # Remove stale vsomeip routing-manager sockets so the second instance + # can become routing manager immediately. + cleanup_vsomeip_sockets(target_init=target_init) + + try: + post_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip("Multicast socket unavailable for post-reboot capture.") + + try: + proc2 = launch_dut(config_path, target_init=target_init) + except Exception: + post_sock.close() + raise + + try: + post_messages = _collect_sd_messages(post_sock, count=2, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS) + finally: + post_sock.close() + terminate_dut(proc2) + + return post_messages + + +class TestSDReboot: + """TC8-SD-012: DUT resets SD state on restart (reboot flag + session ID).""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_reboot"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_012_reboot_flag_set_after_restart( + self, + sd_reboot_capture: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]], + ) -> None: + """TC8-SD-012: First SD message after restart has the reboot flag set (bit 7 = 1).""" + assert sd_reboot_capture, "TC8-SD-012: No SD messages captured after restart" + _, sd_hdr = sd_reboot_capture[0] + + # SOME/IP-SD specification: flags byte bit 7 (0x80) is the reboot flag. + # The someip library exposes this as ``flag_reboot`` on SOMEIPSDHeader. + reboot_flag = getattr(sd_hdr, "flag_reboot", None) + if reboot_flag is None: + # Defensive fallback if the library attribute name changes. + raw_flags = getattr(sd_hdr, "flags", 0) + reboot_flag = bool(raw_flags & 0x80) + + assert reboot_flag, ( + "TC8-SD-012: Reboot flag (SD flags bit 7) not set in first SD message " + "after restart. The DUT must reset its SD state (session counter and " + "reboot flag) when restarted (PRS_SOMEIPSD_00157)." + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_reboot"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_012_session_id_resets_after_restart( + self, + sd_reboot_capture: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]], + ) -> None: + """TC8-SD-012: SD session ID resets to ≤ 2 after restart.""" + assert sd_reboot_capture, "TC8-SD-012: No SD messages captured after restart" + outer, _ = sd_reboot_capture[0] + assert outer.session_id <= 2, ( + f"TC8-SD-012: session_id after restart = {outer.session_id}; " + "expected ≤ 2 (session counter must reset on reboot)" + ) + + +class TestSDRebootDetectionETS: + """TC8-SDLC-017/018: ETS reboot flag and session ID behaviour after restart. + + Unlike ``TestSDReboot``, these tests manage their own someipd lifecycle + inline (no shared fixture) so they can verify unicast and multicast + behaviour independently. + """ + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_reboot"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_093_reboot_on_unicast_channel( + self, + tmp_path_factory: pytest.TempPathFactory, + host_ip: str, + request: pytest.FixtureRequest, + ) -> None: + """TC8-SDLC-017: First unicast SD OFFER after DUT restart has reboot flag set and session_id = 1.""" + target_init = request.getfixturevalue("target_init") + request.getfixturevalue("tc8_itf_config_setup") + + tmp_dir = tmp_path_factory.mktemp("tc8_ets093_config") + config_path = render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir) + + try: + pre_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip(f"Multicast socket setup failed on {host_ip}. Set TC8_HOST_IP to a non-loopback IP.") + + proc1 = launch_dut(config_path, target_init=target_init) + drained: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = [] + deadline = time.monotonic() + _SD_CAPTURE_TIMEOUT_SECS + while time.monotonic() < deadline and len(drained) < 3: + remaining = deadline - time.monotonic() + pre_sock.settimeout(min(remaining, 1.0)) + try: + data, _ = pre_sock.recvfrom(65535) + outer, _ = SOMEIPHeader.parse(data) + if outer.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload) + drained.append((outer, sd_hdr)) + except socket.timeout: + continue + except Exception: # noqa: BLE001 + continue + pre_sock.close() + terminate_dut(proc1) + + cleanup_vsomeip_sockets(target_init=target_init) + + try: + post_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip("Multicast socket unavailable for post-reboot capture.") + + proc2 = launch_dut(config_path, target_init=target_init) + post_messages: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = [] + deadline2 = time.monotonic() + _SD_CAPTURE_TIMEOUT_SECS + while time.monotonic() < deadline2 and len(post_messages) < 1: + remaining = deadline2 - time.monotonic() + post_sock.settimeout(min(remaining, 1.0)) + try: + data, _ = post_sock.recvfrom(65535) + outer, _ = SOMEIPHeader.parse(data) + if outer.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload) + post_messages.append((outer, sd_hdr)) + except socket.timeout: + continue + except Exception: # noqa: BLE001 + continue + post_sock.close() + terminate_dut(proc2) + + assert post_messages, "TC8-SDLC-017: No SD messages captured after DUT restart" + outer_hdr, sd_hdr = post_messages[0] + + reboot_flag = getattr(sd_hdr, "flag_reboot", None) + if reboot_flag is None: + raw_flags = getattr(sd_hdr, "flags", 0) + reboot_flag = bool(raw_flags & 0x80) + + assert reboot_flag, ( + "TC8-SDLC-017: Reboot flag (SD flags bit 7) not set in first SD OFFER after restart (PRS_SOMEIPSD_00157)." + ) + assert outer_hdr.session_id == 1, ( + f"TC8-SDLC-017: session_id after restart = {outer_hdr.session_id}; " + "expected 1 (session counter must reset to 1 on reboot)" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_reboot"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_094_server_reboot_session_id_resets( + self, + tmp_path_factory: pytest.TempPathFactory, + host_ip: str, + request: pytest.FixtureRequest, + ) -> None: + """TC8-SDLC-018: After restart the SD session_id resets to 1 and the reboot flag is set. + + Verifies PRS_SOMEIPSD_00157 from the multicast channel: the very first + OfferService multicast after a clean restart must have session_id = 1 + and the reboot flag bit set regardless of what session_id was before. + """ + target_init = request.getfixturevalue("target_init") + request.getfixturevalue("tc8_itf_config_setup") + + tmp_dir = tmp_path_factory.mktemp("tc8_ets094_config") + config_path = render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir) + + try: + pre_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip(f"Multicast socket setup failed on {host_ip}. Set TC8_HOST_IP to a non-loopback IP.") + + proc1 = launch_dut(config_path, target_init=target_init) + pre_messages: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = [] + deadline = time.monotonic() + _SD_CAPTURE_TIMEOUT_SECS + while time.monotonic() < deadline and len(pre_messages) < 3: + remaining = deadline - time.monotonic() + pre_sock.settimeout(min(remaining, 1.0)) + try: + data, _ = pre_sock.recvfrom(65535) + outer, _ = SOMEIPHeader.parse(data) + if outer.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload) + pre_messages.append((outer, sd_hdr)) + except socket.timeout: + continue + except Exception: # noqa: BLE001 + continue + pre_sock.close() + terminate_dut(proc1) + + cleanup_vsomeip_sockets(target_init=target_init) + + try: + post_sock = open_multicast_socket(host_ip) + except OSError: + pytest.skip("Multicast socket unavailable for post-reboot capture.") + + proc2 = launch_dut(config_path, target_init=target_init) + post_messages: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = [] + deadline2 = time.monotonic() + _SD_CAPTURE_TIMEOUT_SECS + while time.monotonic() < deadline2 and len(post_messages) < 1: + remaining = deadline2 - time.monotonic() + post_sock.settimeout(min(remaining, 1.0)) + try: + data, _ = post_sock.recvfrom(65535) + outer, _ = SOMEIPHeader.parse(data) + if outer.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload) + post_messages.append((outer, sd_hdr)) + except socket.timeout: + continue + except Exception: # noqa: BLE001 + continue + post_sock.close() + terminate_dut(proc2) + + assert post_messages, "TC8-SDLC-018: No SD messages captured after DUT restart" + outer_hdr, sd_hdr = post_messages[0] + + reboot_flag = getattr(sd_hdr, "flag_reboot", None) + if reboot_flag is None: + raw_flags = getattr(sd_hdr, "flags", 0) + reboot_flag = bool(raw_flags & 0x80) + + assert reboot_flag, "TC8-SDLC-018: Reboot flag not set in first SD OFFER after restart (PRS_SOMEIPSD_00157)." + assert outer_hdr.session_id == 1, ( + f"TC8-SDLC-018: session_id after restart = {outer_hdr.session_id}; " + "expected 1 (session counter must reset on reboot per PRS_SOMEIPSD_00157)" + ) diff --git a/tests/tc8_conformance/test_sd_robustness.py b/tests/tc8_conformance/test_sd_robustness.py new file mode 100644 index 000000000..e24fb0f53 --- /dev/null +++ b/tests/tc8_conformance/test_sd_robustness.py @@ -0,0 +1,830 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 Group 4: SD Robustness: malformed SD message handling. + +Verifies that ``someipd`` never crashes, hangs, or enters an incorrect state +when it receives malformed SOME/IP-SD packets. + +All tests follow the pattern: inject one malformed SD packet, then send a valid +FindService and verify the DUT still replies with OfferService. + +The "DUT alive" assertion is the primary safety property: no crash implies the +DUT continues to answer SD queries. + +Test classes: TestSDMalformedEntries (ETS_111-118/123-125), +TestSDMalformedOptions (ETS_134-139/174), TestSDSubscribeEdgeCases +(ETS_109/110/119/140-144), TestSDMessageFramingErrors (ETS_152/153/178). +""" + +import socket +import subprocess +from typing import List + +import pytest + +from attribute_plugin import add_test_properties + +from helpers.constants import ( + EVENTGROUP_UDP_UNICAST, + INSTANCE_ID, + SD_PORT, + SERVICE_ID, +) +from helpers.sd_malformed import ( + send_sd_empty_entries, + send_sd_empty_option, + send_sd_entries_length_wrong, + send_sd_entry_refs_more_options, + send_sd_entry_same_option_twice, + send_sd_entry_unknown_option_type, + send_sd_find_with_options, + send_sd_high_session_id, + send_sd_option_length_too_long, + send_sd_option_length_too_short, + send_sd_option_length_unaligned, + send_sd_options_array_length_too_long, + send_sd_options_array_length_too_short, + send_sd_oversized_entries_length, + send_sd_subscribe_no_endpoint, + send_sd_subscribe_nonexistent_service, + send_sd_subscribe_reserved_option, + send_sd_subscribe_wrong_l4proto, + send_sd_subscribe_zero_ip, + send_sd_truncated_entry, + send_sd_wrong_someip_length, + send_sd_wrong_someip_message_id, +) +from helpers.sd_sender import ( + SOMEIPSDEntryType, + capture_unicast_sd_entries, + open_sender_socket, + send_find_service, +) + +SOMEIP_CONFIG: str = "tc8_someipd_sd.json" + +#: An unknown service/instance not offered by the DUT. +_UNKNOWNSERVICE_ID: int = 0xDEAD +_UNKNOWNINSTANCE_ID: int = 0xBEEF +_UNKNOWNEVENTGROUP_UDP_UNICAST: int = 0xDEAD + +#: How long to wait for a DUT OfferService reply after injecting malformed data. +_DUT_ALIVE_TIMEOUT: float = 5.0 + +#: Subscriber port used when building endpoint options in malformed packets. +_SUBSCRIBER_PORT: int = 34567 + + +def _verify_dut_alive(sock: socket.socket, dut_ip: str) -> None: + """Verify the DUT is still alive by checking it responds to a valid FindService. + + Sends a FindService and waits up to ``_DUT_ALIVE_TIMEOUT`` seconds for an + OfferService reply. Fails the calling test if no reply arrives, which + indicates the DUT crashed or is unresponsive after malformed packet injection. + """ + sd_dest = (dut_ip, SD_PORT) + + def _resend() -> None: + send_find_service(sock, sd_dest, SERVICE_ID) + + _resend() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=_DUT_ALIVE_TIMEOUT, + resend=_resend, + resend_interval_secs=1.0, + max_results=1, + ) + assert len(entries) >= 1, ( + f"DUT is not alive — no OfferService received within {_DUT_ALIVE_TIMEOUT:.0f}s after malformed SD injection" + ) + + +@pytest.fixture(scope="module") +def sender(tester_ip: str) -> socket.socket: + """Open a UDP sender socket bound to tester_ip:SD_PORT for the entire module.""" + sock = open_sender_socket(tester_ip) + yield sock + sock.close() + + +class TestSDMalformedEntries: + """ETS_111/112/113/114/115/116/117/118/123/124/125: malformed entries array.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_111_empty_entries_array( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_111: SD packet with entries_array_length=0; DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_empty_entries(sender, (dut_ip, SD_PORT)) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_112_empty_option_zero_length( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_112/113: SubscribeEventgroup with option length=1 (too short). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_empty_option(sender, (dut_ip, SD_PORT), SERVICE_ID, INSTANCE_ID, EVENTGROUP_UDP_UNICAST) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_114_entries_length_zero( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_114: entries_array_length=0 but one entry is present. DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_entries_length_wrong(sender, (dut_ip, SD_PORT), SERVICE_ID, entries_length_override=0) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_114_entries_length_mismatched( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_114: entries_array_length=8 (not a multiple of 16). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_entries_length_wrong(sender, (dut_ip, SD_PORT), SERVICE_ID, entries_length_override=8) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_115_entry_refs_more_options_than_exist( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_115: Entry num_options_1=3 but options array has only 1. DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_entry_refs_more_options( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_116_entry_unknown_option_type( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_116/174: SubscribeEventgroup with unknown option type 0x77. DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_entry_unknown_option_type(sender, (dut_ip, SD_PORT), SERVICE_ID, INSTANCE_ID, EVENTGROUP_UDP_UNICAST) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_117_two_entries_same_option( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_117: Two entries sharing option index 0 (index overlap). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_entry_same_option_twice( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_118_find_service_with_endpoint_option( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_118: FindService entry with an unexpected endpoint option attached. + + Per spec the DUT must ignore the option and still respond to FindService. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_find_with_options(sender, (dut_ip, SD_PORT), SERVICE_ID, tester_ip, _SUBSCRIBER_PORT) + # The DUT should still respond to this FindService (options are ignored on Find) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_123_entries_length_wildly_too_large( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_123/124: entries_array_length=0xFFFF (far exceeds packet size). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_oversized_entries_length(sender, (dut_ip, SD_PORT), SERVICE_ID) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_125_truncated_entry( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_125: entries_array_length=16 but only 8 bytes of entry data present. DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_truncated_entry(sender, (dut_ip, SD_PORT), SERVICE_ID) + _verify_dut_alive(sender, dut_ip) + + +class TestSDMalformedOptions: + """ETS_134/135/136/137/138/139/174: malformed options array.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_134_option_length_much_too_large( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_134: IPv4EndpointOption length field = 0x00FF (way larger than 0x0009). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_option_length_too_long( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + option_length_override=0x00FF, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_135_option_length_one_too_large( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_135: IPv4EndpointOption length field = 0x000A (one larger than 0x0009). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_option_length_too_long( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + option_length_override=0x000A, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_136_option_length_too_short( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_136: IPv4EndpointOption length field = 0x0001 (shorter than minimum). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_option_length_too_short( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_137_option_length_unaligned( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_137: IPv4EndpointOption length field = 0x000A (unaligned/odd). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_option_length_unaligned( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_138_options_array_length_too_large( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_138: options_array_length claims 100 bytes but only 12 present. DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_options_array_length_too_long( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_139_options_array_length_too_short( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_139: options_array_length claims 2 bytes but 12 are present. DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_options_array_length_too_short( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_174_unknown_option_type_0x77( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_174: Option type 0x77 (unknown/reserved). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_entry_unknown_option_type(sender, (dut_ip, SD_PORT), SERVICE_ID, INSTANCE_ID, EVENTGROUP_UDP_UNICAST) + _verify_dut_alive(sender, dut_ip) + + +class TestSDSubscribeEdgeCases: + """ETS_109/110/119/140/141/142/143/144: subscribe message edge cases.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_109_subscribe_no_endpoint_option( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_109: SubscribeEventgroup with num_options_1=0 (no endpoint). + + DUT must send NAck or silently discard. Must not crash. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_subscribe_no_endpoint(sender, (dut_ip, SD_PORT), SERVICE_ID, INSTANCE_ID, EVENTGROUP_UDP_UNICAST) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_110_subscribe_endpoint_ip_zero( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_110: SubscribeEventgroup with endpoint IP = 0.0.0.0. + + DUT must send NAck or silently discard. Must not crash. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_subscribe_zero_ip( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + subscriber_port=_SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_119_subscribe_unknown_l4proto( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_119: SubscribeEventgroup with L4 protocol byte = 0x00 (unknown). + + DUT must send NAck or silently discard. Must not crash. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_subscribe_wrong_l4proto( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + l4proto=0x00, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_140_subscribe_unknown_service_id( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_140: SubscribeEventgroup for an unknown service_id (0xDEAD). + + DUT must not send SubscribeAck for a service it does not offer. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_subscribe_nonexistent_service( + sender, + (dut_ip, SD_PORT), + _UNKNOWNSERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_141_subscribe_unknown_instance_id( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_141: SubscribeEventgroup for correct service_id but unknown instance_id. + + DUT must not send SubscribeAck for a non-existent instance. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_subscribe_nonexistent_service( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + _UNKNOWNINSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_142_subscribe_unknown_eventgroup_id( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_142: SubscribeEventgroup for correct service/instance but unknown eventgroup. + + DUT must send NAck (SubscribeAck TTL=0) or silently discard. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_subscribe_nonexistent_service( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + _UNKNOWNEVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_143_subscribe_all_ids_unknown( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_143: SubscribeEventgroup with service, instance, and eventgroup all unknown. + + DUT must not send any SubscribeAck. Must not crash. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_subscribe_nonexistent_service( + sender, + (dut_ip, SD_PORT), + _UNKNOWNSERVICE_ID, + _UNKNOWNINSTANCE_ID, + _UNKNOWNEVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_144_subscribe_reserved_option_type( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_144: SubscribeEventgroup with reserved option type 0x20. + + DUT must send NAck or silently discard. Must not crash. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_subscribe_reserved_option( + sender, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + tester_ip, + _SUBSCRIBER_PORT, + ) + _verify_dut_alive(sender, dut_ip) + + +class TestSDMessageFramingErrors: + """ETS_152/153/178: SOME/IP framing and header field errors.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_152_high_session_id_0xfffe( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_152a: FindService with session_id=0xFFFE. DUT must not be confused by near-wrap session ID.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_high_session_id(sender, (dut_ip, SD_PORT), SERVICE_ID, session_id=0xFFFE) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_152_session_id_0xffff( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_152b: FindService with session_id=0xFFFF (maximum). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_high_session_id(sender, (dut_ip, SD_PORT), SERVICE_ID, session_id=0xFFFF) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_152_session_id_one( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_152c: FindService with session_id=0x0001 after high session_id. DUT must accept wrap.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_high_session_id(sender, (dut_ip, SD_PORT), SERVICE_ID, session_id=0x0001) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_153_someip_length_too_small( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_153a: SOME/IP length field smaller than actual payload (length=8). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_wrong_someip_length(sender, (dut_ip, SD_PORT), SERVICE_ID, length_override=8) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_153_someip_length_too_large( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_153b: SOME/IP length field larger than actual payload (length=0x1000). DUT must not crash.""" + assert dut.poll() is None, "DUT is not running before injection" + send_sd_wrong_someip_length(sender, (dut_ip, SD_PORT), SERVICE_ID, length_override=0x1000) + _verify_dut_alive(sender, dut_ip) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_robustness"], + test_type="robustness", + derivation_technique="fault-injection", + ) + def test_ets_178_wrong_someip_service_id( + self, + dut: subprocess.Popen[bytes], + sender: socket.socket, + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_178: SD packet with SOME/IP service_id=0x1234 (not 0xFFFF). + + DUT must silently discard (not recognized as SD) and remain alive. + """ + assert dut.poll() is None, "DUT is not running before injection" + send_sd_wrong_someip_message_id(sender, (dut_ip, SD_PORT)) + _verify_dut_alive(sender, dut_ip) diff --git a/tests/tc8_conformance/test_service_discovery.py b/tests/tc8_conformance/test_service_discovery.py new file mode 100644 index 000000000..229136b79 --- /dev/null +++ b/tests/tc8_conformance/test_service_discovery.py @@ -0,0 +1,2144 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 Service Discovery tests: TC8-SD-001 through TC8-SD-014. + +See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture. +""" + +import ipaddress +import socket +import subprocess +import time +import pytest + +from attribute_plugin import add_test_properties + +from helpers.sd_helpers import capture_sd_offers, open_multicast_socket +from helpers.sd_sender import ( + SOMEIPSDEntryType, + capture_some_ip_messages, + capture_unicast_sd_entries, + open_sender_socket, + send_find_service, + send_subscribe_eventgroup, + send_subscribe_eventgroup_reserved_set, +) +from helpers.someip_assertions import ( + assert_offer_has_ipv4_endpoint_option, + assert_sd_offer_entry, +) +from helpers.constants import ( + DUT_UNRELIABLE_PORT, + EVENTGROUP_UDP_MULTICAST, + EVENTGROUP_UDP_UNICAST, + INSTANCE_ID, + MAJOR_VERSION, + MINOR_VERSION, + SD_MULTICAST_ADDR, + SD_PORT, + SERVICE_ID, +) +from helpers.timing import capture_sd_offers_with_timestamps +from someip.header import SOMEIPHeader, SOMEIPSDHeader + +#: SOME/IP stack config template used for all tests in this module. +SOMEIP_CONFIG: str = "tc8_someipd_sd.json" + +#: Service and instance IDs declared in ``tc8_someipd_sd.json``. +_UNKNOWNEVENTGROUP_UDP_UNICAST: int = 0xBEEF +_UNKNOWNSERVICE_ID: int = 0xBEEF + +#: SD configuration values from ``tc8_someipd_sd.json``. +_CYCLIC_OFFER_DELAY_MS: float = 2000.0 +_REQUEST_RESPONSE_DELAY_MS: float = 500.0 + +#: Unknown IDs used in negative tests. +_UNKNOWNINSTANCE_ID: int = 0xBEEF + + +class TestSDOfferFormat: + """TC8-SD-001/002/003: Offer presence, fields, and cyclic timing.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_offer_format"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_001_multicast_offer_on_startup( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """TC8-SD-001: someipd sends at least one SD OfferService on multicast at startup.""" + assert dut.poll() is None, "DUT is not running" + + offers = capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0) + + assert len(offers) >= 1, ( + "TC8-SD-001: No SD OfferService entries received within 5 s. " + "Verify someipd is running and the SD multicast address/port matches config." + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_offer_format"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_002_offer_entry_format( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """TC8-SD-002: OfferService entry has correct service ID, instance ID, version, and TTL.""" + assert dut.poll() is None, "DUT is not running" + + offers = capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0) + + service_offers = [e for e in offers if e.service_id == SERVICE_ID] + assert service_offers, ( + f"TC8-SD-002: No OfferService entry found for service 0x{SERVICE_ID:04x} " + f"in {len(offers)} captured SD entries." + ) + + assert_sd_offer_entry( + service_offers[0], + expected_service_id=SERVICE_ID, + expected_instance_id=INSTANCE_ID, + expected_major_version=MAJOR_VERSION, + expected_minor_version=MINOR_VERSION, + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_cyclic_timing"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_003_cyclic_offer_timing( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """TC8-SD-003: Offers repeat at cyclic_offer_delay ±20% in the main phase.""" + assert dut.poll() is None, "DUT is not running" + + # Wait for main phase. Repetition phase ends ~1.5 s after first offer + # (200+400+800 ms doubling, repetitions_max=3). Add one cyclic gap + # (2000 ms) to ensure we capture main-phase offers. + time.sleep(3.5) + + # Capture enough offers to derive at least 2 inter-cycle gaps. + # The DUT may send multiple SD packets per cycle (service + eventgroup), + # so we capture extra and de-duplicate within a 500 ms window. + timed = capture_sd_offers_with_timestamps(host_ip, count=5, timeout_secs=15.0) + + service_offers = [(ts, e) for ts, e in timed if e.service_id == SERVICE_ID] + assert len(service_offers) >= 2, "TC8-SD-003: Not enough OfferService entries for timing analysis" + + # De-duplicate: collapse offers within 500 ms into one cycle timestamp. + cycle_timestamps: list[float] = [service_offers[0][0]] + for ts, _ in service_offers[1:]: + if (ts - cycle_timestamps[-1]) * 1000.0 > 500: + cycle_timestamps.append(ts) + + assert len(cycle_timestamps) >= 3, ( + f"TC8-SD-003: Only {len(cycle_timestamps)} distinct cycles captured " + f"(need at least 3 for 2 gap measurements)" + ) + + lo_ms = _CYCLIC_OFFER_DELAY_MS * 0.80 + hi_ms = _CYCLIC_OFFER_DELAY_MS * 1.20 + + for i in range(len(cycle_timestamps) - 1): + gap_ms = (cycle_timestamps[i + 1] - cycle_timestamps[i]) * 1000.0 + assert lo_ms <= gap_ms <= hi_ms, ( + f"TC8-SD-003: inter-offer gap {gap_ms:.0f} ms not in " + f"[{lo_ms:.0f}, {hi_ms:.0f}] ms " + f"(cyclic_offer_delay={_CYCLIC_OFFER_DELAY_MS:.0f} ms ±20%)" + ) + + +class TestSDFindResponse: + """TC8-SD-004/005: FindService response for known/unknown services.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_004_find_known_service_unicast_offer( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-SD-004: FindService for offered service triggers a unicast OfferService.""" + assert dut.poll() is None, "DUT is not running" + + # tester_ip differs from host_ip; both need SD_PORT (SD spec requirement). + # Send FindService via unicast to the DUT (multicast delivery on loopback + # between different 127.x addresses is unreliable in some environments). + sock = open_sender_socket(tester_ip) + try: + + def _send_find() -> None: + send_find_service( + sock, + (dut_ip, SD_PORT), + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + ) + + _send_find() + # Provider responds to the sender's address via unicast. + # DUT may defer the response until the next SD cycle (~2 s). + # Resend periodically to handle batching/dedup in long-running DUT. + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=5.0, + resend=_send_find, + ) + service_offers = [e for e in entries if e.service_id == SERVICE_ID] + assert service_offers, ( + f"TC8-SD-004: No unicast OfferService received for service 0x{SERVICE_ID:04x} within 5 s of FindService" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_005_find_unknown_service_no_response( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-SD-005: FindService for unknown service does not trigger OfferService.""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + send_find_service( + sock, + (SD_MULTICAST_ADDR, SD_PORT), + service_id=_UNKNOWNSERVICE_ID, + ) + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=2.0, + ) + unknown_offers = [e for e in entries if e.service_id == _UNKNOWNSERVICE_ID] + assert not unknown_offers, ( + f"TC8-SD-005: Unexpected OfferService received for unknown service 0x{_UNKNOWNSERVICE_ID:04x}" + ) + finally: + sock.close() + + +class TestSDSubscribeLifecycle: + """TC8-SD-006/007/008: Subscribe Ack, Nack, and StopSubscribe.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_006_subscribe_valid_eventgroup_ack( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-SD-006: Valid SubscribeEventgroup receives SubscribeEventgroupAck (TTL>0).""" + assert dut.poll() is None, "DUT is not running" + + # DUT sends Ack back to the Subscribe source address. + # DUT may defer the Ack until the next SD cycle (~2 s). + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] # = SD_PORT + + def _send_subscribe() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send_subscribe() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_send_subscribe, + ) + acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0] + assert acks, f"TC8-SD-006: No SubscribeEventgroupAck received for eventgroup 0x{EVENTGROUP_UDP_UNICAST:04x}" + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_007_subscribe_unknown_eventgroup_nack( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-SD-007: SubscribeEventgroup for unknown eventgroup receives Nack (TTL=0).""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] # = SD_PORT + + def _send_subscribe_unknown() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + _UNKNOWNEVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send_subscribe_unknown() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_send_subscribe_unknown, + ) + nacks = [e for e in entries if e.eventgroup_id == _UNKNOWNEVENTGROUP_UDP_UNICAST and e.ttl == 0] + assert nacks, ( + f"TC8-SD-007: No SubscribeEventgroupNack received for unknown eventgroup " + f"0x{_UNKNOWNEVENTGROUP_UDP_UNICAST:04x} (expected SubscribeAck with TTL=0)" + ) + finally: + sock.close() + + @pytest.mark.skip(reason="Production stack sends no event notifications without mw::com ETS app (2026-08-11)") + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_008_stop_subscribe_ceases_notifications( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-SD-008: StopSubscribeEventgroup (TTL=0) ceases event notifications.""" + assert dut.poll() is None, "DUT is not running" + + # sd_sock: SD sender at tester_ip:SD_PORT. + # notif_sock: receives event notifications at tester_ip:. + sd_sock = open_sender_socket(tester_ip) + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port: int = notif_sock.getsockname()[1] + + try: + # Notifications will arrive on notif_sock. + def _send_sub_008() -> None: + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ) + + _send_sub_008() + acks = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_send_sub_008, + ) + assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), ( + "TC8-SD-008: Prerequisite failed — no SubscribeEventgroupAck received" + ) + + # Expect at least one notification (DUT fires notify() every 2 s). + notifs = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0) + assert notifs, "TC8-SD-008: No SOME/IP notifications received after subscribe" + + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ttl=0, + ) + + post = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0) + assert not post, f"TC8-SD-008: {len(post)} notification(s) received after StopSubscribeEventgroup" + finally: + sd_sock.close() + notif_sock.close() + + +class TestSDOptionFormat: + """TC8-SD-011: OfferService entry carries a valid IPv4EndpointOption.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_endpoint_option"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_011_offer_ipv4_endpoint_option( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-SD-011: SD OFFER entry includes IPv4EndpointOption with correct address/port/proto.""" + assert dut.poll() is None, "DUT is not running" + + # Capture raw SD packets so we can inspect options attached to entries. + sock = open_multicast_socket(host_ip) + try: + deadline = time.monotonic() + 5.0 + found_entry = None + while time.monotonic() < deadline and found_entry is None: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 1.0)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + continue + try: + someip_msg, _ = SOMEIPHeader.parse(data) + if someip_msg.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload) + sd_hdr = sd_hdr.resolve_options() # populate entry.options_1 from sd_hdr.options + for entry in sd_hdr.entries: + if entry.sd_type == SOMEIPSDEntryType.OfferService and entry.service_id == SERVICE_ID: + found_entry = entry + break + except Exception: # noqa: BLE001 + continue + finally: + sock.close() + + assert found_entry is not None, ( + f"TC8-SD-011: No OfferService entry found for service 0x{SERVICE_ID:04x} within 5 s" + ) + assert_offer_has_ipv4_endpoint_option( + found_entry, + expected_ip=dut_ip, + expected_port=DUT_UNRELIABLE_PORT, + ) + + +# TC8-SD-012 tests are in tests/tc8_conformance/test_sd_reboot.py. +# They require their own someipd lifecycle (start/stop/restart) and cannot +# share the module-scoped dut fixture used by the other SD tests here. + + +class TestSDMulticastEventgroup: + """TC8-SD-013: SUBSCRIBE_ACK for multicast eventgroup includes multicast IP option.""" + + @pytest.mark.network + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_mcast_eg"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_013_subscribe_ack_has_multicast_option( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-SD-013: Subscribing to multicast eventgroup 0x4465 yields a multicast endpoint option.""" + + # On loopback, vsomeip 3.6.1 does not include IPv4MulticastOption in + # SUBSCRIBE_ACK. Requires a non-loopback interface. + if ipaddress.ip_address(host_ip).is_loopback: + pytest.skip( + "TC8-SD-013: Multicast endpoint option in SUBSCRIBE_ACK requires a " + "non-loopback interface. " + "Set TC8_HOST_IP to a non-loopback address (e.g. TC8_HOST_IP=192.168.x.y)." + ) + + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + + def _send_subscribe_multicast() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_MULTICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send_subscribe_multicast() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_send_subscribe_multicast, + ) + acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_MULTICAST and e.ttl > 0] + assert acks, ( + f"TC8-SD-013: No SubscribeEventgroupAck received for multicast " + f"eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + ) + ack = acks[0] + # The ACK for a multicast eventgroup must carry an IPv4MulticastOption + # (SD option type 0x14). This is distinct from IPv4EndpointOption (0x04) + # which is used for unicast endpoints; isinstance(o, IPv4EndpointOption) + # would silently exclude all multicast options. + from someip.header import ( + IPv4MulticastOption, + ) # local import to keep module-level clean + + options = list(getattr(ack, "options_1", ())) + list(getattr(ack, "options_2", ())) + multicast_opts = [ + o + for o in options + if isinstance(o, IPv4MulticastOption) and ipaddress.ip_address(str(o.address)).is_multicast + ] + assert multicast_opts, ( + f"TC8-SD-013: SUBSCRIBE_ACK for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x} " + f"does not carry an IPv4MulticastOption (SD option type 0x14). " + f"Options found: {options}" + ) + finally: + sock.close() + + +class TestSDTTLExpiry: + """TC8-SD-014: Subscription with finite TTL is cleaned up after expiry.""" + + @pytest.mark.skip(reason="Production stack sends no event notifications without mw::com ETS app (2026-08-11)") + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_014_ttl_expiry_ceases_notifications( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """TC8-SD-014: No notifications after subscription TTL expires.""" + assert dut.poll() is None, "DUT is not running" + + sd_sock = open_sender_socket(tester_ip) + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port: int = notif_sock.getsockname()[1] + + try: + _TTL_SECS = 3 + + def _send_sub_ttl() -> None: + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ttl=_TTL_SECS, + ) + + _send_sub_ttl() + acks = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_send_sub_ttl, + ) + assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), ( + "TC8-SD-014: Prerequisite failed — no SubscribeEventgroupAck received" + ) + + pre_expiry = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0) + assert pre_expiry, "TC8-SD-014: No notifications received before TTL expiry" + + # Wait for TTL to expire (TTL + 2 s margin). + time.sleep(_TTL_SECS + 2) + + post_expiry = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=3.0) + assert not post_expiry, ( + f"TC8-SD-014: {len(post_expiry)} notification(s) received after TTL expiry ({_TTL_SECS} s + 2 s margin)" + ) + finally: + sd_sock.close() + notif_sock.close() + + +class TestSDVersionMatching: + """SOMEIPSRV_SD_MESSAGE_01-06: FindService instance/version wildcard and specific matching.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_01_instance_wildcard( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_01: FindService with instance_id=0xFFFF returns specific instance.""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + + def _send() -> None: + send_find_service( + sock, + (dut_ip, SD_PORT), + service_id=SERVICE_ID, + instance_id=0xFFFF, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=5.0, + resend=_send, + ) + matching = [e for e in entries if e.service_id == SERVICE_ID] + assert matching, ( + "SOMEIPSRV_SD_MESSAGE_01: No OfferService received for instance_id=0xFFFF (wildcard) FindService" + ) + assert matching[0].instance_id == INSTANCE_ID, ( + f"SOMEIPSRV_SD_MESSAGE_01: OfferService instance_id " + f"0x{matching[0].instance_id:04x} != 0x{INSTANCE_ID:04x}" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_02_instance_specific( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_02: FindService with exact instance_id returns that instance.""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + + def _send() -> None: + send_find_service( + sock, + (dut_ip, SD_PORT), + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=5.0, + resend=_send, + ) + matching = [e for e in entries if e.service_id == SERVICE_ID and e.instance_id == INSTANCE_ID] + assert matching, ( + f"SOMEIPSRV_SD_MESSAGE_02: No OfferService received for specific instance_id=0x{INSTANCE_ID:04x}" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_03_major_version_wildcard( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_03: FindService with major_version=0xFF (any) returns service.""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + + def _send() -> None: + send_find_service( + sock, + (dut_ip, SD_PORT), + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + major_version=0xFF, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=5.0, + resend=_send, + ) + matching = [e for e in entries if e.service_id == SERVICE_ID] + assert matching, ( + "SOMEIPSRV_SD_MESSAGE_03: No OfferService received for major_version=0xFF (wildcard) FindService" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_04_major_version_specific( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_04: FindService with exact major_version returns service.""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + + def _send() -> None: + send_find_service( + sock, + (dut_ip, SD_PORT), + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + major_version=MAJOR_VERSION, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=5.0, + resend=_send, + ) + matching = [e for e in entries if e.service_id == SERVICE_ID and e.major_version == MAJOR_VERSION] + assert matching, ( + f"SOMEIPSRV_SD_MESSAGE_04: No OfferService received for specific major_version=0x{MAJOR_VERSION:02x}" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_05_minor_version_wildcard( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_05: FindService with minor_version=0xFFFFFFFF (any) returns service.""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + + def _send() -> None: + send_find_service( + sock, + (dut_ip, SD_PORT), + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + major_version=MAJOR_VERSION, + minor_version=0xFFFFFFFF, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=5.0, + resend=_send, + ) + matching = [e for e in entries if e.service_id == SERVICE_ID] + assert matching, ( + "SOMEIPSRV_SD_MESSAGE_05: No OfferService received for minor_version=0xFFFFFFFF (wildcard) FindService" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_06_minor_version_specific( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_06: FindService with exact minor_version=0x00000000 returns service.""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + + def _send() -> None: + send_find_service( + sock, + (dut_ip, SD_PORT), + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + major_version=MAJOR_VERSION, + minor_version=MINOR_VERSION, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=5.0, + resend=_send, + ) + matching = [e for e in entries if e.service_id == SERVICE_ID] + assert matching, ( + f"SOMEIPSRV_SD_MESSAGE_06: No OfferService received for specific minor_version=0x{MINOR_VERSION:08x}" + ) + finally: + sock.close() + + +class TestSDSubscribeNAck: + """SOMEIPSRV_SD_MESSAGE_14-19: SubscribeEventgroup NAck scenarios. + + A SubscribeEventgroup NAck is a SubscribeAck SD entry (type 0x07) with TTL=0. + """ + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_14_wrong_major_version( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_14: Subscribe with wrong major_version receives NAck (TTL=0).""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + + def _send() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + major_version=0xFF, # DUT expects 0x00 + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=3.0, + resend=_send, + ) + nacks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl == 0] + assert nacks, ( + "SOMEIPSRV_SD_MESSAGE_14: No SubscribeEventgroupNAck (TTL=0) received for wrong major_version=0xFF" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_15_wrong_service_id( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_15: Subscribe to unknown service_id receives NAck (TTL=0). + + Per SOMEIPSRV_SD_MESSAGE_15 the DUT shall respond with a SubscribeEventgroupNAck + (SubscribeAck entry with TTL=0). The DUT sends a NAck for unknown + service IDs; the response carries the same eventgroup_id as the request. + """ + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + + def _send() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + service_id=_UNKNOWNSERVICE_ID, + instance_id=INSTANCE_ID, + eventgroup_id=EVENTGROUP_UDP_UNICAST, + major_version=MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=3.0, + resend=_send, + ) + nacks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl == 0] + assert nacks, ( + f"SOMEIPSRV_SD_MESSAGE_15: No SubscribeEventgroupNAck (TTL=0) received " + f"for unknown service_id=0x{_UNKNOWNSERVICE_ID:04x}" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_16_wrong_instance_id( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_16: Subscribe to wrong instance_id receives NAck (TTL=0).""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + + def _send() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + instance_id=_UNKNOWNINSTANCE_ID, + eventgroup_id=EVENTGROUP_UDP_UNICAST, + major_version=MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=3.0, + resend=_send, + ) + nacks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl == 0] + assert nacks, ( + "SOMEIPSRV_SD_MESSAGE_16: No SubscribeEventgroupNAck (TTL=0) received " + f"for wrong instance_id=0x{_UNKNOWNINSTANCE_ID:04x}" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_17_unknown_eventgroup_id( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_17: Subscribe to unknown eventgroup_id receives NAck (TTL=0).""" + assert dut.poll() is None, "DUT is not running" + + # This reuses the same scenario as TC8-SD-007 with an explicit SD_MESSAGE_17 trace. + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + + def _send() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + eventgroup_id=_UNKNOWNEVENTGROUP_UDP_UNICAST, + major_version=MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_send, + ) + nacks = [e for e in entries if e.eventgroup_id == _UNKNOWNEVENTGROUP_UDP_UNICAST and e.ttl == 0] + assert nacks, ( + f"SOMEIPSRV_SD_MESSAGE_17: No SubscribeEventgroupNAck (TTL=0) received " + f"for unknown eventgroup_id=0x{_UNKNOWNEVENTGROUP_UDP_UNICAST:04x}" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_message_18_ttl_zero_stop_subscribe( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_18: StopSubscribeEventgroup (TTL=0) produces no SubscribeAck.""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + # Send a StopSubscribe directly (TTL=0) without a prior subscribe. + # Per spec, a TTL=0 subscribe is a StopSubscribe and must not generate an Ack. + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ttl=0, + ) + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=2.0, + ) + acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0] + assert not acks, ( + f"SOMEIPSRV_SD_MESSAGE_18: Unexpected SubscribeAck(TTL>0) received " + f"in response to StopSubscribeEventgroup (TTL=0). Got {len(acks)} entry/ies." + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + @pytest.mark.xfail( + strict=True, + reason=( + "vsomeip 3.6.1 limitation (SOMEIPSRV_SD_MESSAGE_19): DUT sends a positive " + "SubscribeEventgroupAck (TTL > 0) even when reserved bits are set in the " + "Subscribe entry; spec requires a NAck (TTL = 0). " + "See docs/architecture/tc8_conformance_testing.rst " + "see: Known SOME/IP Stack Limitations." + ), + ) + def test_sd_message_19_reserved_field_set( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_MESSAGE_19: Subscribe with reserved field set receives NAck or is ignored.""" + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + + def _send() -> None: + send_subscribe_eventgroup_reserved_set( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + reserved_value=0x0F, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=3.0, + resend=_send, + ) + # Accept either: a NAck (TTL=0) or no response at all (DUT silently ignores). + # A positive Ack (TTL>0) would indicate the DUT accepted the malformed entry. + acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0] + assert not acks, ( + "SOMEIPSRV_SD_MESSAGE_19: DUT sent a positive SubscribeAck (TTL>0) for a " + "SubscribeEventgroup entry with reserved bits set. Expected NAck or no response." + ) + finally: + sock.close() + + +class TestSDFindServiceTiming: + """SOMEIPSRV_SD_BEHAVIOR_03-04: FindService response timing constraints.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_cyclic_timing"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_behavior_03_unicast_findservice_timing( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_BEHAVIOR_03: Unicast FindService response arrives within request_response_delay * 1.5. + + The DUT is in its main phase (the module-scoped dut fixture has been + running for the full test session). Per spec the DUT must respond within + ``request_response_delay`` (500 ms); we allow 1.5x = 750 ms per implementation + tolerance. If the cyclic offer fires within that window it also satisfies the + test. We resend every 600 ms so the measurement window starts fresh each send. + """ + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + # Resend interval < request_response_delay so each send-to-response window + # is well-contained. Measure individually: send, wait up to 750 ms, + # record elapsed, assert. Repeat up to 3 times to avoid a cyclic offer + # coincidence that swamps the measurement. + _max_allowed_ms = _REQUEST_RESPONSE_DELAY_MS * 1.5 + + for _ in range(3): + t0 = time.monotonic() + send_find_service( + sock, + (dut_ip, SD_PORT), + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + major_version=MAJOR_VERSION, + ) + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=_max_allowed_ms / 1000.0, + max_results=1, + ) + t1 = time.monotonic() + matching = [e for e in entries if e.service_id == SERVICE_ID] + if matching: + elapsed_ms = (t1 - t0) * 1000.0 + assert elapsed_ms <= _max_allowed_ms, ( + f"SOMEIPSRV_SD_BEHAVIOR_03: OfferService arrived in {elapsed_ms:.0f} ms, " + f"exceeds request_response_delay * 1.5 = {_max_allowed_ms:.0f} ms " + f"(configured request_response_delay={_REQUEST_RESPONSE_DELAY_MS:.0f} ms)" + ) + return # test passes on first successful attempt + # No response in the tight window; the cyclic offer may have just fired. + # Drain any pending offers and retry. + capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=_CYCLIC_OFFER_DELAY_MS / 1000.0, + max_results=1, + ) + + pytest.fail( + "SOMEIPSRV_SD_BEHAVIOR_03: No OfferService received within " + f"request_response_delay * 1.5 = {_max_allowed_ms:.0f} ms " + "in 3 consecutive attempts" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_cyclic_timing"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_sd_behavior_04_multicast_findservice_timing( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """SOMEIPSRV_SD_BEHAVIOR_04: Multicast FindService (Unicast flag=0) triggers a multicast OfferService. + + A FindService sent to the SD multicast group shall be answered with a multicast + OfferService response. This test captures the response on the SD multicast + socket (not the unicast sender socket) and verifies it arrives within + ``cyclic_offer_delay * 1.5`` of the FindService transmission. + + On a loopback interface the multicast response arrives on the socket that has + joined the SD multicast group (``open_multicast_socket``). Both the sender + and listener sockets are opened so the FindService can be injected while the + multicast socket is actively listening. + """ + assert dut.poll() is None, "DUT is not running" + + send_sock = open_sender_socket(tester_ip) + mc_sock = open_multicast_socket(host_ip) + try: + t0 = time.monotonic() + send_find_service( + send_sock, + (SD_MULTICAST_ADDR, SD_PORT), + service_id=SERVICE_ID, + instance_id=0xFFFF, + major_version=0xFF, + ) + + # Listen on the multicast socket for the DUT's OfferService response. + # The response may be the next scheduled cyclic offer or a triggered offer. + # Allow up to cyclic_offer_delay * 1.5 for the first offer to arrive. + _max_allowed_secs = (_CYCLIC_OFFER_DELAY_MS * 1.5) / 1000.0 + deadline = time.monotonic() + _max_allowed_secs + matching = [] + while time.monotonic() < deadline and not matching: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + mc_sock.settimeout(min(remaining, 0.5)) + try: + data, _ = mc_sock.recvfrom(65535) + except socket.timeout: + continue + try: + someip_msg, _ = SOMEIPHeader.parse(data) + if someip_msg.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload) + for entry in sd_hdr.entries: + if entry.sd_type == SOMEIPSDEntryType.OfferService and entry.service_id == SERVICE_ID: + matching.append(entry) + except Exception: # noqa: BLE001 + continue + t1 = time.monotonic() + + assert matching, ( + "SOMEIPSRV_SD_BEHAVIOR_04: No multicast OfferService received after " + f"multicast FindService within {_max_allowed_secs:.1f} s " + f"(cyclic_offer_delay * 1.5 = {_CYCLIC_OFFER_DELAY_MS * 1.5:.0f} ms)" + ) + elapsed_ms = (t1 - t0) * 1000.0 + max_allowed_ms = _CYCLIC_OFFER_DELAY_MS * 1.5 + assert elapsed_ms <= max_allowed_ms, ( + f"SOMEIPSRV_SD_BEHAVIOR_04: OfferService arrived in {elapsed_ms:.0f} ms, " + f"exceeds cyclic_offer_delay * 1.5 = {max_allowed_ms:.0f} ms" + ) + finally: + send_sock.close() + mc_sock.close() + + +class TestSDSubscribeLifecycleAdvanced: + """ETS_088/092/098/107/120/122/155: Multi-subscribe and lifecycle edge cases.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_088_two_subscribes_same_session( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_088: Two SubscribeEventgroup entries (different eventgroups) both receive ACKs. + + The spec requires the DUT to process multiple subscribe entries even when + sent in rapid succession. We send two separate SD messages (one per + eventgroup) and assert that both receive a SubscribeAck with TTL > 0. + """ + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + + def _subscribe_eg1() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + def _subscribe_eg2() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_MULTICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _subscribe_eg1() + _subscribe_eg2() + + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=6.0, + resend=lambda: (_subscribe_eg1(), _subscribe_eg2()), # type: ignore[func-returns-value] + ) + acks_eg1 = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0] + acks_eg2 = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_MULTICAST and e.ttl > 0] + assert acks_eg1, f"ETS_088: No SubscribeAck received for eventgroup 0x{EVENTGROUP_UDP_UNICAST:04x}" + assert acks_eg2, f"ETS_088: No SubscribeAck received for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}" + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_092_ttl_zero_stop_subscribe_no_nack( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_092: SubscribeEventgroup with TTL=0 is treated as StopSubscribe; no NAck sent. + + Per PRS_SOMEIPSD_00386 and PRS_SOMEIPSD_00387 a subscribe entry with TTL=0 is + a StopSubscribeEventgroup. The DUT must not send a SubscribeAck (positive or + negative) in response. + """ + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ttl=0, + ) + # No resend; a StopSubscribe should never produce an ACK. + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=2.0, + ) + # Per spec a NAck (TTL=0 Ack) must NOT be sent for a stop-subscribe. + nacks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl == 0] + assert not nacks, ( + f"ETS_092: DUT sent NAck (TTL=0 SubscribeAck) in response to " + f"StopSubscribeEventgroup (TTL=0). Got {len(nacks)} NAck(s). " + "StopSubscribe must not trigger any acknowledgement." + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_098_subscribe_accepted_without_prior_rpc( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_098: SubscribeEventgroup is accepted without a prior method call. + + A server must not require the client to invoke a method before accepting + an eventgroup subscription. Verify a positive ACK (TTL > 0) is received. + """ + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + + def _send() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_send, + ) + acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0] + assert acks, ( + "ETS_098: No SubscribeAck (TTL>0) received without a prior method call. " + "Server must accept subscriptions unconditionally." + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_107_find_service_and_subscribe_processed_independently( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_107: DUT processes SD entries independently of arrival order. + + Send a FindService immediately followed by a SubscribeEventgroup in rapid + succession (two separate packets). The DUT must process both entries + independently regardless of their order in the stream. + + Verification strategy: + - FindService response (OfferService) is captured on the SD multicast + socket because the DUT (server) responds to incoming FindService messages on multicast. + - SubscribeAck arrives on the unicast sender socket. + Both arriving confirms the DUT processed both entries. + """ + assert dut.poll() is None, "DUT is not running" + + sd_sock = open_sender_socket(tester_ip) + mc_sock = open_multicast_socket(host_ip) + try: + sender_port = sd_sock.getsockname()[1] + + def _send_both() -> None: + send_find_service( + sd_sock, + (SD_MULTICAST_ADDR, SD_PORT), + service_id=SERVICE_ID, + instance_id=INSTANCE_ID, + ) + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send_both() + + acks = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=6.0, + resend=_send_both, + ) + acks_valid = [e for e in acks if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0] + + # Capture OfferService on the multicast socket (FindService response). + _max_secs = (_CYCLIC_OFFER_DELAY_MS * 1.5) / 1000.0 + deadline = time.monotonic() + _max_secs + offers: list = [] + while time.monotonic() < deadline and not offers: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + mc_sock.settimeout(min(remaining, 0.5)) + try: + data, _ = mc_sock.recvfrom(65535) + except socket.timeout: + continue + try: + someip_msg, _ = SOMEIPHeader.parse(data) + if someip_msg.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload) + for entry in sd_hdr.entries: + if entry.sd_type == SOMEIPSDEntryType.OfferService and entry.service_id == SERVICE_ID: + offers.append(entry) + except Exception: # noqa: BLE001 + continue + + assert offers, ( + "ETS_107: No OfferService received on multicast after FindService burst. " + "DUT must process FindService independently of co-arriving SD entries." + ) + assert acks_valid, ( + "ETS_107: No SubscribeAck received after SubscribeEventgroup burst. " + "DUT must process SubscribeEventgroup independently of co-arriving SD entries." + ) + finally: + sd_sock.close() + mc_sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_120_subscribe_endpoint_ip_matches_tester( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_120: SubscribeEventgroup with explicit subscriber IP receives OfferService ACK. + + The subscribe endpoint carries tester_ip as the subscriber address. + The DUT must send the ACK to that IP. Verifying the ACK arrives on the + tester socket confirms the DUT correctly used the subscriber_ip field. + """ + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + sender_port = sock.getsockname()[1] + + def _send() -> None: + send_subscribe_eventgroup( + sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=sender_port, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_send, + ) + acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0] + assert acks, ( + f"ETS_120: No SubscribeAck received at tester_ip={tester_ip} for subscribe with explicit subscriber_ip." + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_offer_format"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_122_sd_interface_version_is_one( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """ETS_122: SOME/IP-SD messages carry interface_version = 0x01. + + Per PRS_SOMEIPSD_00357 and PRS_SOMEIPSD_00360 the interface_version field + in the SOME/IP outer header of SD messages must be fixed at 0x01. + """ + assert dut.poll() is None, "DUT is not running" + + mc_sock = open_multicast_socket(host_ip) + try: + deadline = time.monotonic() + 6.0 + found: list = [] + while time.monotonic() < deadline and not found: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + mc_sock.settimeout(min(remaining, 1.0)) + try: + data, _ = mc_sock.recvfrom(65535) + except socket.timeout: + continue + try: + someip_msg, _ = SOMEIPHeader.parse(data) + if someip_msg.service_id != 0xFFFF: + continue + found.append(someip_msg) + except Exception: # noqa: BLE001 + continue + finally: + mc_sock.close() + + assert found, "ETS_122: No SOME/IP-SD messages captured within 6 s" + sd_msg = found[0] + assert sd_msg.interface_version == 1, ( + f"ETS_122: SOME/IP-SD interface_version = {sd_msg.interface_version}, " + "expected 0x01 per PRS_SOMEIPSD_00357, PRS_SOMEIPSD_00360" + ) + + @pytest.mark.skip(reason="Production stack sends no event notifications without mw::com ETS app (2026-08-11)") + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_155_resubscribe_after_stop( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_155: Re-subscribe after StopSubscribe receives a new ACK and resumes events. + + Lifecycle: Subscribe, ACK, StopSubscribe (TTL=0), Subscribe, ACK. + The DUT must accept the second subscription and resume event delivery. + """ + assert dut.poll() is None, "DUT is not running" + + sd_sock = open_sender_socket(tester_ip) + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port: int = notif_sock.getsockname()[1] + + try: + + def _subscribe() -> None: + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ) + + _subscribe() + acks1 = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_subscribe, + ) + assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks1), ( + "ETS_155: Prerequisite failed — no initial SubscribeAck received" + ) + + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ttl=0, + ) + time.sleep(0.5) # Allow DUT to process the StopSubscribe. + + _subscribe() + acks2 = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_subscribe, + ) + assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks2), ( + "ETS_155: No SubscribeAck received after re-subscribe following StopSubscribe" + ) + finally: + sd_sock.close() + notif_sock.close() + + @pytest.mark.skip(reason="Production stack sends no event notifications without mw::com ETS app (2026-08-11)") + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_ttl_expiry"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_095_subscribe_ttl_expires_no_events( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_095: No NOTIFICATION messages are received after subscription TTL expires. + + SOMEIP_ETS_095 (Sec. 5.1.6): After a subscription TTL elapses and is not + renewed the server must cease sending event notifications to the expired + subscriber. + """ + assert dut.poll() is None, "DUT is not running" + + # Use TTL=3 (same as TC8-SD-014); TTL=1 is too short to reliably + # process the expiry before the next notify cycle. + _TTL_SECS = 3 + + sd_sock = open_sender_socket(tester_ip) + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port: int = notif_sock.getsockname()[1] + + try: + + def _send_sub_ttl() -> None: + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ttl=_TTL_SECS, + ) + + _send_sub_ttl() + acks = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_send_sub_ttl, + ) + assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), ( + "ETS_095: Prerequisite failed — no SubscribeEventgroupAck received" + ) + + # Drain notifications sent during the active subscription period + # so they don't contaminate the post-expiry observation window. + pre_expiry = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0) + assert pre_expiry, "ETS_095: No notifications received before TTL expiry" + + # Wait for TTL to expire (TTL + 2 s margin); do NOT renew. + time.sleep(_TTL_SECS + 2) + + # Verify no notifications arrive after TTL expiry. + post_expiry = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=3.0) + assert not post_expiry, ( + f"ETS_095: {len(post_expiry)} NOTIFICATION(s) received after " + f"subscription TTL expired (TTL={_TTL_SECS} s + 2 s margin). " + "Server must cease sending events once the subscription TTL elapses." + ) + finally: + sd_sock.close() + notif_sock.close() + + +class TestSDFindServiceAdvanced: + """ETS_091/099/100/128/130: FindService and offer lifecycle advanced scenarios.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_offer_format"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_091_session_id_increments( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + ) -> None: + """ETS_091: Successive SD messages have monotonically incrementing session_id. + + Capture at least 2 OfferService packets from the DUT and verify that + each subsequent packet's session_id is greater than the previous one. + The DUT emits cyclic offers every 2000 ms; allow up to 8 s to capture 3. + """ + assert dut.poll() is None, "DUT is not running" + + mc_sock = open_multicast_socket(host_ip) + try: + session_ids: list[int] = [] + deadline = time.monotonic() + 8.0 + while time.monotonic() < deadline and len(session_ids) < 3: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + mc_sock.settimeout(min(remaining, 1.0)) + try: + data, _ = mc_sock.recvfrom(65535) + except socket.timeout: + continue + try: + someip_msg, _ = SOMEIPHeader.parse(data) + if someip_msg.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload) + has_offer = any( + e.sd_type == SOMEIPSDEntryType.OfferService and e.service_id == SERVICE_ID + for e in sd_hdr.entries + ) + if has_offer: + session_ids.append(someip_msg.session_id) + except Exception: # noqa: BLE001 + continue + finally: + mc_sock.close() + + assert len(session_ids) >= 2, ( + f"ETS_091: Only {len(session_ids)} OfferService packet(s) captured " + "(need at least 2 to check session_id monotonicity)" + ) + for i in range(len(session_ids) - 1): + current = session_ids[i] + nxt = session_ids[i + 1] + # Allow wrap-around at 0xFFFF (session_id is 16-bit). + is_increment = nxt == (current % 0xFFFF) + 1 + assert is_increment, f"ETS_091: session_id did not increment monotonically: {current:#06x} -> {nxt:#06x}" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_099_initial_event_sent_after_subscribe( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_099: DUT sends initial field value as event after SubscribeEventgroup ACK. + + The eventgroup 0x4455 carries a field event (update-cycle=2000ms in config). + After subscribing, the first notification must arrive within the cycle window. + """ + assert dut.poll() is None, "DUT is not running" + + sd_sock = open_sender_socket(tester_ip) + notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + notif_sock.bind((tester_ip, 0)) + notif_port: int = notif_sock.getsockname()[1] + + try: + + def _subscribe() -> None: + send_subscribe_eventgroup( + sd_sock, + (dut_ip, SD_PORT), + SERVICE_ID, + INSTANCE_ID, + EVENTGROUP_UDP_UNICAST, + MAJOR_VERSION, + subscriber_ip=tester_ip, + subscriber_port=notif_port, + ) + + _subscribe() + acks = capture_unicast_sd_entries( + sd_sock, + filter_types=(SOMEIPSDEntryType.SubscribeAck,), + timeout_secs=5.0, + resend=_subscribe, + ) + assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), ( + "ETS_099: Prerequisite failed — no SubscribeAck received" + ) + + # Expect at least one notification (field sends initial value + cyclic updates). + notifs = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=5.0) + assert notifs, ( + "ETS_099: No SOME/IP notifications received after subscribe. " + "DUT must send initial field value on subscription." + ) + finally: + sd_sock.close() + notif_sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_offer_format"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_100_no_findservice_emitted_by_server( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_100: DUT (server) must not emit FindService entries in main phase. + + A SOME/IP server that has offered its service must not transmit + FindService SD entries. Capture unicast SD entries on the tester + socket (which is bound at SD_PORT) for 5 s and assert none are + FindService type from the DUT. + """ + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.FindService,), + timeout_secs=5.0, + ) + assert not entries, ( + f"ETS_100: DUT emitted {len(entries)} FindService SD entry/ies. " + "A server in main phase must not send FindService." + ) + finally: + sock.close() + + @add_test_properties( + partially_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_101_stop_offer_ceases_client_events( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_101: DUT is server-only; client StopSubscribe reaction to server StopOfferService is not applicable.""" + pytest.skip("DUT is server-only; client StopSubscribe reaction to server StopOfferService is not applicable.") + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_128_multicast_findservice_version_wildcard( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_128: Multicast FindService with major=0xFF/minor=0xFFFFFFFF triggers OfferService. + + Sending a FindService with wildcard version to the SD multicast address + must cause the DUT to respond with an OfferService. + """ + assert dut.poll() is None, "DUT is not running" + + sock = open_sender_socket(tester_ip) + try: + + def _send() -> None: + send_find_service( + sock, + (SD_MULTICAST_ADDR, SD_PORT), + service_id=SERVICE_ID, + instance_id=0xFFFF, + major_version=0xFF, + minor_version=0xFFFFFFFF, + ) + + _send() + entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=5.0, + resend=_send, + ) + matching = [e for e in entries if e.service_id == SERVICE_ID] + assert matching, ( + f"ETS_128: No OfferService received for multicast FindService " + f"with wildcard version (major=0xFF, minor=0xFFFFFFFF)" + ) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__sd_find_response"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_130_multicast_findservice_unicast_flag_clear( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + tester_ip: str, + ) -> None: + """ETS_130: FindService with unicast_flag=0 (flags byte bit 6 clear) is processed. + + Per SOME/IP-SD spec, the unicast flag (bit 6 of the SD flags byte) signals + whether the sender supports unicast responses. With the flag clear (0) the + DUT may respond on multicast. At minimum it must process the FindService + and we capture any resulting offer on either socket. + """ + assert dut.poll() is None, "DUT is not running" + + from helpers.sd_malformed import build_raw_sd_packet, _find_service_entry_bytes # noqa: PLC0415 + + sock = open_sender_socket(tester_ip) + mc_sock = open_multicast_socket(host_ip) + try: + # SD flags: reboot bit (0x80) set, unicast bit (0x40) clear, resulting byte = 0x80. + entry_bytes = _find_service_entry_bytes( + service_id=SERVICE_ID, + instance_id=0xFFFF, + major_version=0xFF, + minor_version=0xFFFFFFFF, + ) + pkt = build_raw_sd_packet(flags=0x80, entries_bytes=entry_bytes) + sock.sendto(pkt, (SD_MULTICAST_ADDR, SD_PORT)) + + # Capture on multicast socket (DUT may respond on multicast when flag=0). + deadline = time.monotonic() + 5.0 + found: list = [] + while time.monotonic() < deadline and not found: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + mc_sock.settimeout(min(remaining, 0.5)) + try: + data, _ = mc_sock.recvfrom(65535) + except socket.timeout: + continue + try: + someip_msg, _ = SOMEIPHeader.parse(data) + if someip_msg.service_id != 0xFFFF: + continue + sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload) + for entry in sd_hdr.entries: + if entry.sd_type == SOMEIPSDEntryType.OfferService and entry.service_id == SERVICE_ID: + found.append(entry) + except Exception: # noqa: BLE001 + continue + + # Also drain any unicast OfferService on the sender socket. + if not found: + unicast_entries = capture_unicast_sd_entries( + sock, + filter_types=(SOMEIPSDEntryType.OfferService,), + timeout_secs=0.5, + ) + found.extend(e for e in unicast_entries if e.service_id == SERVICE_ID) + + assert found, ( + "ETS_130: No OfferService received after multicast FindService " + "with unicast_flag=0. DUT must still process the FindService entry." + ) + finally: + sock.close() + mc_sock.close() diff --git a/tests/tc8_conformance/test_someip_message_format.py b/tests/tc8_conformance/test_someip_message_format.py new file mode 100644 index 000000000..88e260202 --- /dev/null +++ b/tests/tc8_conformance/test_someip_message_format.py @@ -0,0 +1,1483 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""TC8 SOME/IP Message Format tests: TC8-MSG-001 through TC8-MSG-008. + +See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture. +""" + +import socket +import subprocess +import time +import pytest + +from attribute_plugin import add_test_properties + +from helpers.message_builder import ( + build_notification_as_request, + build_oversized_message, + build_request, + build_request_no_return, + build_request_with_return_code, + build_truncated_message, + build_wrong_protocol_version_request, +) +from helpers.someip_assertions import ( + assert_client_echo, + assert_offer_has_tcp_endpoint_option, + assert_return_code, + assert_session_echo, + assert_valid_response, +) +from helpers.constants import ( + DUT_RELIABLE_PORT, + DUT_UNRELIABLE_PORT, + EVENT_TEST_UINT8, + INSTANCE_ID, + METHOD_ECHO_UINT8, + SERVICE_ID, +) +from helpers.sd_helpers import capture_sd_offers +from helpers.tcp_helpers import ( + tcp_connect, + tcp_receive_n_responses, + tcp_receive_response, + tcp_send_concatenated, + tcp_send_request, +) +from helpers.udp_helpers import udp_receive_responses, udp_send_concatenated +from someip.header import SOMEIPHeader, SOMEIPMessageType, SOMEIPReturnCode + +pytestmark = pytest.mark.skip( + reason="Production stack does not handle SOME/IP methods (gatewayd: to_num_of_methods=0) without mw::com ETS app (2026-08-11)" +) + +SOMEIP_CONFIG: str = "tc8_someipd_service.json" + +_UNKNOWNMETHOD_ECHO_UINT8: int = 0xBEEF + + +def _wait_for_dut_offer(host_ip: str, timeout: float = 5.0) -> None: + """Block until the DUT sends at least one SD OfferService.""" + try: + capture_sd_offers(host_ip, min_count=1, timeout_secs=timeout) + except (TimeoutError, OSError): + pytest.skip("DUT did not offer service within timeout — multicast may be unavailable") + + +def _send_request_and_receive( + dut_ip: str, + request_bytes: bytes, + timeout_secs: float = 3.0, +) -> SOMEIPHeader: + """Send a SOME/IP request to the DUT and return the first response.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("", 0)) + sock.sendto(request_bytes, (dut_ip, DUT_UNRELIABLE_PORT)) + sock.settimeout(timeout_secs) + data, _ = sock.recvfrom(65535) + resp, _ = SOMEIPHeader.parse(data) + return resp + finally: + sock.close() + + +def _send_request_expect_no_response( + dut_ip: str, + request_bytes: bytes, + timeout_secs: float = 2.0, +) -> list: + """Send a SOME/IP request and verify no response arrives.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + collected = [] + try: + sock.bind(("", 0)) + sock.sendto(request_bytes, (dut_ip, DUT_UNRELIABLE_PORT)) + deadline = time.monotonic() + timeout_secs + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 0.5)) + try: + data, _ = sock.recvfrom(65535) + msg, _ = SOMEIPHeader.parse(data) + collected.append(msg) + except socket.timeout: + continue + except Exception: + continue + finally: + sock.close() + return collected + + +class TestSomeipResponseHeader: + """TC8-MSG-001/002/005/008: Response header validation.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_001_protocol_version( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-001: RESPONSE has protocol_version = 0x01.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0001) + resp = _send_request_and_receive(dut_ip, req) + + assert resp.protocol_version == 1, f"TC8-MSG-001: protocol_version = {resp.protocol_version}, expected 1 (0x01)" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_002_message_type_response( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-002: Response to REQUEST has message_type = RESPONSE (0x80).""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0002) + resp = _send_request_and_receive(dut_ip, req) + + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_002_no_response_for_request_no_return( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-002 (fire-and-forget): REQUEST_NO_RETURN must not produce a RESPONSE.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request_no_return(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0009) + responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0) + + assert not responses, ( + f"TC8-MSG-002: {len(responses)} response(s) received for REQUEST_NO_RETURN; " + "SOME/IP spec requires no response to fire-and-forget messages" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_005_session_id_echo( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-005: RESPONSE session_id matches REQUEST session_id.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + session_id = 0x1234 + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=session_id) + resp = _send_request_and_receive(dut_ip, req) + + assert_session_echo(resp, session_id) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_008_client_id_echo( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-008: RESPONSE client_id matches REQUEST client_id.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + client_id = 0x0011 + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=client_id, session_id=0x0003) + resp = _send_request_and_receive(dut_ip, req) + + assert_client_echo(resp, client_id) + + +class TestSomeipErrorCodes: + """TC8-MSG-003/004/006: Error return codes.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_error_codes"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_003_unknown_service( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-003: Request to unknown service gets E_UNKNOWN_SERVICE or no response. + + Note: The DUT may silently drop requests for services it does not offer. + TC8 allows either E_UNKNOWN_SERVICE (0x02) or no response at all; this + test accepts both behaviors. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + unknown_service = 0xBEEF + req = build_request(unknown_service, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0004) + responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0) + + if responses: + assert_return_code(responses[0], SOMEIPReturnCode.E_UNKNOWN_SERVICE) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_error_codes"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_004_unknown_method( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-004: Request with unknown method_id gets E_UNKNOWN_METHOD.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, _UNKNOWNMETHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0005) + resp = _send_request_and_receive(dut_ip, req) + + assert_return_code(resp, SOMEIPReturnCode.E_UNKNOWN_METHOD) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_error_codes"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_006_wrong_interface_version( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-006: Request with wrong interface version must get E_WRONG_INTERFACE_VERSION. + + Per SOME/IP spec PRS_SOMEIP_00191, a server receiving a REQUEST whose + interface_version does not match the offered service version must respond + with E_WRONG_INTERFACE_VERSION (0x08). vsomeip 3.6.1 returns the conformant + E_WRONG_INTERFACE_VERSION for such requests. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0010, + session_id=0x0006, + interface_version=0xFF, + ) + resp = _send_request_and_receive(dut_ip, req) + + assert_return_code(resp, SOMEIPReturnCode.E_WRONG_INTERFACE_VERSION) + + +class TestMalformedMessages: + """TC8-MSG-007: DUT must survive malformed SOME/IP messages without crashing.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_malformed"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_007_truncated_message_no_crash( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-007: Truncated message (< 8 bytes) does not crash the DUT.""" + assert dut.poll() is None, "DUT is not running" + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.sendto(build_truncated_message(), (dut_ip, DUT_UNRELIABLE_PORT)) + finally: + sock.close() + + time.sleep(0.3) + assert dut.poll() is None, "TC8-MSG-007: DUT crashed after receiving a truncated SOME/IP message" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_malformed"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_007_wrong_protocol_version_no_crash( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-007: Message with protocol_version=0xFF does not crash the DUT.""" + assert dut.poll() is None, "DUT is not running" + + malformed = build_wrong_protocol_version_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0010, + session_id=0x0007, + ) + # DUT may respond with E_MALFORMED_MESSAGE or drop silently; both are valid. + _send_request_expect_no_response(dut_ip, malformed, timeout_secs=1.0) + + assert dut.poll() is None, "TC8-MSG-007: DUT crashed after receiving a wrong-protocol-version message" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_malformed"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_msg_007_oversized_length_field_no_crash( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """TC8-MSG-007: Message whose length field claims 0x7FF3 bytes does not crash the DUT.""" + assert dut.poll() is None, "DUT is not running" + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.sendto( + build_oversized_message(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0008), + (dut_ip, DUT_UNRELIABLE_PORT), + ) + finally: + sock.close() + + time.sleep(0.3) + assert dut.poll() is None, "TC8-MSG-007: DUT crashed after receiving a message with oversized length field" + + +class TestSomeipTcpTransport: + """TCP transport binding tests (SOMEIPSRV_RPC_01/02, OPTIONS_15). + + These tests verify that someipd correctly handles SOME/IP request/response + over TCP (reliable transport binding). The DUT is configured with both + unreliable (UDP ``DUT_UNRELIABLE_PORT``) and reliable (TCP ``DUT_RELIABLE_PORT``) ports. + """ + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__tcp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_rpc_01_tcp_request_response( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_01: REQUEST over TCP receives a valid RESPONSE. + + Verifies that the DUT accepts a TCP connection and correctly responds + to a SOME/IP REQUEST with a RESPONSE having message_type=0x80. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT) + try: + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0050) + tcp_send_request(sock, req) + resp = tcp_receive_response(sock) + + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__tcp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_rpc_01_tcp_session_id_echo( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_01: TCP RESPONSE echoes the REQUEST session_id.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + session_id = 0x5678 + sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT) + try: + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=session_id) + tcp_send_request(sock, req) + resp = tcp_receive_response(sock) + + assert_session_echo(resp, session_id) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__tcp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_rpc_01_tcp_client_id_echo( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_01: TCP RESPONSE echoes the REQUEST client_id.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + client_id = 0x0015 + sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT) + try: + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=client_id, session_id=0x0051) + tcp_send_request(sock, req) + resp = tcp_receive_response(sock) + + assert_client_echo(resp, client_id) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__tcp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_rpc_02_tcp_multiple_methods_single_connection( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_02: Multiple SOME/IP methods on a single TCP connection. + + Verifies that the DUT handles multiple sequential request/response + exchanges on the same TCP connection without requiring reconnection. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT) + try: + req1 = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0060) + tcp_send_request(sock, req1) + resp1 = tcp_receive_response(sock) + assert_valid_response(resp1, SERVICE_ID, METHOD_ECHO_UINT8) + assert_session_echo(resp1, 0x0060) + + # Second request on the SAME connection + req2 = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0061) + tcp_send_request(sock, req2) + resp2 = tcp_receive_response(sock) + assert_valid_response(resp2, SERVICE_ID, METHOD_ECHO_UINT8) + assert_session_echo(resp2, 0x0061) + finally: + sock.close() + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__tcp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_sd_options_15_tcp_endpoint_advertised( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_OPTIONS_15: SD OfferService includes a TCP endpoint option. + + When the DUT is configured with a reliable (TCP) port, the SD + OfferService message must include an IPv4EndpointOption with + L4Proto=TCP and the correct port. + """ + assert dut.poll() is None, "DUT is not running" + + offers = capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0) + assert offers, "No SD OfferService received" + + assert_offer_has_tcp_endpoint_option(offers[0], dut_ip, DUT_RELIABLE_PORT) + + +class TestTcpUnalignedMessages: + """SOMEIP_ETS_068: Multiple unaligned SOME/IP messages in one TCP segment. + + PRS_SOMEIP_00142, PRS_SOMEIP_00569: A SOME/IP TCP receiver must parse + the byte stream using the length field as the sole framing indicator. + No 4-byte alignment between consecutive messages is guaranteed or required. + """ + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__tcp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_ets_068_unaligned_someip_messages_over_tcp( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIP_ETS_068: Three REQUEST messages in one TCP segment; third is unaligned. + + Payload layout in the concatenated TCP write: + msg1 (session 0x0071): 0-byte payload -> 16 bytes (offset 0, aligned) + msg2 (session 0x0072): 2-byte payload -> 18 bytes (offset 16, aligned) + msg3 (session 0x0073): 0-byte payload -> 16 bytes (offset 34, NOT 4-byte aligned) + + All three must receive individual RESPONSE messages from the DUT. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + msg1 = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0010, + session_id=0x0071, + payload=b"", + ) + msg2 = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0010, + session_id=0x0072, + payload=b"\xaa\xbb", + ) + msg3 = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0010, + session_id=0x0073, + payload=b"", + ) + + sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT) + try: + tcp_send_concatenated(sock, [msg1, msg2, msg3]) + responses = tcp_receive_n_responses(sock, count=3, timeout_secs=5.0) + finally: + sock.close() + + assert len(responses) == 3, f"SOMEIP_ETS_068: expected 3 RESPONSE messages, got {len(responses)}" + for resp in responses: + assert resp.service_id == SERVICE_ID + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + + session_ids = {resp.session_id for resp in responses} + assert session_ids == {0x0071, 0x0072, 0x0073}, ( + f"SOMEIP_ETS_068: unexpected session IDs in responses: " + f"{{{', '.join(f'0x{s:04x}' for s in sorted(session_ids))}}}" + ) + + +class TestUdpUnalignedMessages: + """SOMEIP_ETS_069: Multiple unaligned SOME/IP messages in one UDP datagram. + + PRS_SOMEIP_00142, PRS_SOMEIP_00569: A SOME/IP UDP receiver must parse + each SOME/IP message within the datagram using the length field as the + sole framing indicator. No 4-byte alignment between consecutive messages + is guaranteed or required. + """ + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__udp_transport"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_tc8_ets_069_unaligned_someip_messages_over_udp( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIP_ETS_069: Three REQUEST messages in one UDP datagram; third is unaligned. + + Payload layout in the concatenated UDP write: + msg1 (session 0x0081): 0-byte payload -> 16 bytes (offset 0, aligned) + msg2 (session 0x0082): 2-byte payload -> 18 bytes (offset 16, aligned) + msg3 (session 0x0083): 0-byte payload -> 16 bytes (offset 34, NOT 4-byte aligned) + + All three must receive individual RESPONSE messages from the DUT. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + msg1 = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0010, + session_id=0x0081, + payload=b"", + ) + msg2 = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0010, + session_id=0x0082, + payload=b"\xaa\xbb", + ) + msg3 = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0010, + session_id=0x0083, + payload=b"", + ) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("", 0)) + try: + udp_send_concatenated(sock, (dut_ip, DUT_UNRELIABLE_PORT), [msg1, msg2, msg3]) + responses = udp_receive_responses(sock, count=3, timeout_secs=5.0) + finally: + sock.close() + + assert len(responses) == 3, f"SOMEIP_ETS_069: expected 3 RESPONSE messages, got {len(responses)}" + for resp in responses: + assert resp.service_id == SERVICE_ID + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + + session_ids = {resp.session_id for resp in responses} + assert session_ids == {0x0081, 0x0082, 0x0083}, ( + f"SOMEIP_ETS_069: unexpected session IDs in responses: " + f"{{{', '.join(f'0x{s:04x}' for s in sorted(session_ids))}}}" + ) + + +_UNKNOWNSERVICE_ID: int = 0xBEEF + + +def _send_request_and_receive_with_addr( + dut_ip: str, + request_bytes: bytes, + timeout_secs: float = 3.0, +) -> tuple[SOMEIPHeader, tuple[str, int]]: + """Send a SOME/IP request to the DUT and return (response, (src_ip, src_port)).""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("", 0)) + sock.sendto(request_bytes, (dut_ip, DUT_UNRELIABLE_PORT)) + sock.settimeout(timeout_secs) + data, addr = sock.recvfrom(65535) + resp, _ = SOMEIPHeader.parse(data) + return resp, addr + finally: + sock.close() + + +class TestSomeipBasicIdentifiers: + """SOMEIPSRV_BASIC_01-03: Service identification primitives.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_basic_01_correct_service_id_gets_response( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_BASIC_01: REQUEST to known service/method receives RESPONSE with E_OK.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0020, session_id=0x0001) + resp = _send_request_and_receive(dut_ip, req) + + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + assert_return_code(resp, SOMEIPReturnCode.E_OK) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_error_codes"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_basic_02_unknown_service_id_no_response_or_error( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_BASIC_02: REQUEST to unknown service_id must return E_UNKNOWN_SERVICE. + + Per SOMEIPSRV_BASIC_02, a DUT that receives a REQUEST for an unknown service_id + MUST reply with E_UNKNOWN_SERVICE (return_code 0x02). + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(_UNKNOWNSERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0020, session_id=0x0002) + responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0) + + assert responses, ( + "SOMEIPSRV_BASIC_02: No response received for unknown service_id " + f"0x{_UNKNOWNSERVICE_ID:04x}; DUT must reply with E_UNKNOWN_SERVICE" + ) + assert_return_code(responses[0], SOMEIPReturnCode.E_UNKNOWN_SERVICE) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + @pytest.mark.xfail( + strict=False, + reason=( + "vsomeip 3.6.1 sends E_UNKNOWN_METHOD RESPONSE to REQUEST with event " + "method_id (bit 15 set); stack limitation" + ), + ) + def test_basic_03_event_method_id_no_response( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_BASIC_03: REQUEST with event method_id (bit 15 set) must not produce a RESPONSE. + + Per SOMEIPSRV_BASIC_03, when the DUT receives a message with method_id bit 15 = 1 + (event notification ID range), it MUST NOT send a RESPONSE (message_type 0x80). + ERROR messages are not prohibited by the spec. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, EVENT_TEST_UINT8, client_id=0x0020, session_id=0x0003) + responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0) + + response_msgs = [r for r in responses if r.message_type == SOMEIPMessageType.RESPONSE] + assert not response_msgs, ( + f"SOMEIPSRV_BASIC_03: DUT sent {len(response_msgs)} RESPONSE message(s) " + "(message_type=0x80) to a REQUEST with event method_id — " + "DUT must not send a RESPONSE for event method IDs" + ) + + +class TestSomeipResponseFields: + """SOMEIPSRV_ONWIRE_01/02/04/06/11 + RPC_18/20: Verify RESPONSE field values.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_onwire_01_response_source_address( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ONWIRE_01: RESPONSE originates from the DUT's offering address and port.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0021, session_id=0x0010) + resp, addr = _send_request_and_receive_with_addr(dut_ip, req) + + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + assert addr[0] == dut_ip, f"SOMEIPSRV_ONWIRE_01: RESPONSE source IP mismatch: got {addr[0]}, expected {dut_ip}" + assert addr[1] == DUT_UNRELIABLE_PORT, ( + f"SOMEIPSRV_ONWIRE_01: RESPONSE source port mismatch: got {addr[1]}, expected {DUT_UNRELIABLE_PORT}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_onwire_02_method_id_msb_zero_in_response( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ONWIRE_02: RESPONSE method_id has bit 15 = 0 (not an event).""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0021, session_id=0x0011) + resp = _send_request_and_receive(dut_ip, req) + + assert (resp.method_id & 0x8000) == 0, ( + f"SOMEIPSRV_ONWIRE_02: RESPONSE method_id bit 15 is set " + f"(0x{resp.method_id:04x}); responses must not carry event method IDs" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_onwire_04_request_id_reuse( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ONWIRE_04: Each RESPONSE echoes the corresponding request_id (client:session). + + Sends two REQUESTs with the same client_id/session_id pair and verifies + both RESPONSEs echo the pair correctly. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + client_id = 0x0001 + session_id = 0x0042 + + for _ in range(2): + req = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=client_id, + session_id=session_id, + ) + resp = _send_request_and_receive(dut_ip, req) + + assert resp.client_id == client_id, ( + f"SOMEIPSRV_ONWIRE_04: client_id mismatch: got 0x{resp.client_id:04x}, expected 0x{client_id:04x}" + ) + assert resp.session_id == session_id, ( + f"SOMEIPSRV_ONWIRE_04: session_id mismatch: got 0x{resp.session_id:04x}, expected 0x{session_id:04x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_onwire_06_interface_version_echoed( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ONWIRE_06: RESPONSE interface_version matches the REQUEST interface_version. + + The interface_version byte (byte 13) in the RESPONSE must be copied + from the inbound REQUEST, not from the local service configuration. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + iface_ver = 0x05 + req = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0021, + session_id=0x0012, + interface_version=iface_ver, + ) + resp = _send_request_and_receive(dut_ip, req) + + assert resp.interface_version == iface_ver, ( + f"SOMEIPSRV_ONWIRE_06: interface_version mismatch: " + f"got 0x{resp.interface_version:02x}, expected 0x{iface_ver:02x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_onwire_11_normal_response_return_code_ok( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ONWIRE_11: A normal RESPONSE to a valid REQUEST has return_code E_OK.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0021, session_id=0x0013) + resp = _send_request_and_receive(dut_ip, req) + + assert_return_code(resp, SOMEIPReturnCode.E_OK) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_18_message_id_echoed( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_18: RESPONSE message_id (service_id:method_id) echoes REQUEST values.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0021, session_id=0x0014) + resp = _send_request_and_receive(dut_ip, req) + + assert resp.service_id == SERVICE_ID, ( + f"SOMEIPSRV_RPC_18: service_id mismatch in RESPONSE: " + f"got 0x{resp.service_id:04x}, expected 0x{SERVICE_ID:04x}" + ) + assert resp.method_id == METHOD_ECHO_UINT8, ( + f"SOMEIPSRV_RPC_18: method_id mismatch in RESPONSE: " + f"got 0x{resp.method_id:04x}, expected 0x{METHOD_ECHO_UINT8:04x}" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_19_session_id_echoed_in_error( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_19: Error response session_id must equal the request session_id. + + When the DUT returns an error response (e.g. E_UNKNOWN_METHOD for an + unknown method_id), the session_id in the response header MUST equal + the session_id from the request per PRS_SOMEIP_00137 (request_id echo). + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + session_id = 0x0016 + req = build_request( + SERVICE_ID, + _UNKNOWNMETHOD_ECHO_UINT8, + client_id=0x0021, + session_id=session_id, + ) + resp = _send_request_and_receive(dut_ip, req) + + assert_session_echo(resp, session_id) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_20_interface_version_copied_from_request( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_20: RESPONSE interface_version is copied from the REQUEST (variant). + + Sends with interface_version=0x03 (different from ONWIRE_06's 0x05) to + confirm the echo is dynamic and not hardcoded. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + iface_ver = 0x03 + req = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0021, + session_id=0x0015, + interface_version=iface_ver, + ) + resp = _send_request_and_receive(dut_ip, req) + + assert resp.interface_version == iface_ver, ( + f"SOMEIPSRV_RPC_20: interface_version mismatch: " + f"got 0x{resp.interface_version:02x}, expected 0x{iface_ver:02x}" + ) + + +class TestSomeipFireAndForgetAndErrors: + """SOMEIPSRV_RPC_05-10 + ETS_004/054/059/061/075: Fire-and-forget and robustness.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_05_fire_and_forget_no_error( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_05: REQUEST_NO_RETURN produces neither a RESPONSE nor an ERROR.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request_no_return(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0020) + responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0) + + error_msgs = [ + r + for r in responses + if r.message_type + in ( + SOMEIPMessageType.ERROR, + SOMEIPMessageType.ERROR_ACK, + ) + ] + assert not responses, ( + f"SOMEIPSRV_RPC_05: {len(responses)} message(s) received for " + "REQUEST_NO_RETURN; no RESPONSE or ERROR expected" + ) + assert not error_msgs, ( + f"SOMEIPSRV_RPC_05: {len(error_msgs)} ERROR message(s) received for " + "REQUEST_NO_RETURN; SOME/IP spec prohibits error replies to fire-and-forget" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_06_return_code_upper_bits_zero( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_06: RESPONSE return_code has bits 7-5 = 0 (only bits 4-0 are used).""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0021) + resp = _send_request_and_receive(dut_ip, req) + + rc_value: int = resp.return_code.value if hasattr(resp.return_code, "value") else int(resp.return_code) + assert (rc_value & 0xE0) == 0, ( + f"SOMEIPSRV_RPC_06: RESPONSE return_code upper bits are not zero: " + f"0x{rc_value:02x} (bits 7-5 = 0x{(rc_value >> 5) & 0x07:01x})" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_07_request_with_return_code_bits_set( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_07: DUT processes REQUEST normally even if return_code field is non-zero. + + The SOME/IP spec says servers must ignore return_code in inbound REQUESTs. + Sending return_code=0x20 (non-zero) must still yield a valid RESPONSE. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request_with_return_code( + SERVICE_ID, + METHOD_ECHO_UINT8, + return_code=0x20, + client_id=0x0030, + session_id=0x0022, + ) + resp = _send_request_and_receive(dut_ip, req) + + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + @pytest.mark.xfail( + strict=True, + reason=( + "vsomeip 3.6.1 limitation (SOMEIPSRV_RPC_08): DUT replies to REQUEST " + "messages carrying a non-zero return code, violating the spec. " + "See docs/architecture/tc8_conformance_testing.rst " + "see: Known SOME/IP Stack Limitations." + ), + ) + def test_rpc_08_request_with_error_return_code_no_reply( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_08: DUT does not reply to REQUEST with return_code = E_NOT_OK (0x01). + + Per SOME/IP spec a server must not process a REQUEST whose return_code + field is set to an error value by the client. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request_with_return_code( + SERVICE_ID, + METHOD_ECHO_UINT8, + return_code=0x01, + client_id=0x0030, + session_id=0x0023, + ) + responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0) + + assert not responses, ( + f"SOMEIPSRV_RPC_08: {len(responses)} response(s) received; " + "DUT must not reply to REQUEST with non-zero return_code" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_error_codes"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_09_error_response_no_payload( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_09: ERROR response to unknown method has length = 8 (no payload). + + The SOME/IP length field counts from byte 8 onward. An error RESPONSE + with no payload has length = 8 (the fixed-header tail: client_id, session_id, + protocol_version, interface_version, message_type, return_code). + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, _UNKNOWNMETHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0024) + resp = _send_request_and_receive(dut_ip, req) + + raw_resp = resp.build() + length_field = int.from_bytes(raw_resp[4:8], "big") + assert length_field == 8, ( + f"SOMEIPSRV_RPC_09: ERROR response length field = {length_field}, " + "expected 8 (no payload for error responses)" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_malformed"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_rpc_10_fire_and_forget_reserved_type_no_error( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_RPC_10: REQUEST_NO_RETURN with patched reserved message_type byte is dropped. + + Patches byte 14 to 0x04 (reserved message type) then sends as fire-and-forget. + The DUT must not send an ERROR in response. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + raw = build_request_no_return(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0025) + # Patch byte 14 (message_type) to reserved value 0x04. + patched = raw[:14] + b"\x04" + raw[15:] + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + responses = [] + try: + sock.bind(("", 0)) + sock.sendto(patched, (dut_ip, DUT_UNRELIABLE_PORT)) + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(remaining, 0.5)) + try: + data, _ = sock.recvfrom(65535) + msg, _ = SOMEIPHeader.parse(data) + responses.append(msg) + except socket.timeout: + continue + except Exception: + continue + finally: + sock.close() + + assert not responses, ( + f"SOMEIPSRV_RPC_10: {len(responses)} message(s) received after sending " + "fire-and-forget with reserved message_type; DUT must not send ERROR" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_004_burst_10_sequential_requests( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ETS_004: 10 sequential REQUESTs each produce a correctly echoed RESPONSE. + + Sends 10 REQUESTs with incrementing session IDs and verifies each + RESPONSE carries the matching session_id. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + base_session_id = 0x0100 + for i in range(10): + session_id = base_session_id + i + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=session_id) + resp = _send_request_and_receive(dut_ip, req, timeout_secs=3.0) + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + assert_session_echo(resp, session_id) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_054_empty_payload_request( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ETS_054: REQUEST with empty payload (length=8) gets E_OK RESPONSE.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request( + SERVICE_ID, + METHOD_ECHO_UINT8, + client_id=0x0030, + session_id=0x0030, + payload=b"", + ) + resp = _send_request_and_receive(dut_ip, req) + + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + assert_return_code(resp, SOMEIPReturnCode.E_OK) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_059_fire_and_forget_wrong_service_no_error( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ETS_059: REQUEST_NO_RETURN to non-existent service gets no ERROR reply.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request_no_return(_UNKNOWNSERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0031) + responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0) + + assert not responses, ( + f"SOMEIPSRV_ETS_059: {len(responses)} message(s) received after sending " + "REQUEST_NO_RETURN to unknown service; DUT must not send ERROR" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_061_two_sequential_requests( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ETS_061: Two sequential REQUESTs each receive RESPONSE with correct session_id.""" + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + for session_id in (0x0040, 0x0041): + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=session_id) + resp = _send_request_and_receive(dut_ip, req) + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8) + assert_session_echo(resp, session_id) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_malformed"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_075_notification_as_request_ignored( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """SOMEIPSRV_ETS_075: DUT ignores a message with message_type=NOTIFICATION (0x02). + + A NOTIFICATION message type in the client-to-server direction is invalid + per SOME/IP spec. The server must not send a RESPONSE. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + msg = build_notification_as_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0050) + responses = _send_request_expect_no_response(dut_ip, msg, timeout_secs=2.0) + + assert not responses, ( + f"SOMEIPSRV_ETS_075: {len(responses)} response(s) received for a " + "NOTIFICATION message sent to the server; DUT must not reply" + ) + + +class TestSomeipByteOrder: + """ETS_005/058: Big-endian byte order in SOME/IP responses and oversized length robustness.""" + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_resp_header"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_005_response_uses_big_endian_byte_order( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_005: SOME/IP RESPONSE is encoded in big-endian byte order. + + Verifies PRS_SOMEIP_00087: all SOME/IP header fields are big-endian. + Sends a REQUEST and compares the raw bytes of the RESPONSE with the + parsed field values to confirm big-endian encoding. + """ + import struct as _struct + + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0022, session_id=0x0090) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + raw_data: bytes = b"" + parsed: SOMEIPHeader + try: + sock.bind(("", 0)) + sock.sendto(req, (dut_ip, DUT_UNRELIABLE_PORT)) + sock.settimeout(3.0) + raw_data, _ = sock.recvfrom(65535) + parsed, _ = SOMEIPHeader.parse(raw_data) + finally: + sock.close() + + assert len(raw_data) >= 16, f"ETS_005: RESPONSE too short to be a valid SOME/IP header ({len(raw_data)} bytes)" + + # service_id is bytes 0-1 (big-endian uint16) + expected_service_id_msb = (parsed.service_id >> 8) & 0xFF + assert raw_data[0] == expected_service_id_msb, ( + f"ETS_005: service_id MSB byte mismatch — raw byte[0] = 0x{raw_data[0]:02x}, " + f"expected 0x{expected_service_id_msb:02x} (service_id = 0x{parsed.service_id:04x}). " + "SOME/IP header must use big-endian byte order (PRS_SOMEIP_00087)." + ) + + # length field is bytes 4-7 (big-endian uint32) + raw_length = _struct.unpack_from(">I", raw_data, 4)[0] + assert raw_length == len(raw_data) - 8, ( + f"ETS_005: length field = {raw_length}, actual payload+header-tail = " + f"{len(raw_data) - 8}; length field must be big-endian encoded" + ) + + @add_test_properties( + fully_verifies=["comp_req__tc8_conformance__msg_malformed"], + test_type="requirements-based", + derivation_technique="requirements-analysis", + ) + def test_ets_058_oversized_length_field_no_crash( + self, + dut: subprocess.Popen[bytes], + host_ip: str, + dut_ip: str, + ) -> None: + """ETS_058: SOME/IP message with oversized length field (0xFFFFFFF0) does not crash DUT. + + Patches bytes 4-7 of a valid REQUEST to claim a payload length of + 0xFFFFFFF0 (far exceeding the actual UDP datagram size). The DUT must + discard the malformed message and remain operational, confirmed by a + successful response to a subsequent valid REQUEST. + """ + assert dut.poll() is None, "DUT is not running" + _wait_for_dut_offer(host_ip) + + valid_req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0022, session_id=0x0091) + oversized = bytearray(valid_req) + oversized[4] = 0xFF + oversized[5] = 0xFF + oversized[6] = 0xFF + oversized[7] = 0xF0 + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("", 0)) + sock.sendto(bytes(oversized), (dut_ip, DUT_UNRELIABLE_PORT)) + finally: + sock.close() + + time.sleep(0.3) + assert dut.poll() is None, ( + "ETS_058: DUT crashed after receiving a message with oversized length field (0xFFFFFFF0)" + ) + + follow_up = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0022, session_id=0x0092) + resp = _send_request_and_receive(dut_ip, follow_up) + assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)