diff --git a/bazel_common/score_modules_target_sw.MODULE.bazel b/bazel_common/score_modules_target_sw.MODULE.bazel index 86ba1857320..8804ce3e2bd 100644 --- a/bazel_common/score_modules_target_sw.MODULE.bazel +++ b/bazel_common/score_modules_target_sw.MODULE.bazel @@ -31,6 +31,7 @@ git_override( patch_strip = 1, patches = [ "//patches/communication:001-expose-comm-examples.patch", + "//patches/communication:002-expose-com-api-gen.patch", "//patches/communication:003-module-deps-visibility.patch", ], remote = "https://github.com/eclipse-score/communication.git", diff --git a/feature_integration_tests/itf/BUILD b/feature_integration_tests/itf/BUILD index c1834ddc4b9..ca535e80037 100644 --- a/feature_integration_tests/itf/BUILD +++ b/feature_integration_tests/itf/BUILD @@ -33,6 +33,9 @@ config_setting( filegroup( name = "all_tests", srcs = [ + "comm_helpers.py", + "test_communication.py", + "test_properties.py", "test_remote_logging.py", "test_showcases.py", "test_ssh.py", @@ -44,7 +47,16 @@ py_itf_test( srcs = [ ":all_tests", ], - args = select( + # "-p attribute_plugin" activates score_tooling's requirement-traceability + # pytest plugin (see the "deps" attribute below) so that + # @add_test_properties(...) (feature_integration_tests/itf/test_properties.py) + # writes PartiallyVerifies/FullyVerifies properties into the JUnit XML + # report, matching the convention already used by + # feature_integration_tests/test_cases (persistency FIT). + args = [ + "-p", + "attribute_plugin", + ] + select( { ":config_linux_x86_64": [ "--dlt-config=$(location //feature_integration_tests/configs:dlt_config_x86_64.json)", @@ -86,6 +98,9 @@ py_itf_test( tags = [ "manual", ], + deps = [ + "@score_tooling//python_basics/score_pytest:attribute_plugin", + ], ) # Backward compatible aliases for CI jobs - these should be removed once the CI jobs are updated to use the new target names diff --git a/feature_integration_tests/itf/comm_helpers.py b/feature_integration_tests/itf/comm_helpers.py new file mode 100644 index 00000000000..7dffafc83a2 --- /dev/null +++ b/feature_integration_tests/itf/comm_helpers.py @@ -0,0 +1,409 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +""" +Helpers for the communication (LoLa / mw::com) ITF tests. + +Upstream replaced the two-process ``ipc_bridge`` example with the +single-process ``com-api-example`` demo, which cannot act as separate +producer/consumer processes. Per the maintainer's guidance we therefore ship +our own sender/receiver scenario (``showcases/standalone/comm_fit``): two +distinct binaries that offer and subscribe to the same ``VehicleInterface`` +service instance over real shared memory. + +The checks here are derived from observable data the receiver produces, not +from strings the app echoes about its own configuration. ``fit_sender`` +publishes ``left_tire`` samples whose payload carries a monotonically +increasing sequence number; ``fit_receiver`` prints one ``FIT_RECV seq=`` +line per consumed sample. That gives independent oracles for delivery, +ordering and value-integrity that a middleware regression would break. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# Deployment layout of the communication FIT binaries inside the reference +# image (see showcases/standalone/comm_fit/BUILD and showcases/BUILD). The +# deployment manifest is the one deployed by the com-api-example bundle +# (showcases/standalone/BUILD), which fit_sender/fit_receiver reuse. +FIT_SENDER_BIN = "/showcases/bin/fit_sender" +FIT_RECEIVER_BIN = "/showcases/bin/fit_receiver" +COMM_CWD = "/showcases/data/comm" +DEFAULT_MANIFEST = "/showcases/data/comm/etc/mw_com_config.json" + +# Oracle strings emitted by fit_sender / fit_receiver. +RECV_SAMPLE_RE = re.compile(r"FIT_RECV seq=(\d+)") +FOUND_SERVICE_MARKER = "FIT_FOUND_SERVICE" +RECV_DONE_MARKER = "FIT_RECV_DONE" + + +@dataclass +class CommResult: + """Captured output of one producer/consumer exchange.""" + + recv_exit_code: int + recv_stdout: str + send_stdout: str + + # --- oracles --------------------------------------------------------- + @property + def received_samples(self) -> list[int]: + """Ordered list of the producer sequence numbers the consumer received.""" + return [int(m.group(1)) for m in RECV_SAMPLE_RE.finditer(self.recv_stdout)] + + @property + def found_service(self) -> bool: + """True if service discovery located the offered instance.""" + return FOUND_SERVICE_MARKER in self.recv_stdout + + @property + def completed(self) -> bool: + """True if the receiver consumed all requested samples and exited cleanly.""" + return RECV_DONE_MARKER in self.recv_stdout + + +def is_non_decreasing(seq: list[int]) -> bool: + """No reordering: each received sequence number is >= the previous one.""" + return all(b >= a for a, b in zip(seq, seq[1:])) + + +def samples_are_intact(seq: list[int], send_cycles: int) -> bool: + """ + Value-integrity: every received sequence number is one the sender actually + produced (0 .. send_cycles-1). A corrupted payload would decode to a value + outside that range. + """ + return all(0 <= s < send_cycles for s in seq) + + +def _shell_exit_marker(out: str, marker: str = "RECV_EXIT") -> int: + """Extract ``=`` printed by the exchange command.""" + m = re.search(rf"{marker}=(-?\d+)", out) + if m is None: + raise AssertionError(f"Could not find {marker}= in command output:\n{out}") + return int(m.group(1)) + + +def run_event_exchange( + target, + send_cycles: int = 50, + recv_cycles: int = 10, + interval_ms: int = 100, + startup_delay_s: int = 1, + manifest: str | None = None, +) -> CommResult: + """ + Run one producer/consumer exchange on ``target`` and return parsed output. + + The producer (fit_sender) is started first in the background with enough + cycles to outlast the consumer (fit_receiver). The consumer retries service + discovery, so start order is not critical, but starting the producer first + keeps the run bounded by ``recv_cycles`` and the receiver's ``--max-polls``. + The producer's PID is captured and killed once the consumer finishes so it + never leaks onto the shared target and collides with the next test (two + providers offering the same instance would make the next consumer hang). + + ``manifest`` points both roles at a deployment config via ``-s``; when + ``None`` the deployed default (``DEFAULT_MANIFEST``) is used. + """ + manifest_path = manifest or DEFAULT_MANIFEST + send_log = "/tmp/fit_send.log" + recv_log = "/tmp/fit_recv.log" + + command = ( + f"cd {COMM_CWD} && rm -f {send_log} {recv_log} && " + f"{{ {FIT_SENDER_BIN} -n {send_cycles} -t {interval_ms} -s {manifest_path} " + f"> {send_log} 2>&1 & SEND_PID=$!; }} && " + f"sleep {startup_delay_s} && " + f"{FIT_RECEIVER_BIN} -n {recv_cycles} -t {interval_ms} -s {manifest_path} " + f"> {recv_log} 2>&1 ; RECV_RC=$? ; " + f"kill $SEND_PID 2>/dev/null ; wait $SEND_PID 2>/dev/null ; " + f"echo RECV_EXIT=$RECV_RC" + ) + _, out = target.execute(command) + recv_exit_code = _shell_exit_marker(out.decode(errors="replace")) + + _, recv_bytes = target.execute(f"cat {recv_log}") + _, send_bytes = target.execute(f"cat {send_log}") + + return CommResult( + recv_exit_code=recv_exit_code, + recv_stdout=recv_bytes.decode(errors="replace"), + send_stdout=send_bytes.decode(errors="replace"), + ) + + +# -------------------------------------------------------------------------- +# Deployment-configuration integrity (negative scenarios) +# -------------------------------------------------------------------------- +# +# fit_receiver's own contract (fit_receiver.rs) is: a config that cannot be +# loaded or resolves to no service instances must never let the process hang +# or silently look like success -- it must exit non-zero, bounded in time, +# and never reach FIT_FOUND_SERVICE. That is what these tests check. Unlike +# the removed ipc_bridge example (whose C++ config parser used assert() and +# so failed via SIGABRT), the exact underlying failure mode of the Rust +# `com_api` runtime builder is intentionally NOT asserted on here -- only the +# black-box contract is. +# +# These tests run against a dedicated instance specifier that no other test +# ever offers (NEGATIVE_CONFIG_INSTANCE_SPECIFIER), rather than the shared +# default. The QEMU target fixture is session-scoped (the same booted image +# persists across every test in the file, unlike the Docker plugin's +# per-test container), and run_event_exchange's producer cleanup is a plain +# `kill`, not a graceful unoffer() -- so a killed producer's SHM registration +# / cached samples for the *shared* default instance can still be +# discoverable by a later test. A consumer whose own config genuinely +# resolves to zero instances must never be able to observe that kind of +# cross-test leftover, so it needs an instance nobody else touches. + +# Deliberately malformed: not valid JSON at all. +TRUNCATED_CONFIG_JSON = '{ "serviceTypes": [ { "serviceTypeName"' + +# Deliberately incomplete: valid JSON, but missing the required +# "serviceTypes"/"serviceInstances" structure the schema demands. +SCHEMA_INVALID_CONFIG_JSON = "{}" + +# Never offered by any producer in this suite -- see the note above. +NEGATIVE_CONFIG_INSTANCE_SPECIFIER = "/Vehicle/ServiceNegativeConfigTest/Instance" + + +def write_remote_file(target, path: str, content: str) -> None: + """Write ``content`` to ``path`` on the target via a quoted heredoc.""" + heredoc = f"cat > {path} <<'COMM_FIT_EOF'\n{content}\nCOMM_FIT_EOF\n" + exit_code, out = target.execute(heredoc) + if exit_code != 0: + raise AssertionError(f"Failed to write {path} on target: {out!r}") + + +def run_receiver_with_manifest( + target, + manifest_path: str, + cycles: int = 3, + interval_ms: int = 100, + max_polls: int = 10, + timeout_s: int = 10, + instance_specifier: str = NEGATIVE_CONFIG_INSTANCE_SPECIFIER, +) -> CommResult: + """ + Run only the receiver against ``manifest_path``, bounded by a shell + ``timeout`` so an unexpected hang while loading the config cannot block the + test suite. Used for negative config scenarios where no producer is + started -- the receiver is expected to fail before ever discovering a + service. + + ``instance_specifier`` defaults to one no producer in this suite ever + offers, so a consumer that (incorrectly) reaches discovery cannot latch + onto another test's leftover service state. + """ + recv_log = "/tmp/fit_recv_negative.log" + command = ( + f"cd {COMM_CWD} && rm -f {recv_log} && " + f"timeout {timeout_s} {FIT_RECEIVER_BIN} -n {cycles} -t {interval_ms} " + f"--max-polls {max_polls} -s {manifest_path} -i {instance_specifier} " + f"> {recv_log} 2>&1 ; echo RECV_EXIT=$?" + ) + _, out = target.execute(command) + recv_exit_code = _shell_exit_marker(out.decode(errors="replace")) + _, recv_bytes = target.execute(f"cat {recv_log}") + return CommResult( + recv_exit_code=recv_exit_code, + recv_stdout=recv_bytes.decode(errors="replace"), + send_stdout="", + ) + + +# -------------------------------------------------------------------------- +# Mixed-criticality safety & isolation (ASIL-B ACL enforcement) +# -------------------------------------------------------------------------- +# +# LoLa's `allowedConsumer`/`allowedProvider` ACLs are enforced via POSIX ACLs +# on the shared-memory objects the provider creates, gated by the real +# process UID of the consumer/provider -- this is the same enforcement point +# already exercised (and documented) for the now-removed ipc_bridge example: +# +# 1. It is the PROVIDER's config that is authoritative (its allowedConsumer +# list decides who may open the SHM object) -- a consumer's own config +# does not self-restrict what it may read. +# 2. Enforcement requires distinct real UIDs -- running "denied" and +# "allowed" consumers under the same UID proves nothing, because the +# excluded UID is never actually attempted. +# +# fit_receiver's discover_consumer() swallows a denied proxy-construction +# error into a retry-then-give-up loop (see fit_receiver.rs), so a denied +# consumer surfaces identically to "no service found": non-zero exit, +# FIT_FOUND_SERVICE never printed, no samples received. That is what is +# asserted here; the exact underlying error text is not, since it depends on +# the Rust `com_api` FFI error formatting. +# +# Getting a second real UID needs `on -u :` on QNX (no /etc/passwd +# entry required) or `setpriv --reuid= --regid=` on Linux (needs +# root or CAP_SETUID, i.e. it works if the ITF target container already runs +# as root, mirroring how test_remote_logging.py starts datarouter without +# invoking sudo). +# +# This SHM-open denial signature is only reachable on Linux. On QNX, a +# consumer launched under a lower-privilege UID aborts earlier, at +# MessagePassingService endpoint registration ("Failed to start listening ... +# Operation not permitted"), because that registration itself requires +# privilege -- so an excluded UID never reaches the SHM-open ACL. The +# isolation test therefore skips on QNX (see is_qnx()), matching the +# limitation already documented and observed for ipc_bridge. + +ASILB_INSTANCE_SPECIFIER = "/Vehicle/ServiceAcl/Instance" +DENIED_CONSUMER_UID = 64000 + +# Reuses the VehicleInterface service type (serviceId / event ids) that +# com-api-gen bakes in, so fit_sender/fit_receiver can still bind against it; +# only the service *instance* below is new, with a dedicated ASIL-B binding +# and a restrictive allowedConsumer/allowedProvider list. +# +# "global.asil-level" declares the *process's own* ASIL capability -- distinct +# from the per-instance "asil-level" below, which declares the *instance's* +# level. LoLa aborts ("Service instance has a higher ASIL than the process") +# if a process without this declaration touches an ASIL-B instance, checked +# before any allowedConsumer/allowedProvider UID enforcement is reached. +ASILB_CONFIG_TEMPLATE = """{{ + "serviceTypes": [ + {{ + "serviceTypeName": "/bmw/adp/VehicleInterface", + "version": {{"major": 1, "minor": 0}}, + "bindings": [ + {{ + "binding": "SHM", + "serviceId": 6433, + "events": [ + {{"eventName": "left_tire", "eventId": 1}}, + {{"eventName": "exhaust", "eventId": 2}} + ] + }} + ] + }} + ], + "global": {{"applicationID": 4099, "asil-level": "B"}}, + "serviceInstances": [ + {{ + "instanceSpecifier": "{instance_specifier}", + "serviceTypeName": "/bmw/adp/VehicleInterface", + "version": {{"major": 1, "minor": 0}}, + "instances": [ + {{ + "instanceId": 9, + "allowedConsumer": {{"B": [{allowed_uid}], "QM": [{allowed_uid}]}}, + "allowedProvider": {{"B": [{allowed_uid}], "QM": [{allowed_uid}]}}, + "asil-level": "B", + "binding": "SHM", + "events": [ + {{"eventName": "left_tire", "numberOfSampleSlots": 30, "maxSubscribers": 5}}, + {{"eventName": "exhaust", "numberOfSampleSlots": 30, "maxSubscribers": 5}} + ] + }} + ] + }} + ] +}}""" + + +def render_asilb_config(allowed_uid: int) -> str: + """ASIL-B manifest granting only ``allowed_uid`` (not DENIED_CONSUMER_UID).""" + return ASILB_CONFIG_TEMPLATE.format( + instance_specifier=ASILB_INSTANCE_SPECIFIER, + allowed_uid=allowed_uid, + ) + + +def discover_default_uid(target) -> int: + """The real UID `target.execute` commands run as by default.""" + _, out = target.execute("id -u") + return int(out.decode(errors="replace").strip()) + + +def is_qnx(target) -> bool: + """Same detection approach as test_remote_logging.py's `_is_qnx`.""" + _, out = target.execute("uname -s") + return b"QNX" in out + + +def as_uid_prefix(target, uid: int) -> str: + """ + Shell prefix that re-executes the following command under ``uid``. + + QNX: `on -u :` needs no /etc/passwd entry. + Linux: `setpriv` needs CAP_SETUID (i.e. running as root), the same + privilege level the Linux ITF container already runs at. + """ + if is_qnx(target): + return f"on -u {uid}:{uid} " + return f"setpriv --reuid={uid} --regid={uid} --clear-groups " + + +def run_acl_isolation_scenario( + target, + send_cycles: int = 40, + recv_cycles: int = 8, + interval_ms: int = 150, + startup_delay_s: int = 1, +): + """ + Run an ASIL-B producer, one consumer with an allowed UID, and one + consumer with a UID deliberately excluded from the ACL, all running + concurrently against the same instance. + + Returns (allowed_result, denied_result, allowed_uid). + """ + allowed_uid = discover_default_uid(target) + manifest_path = "/tmp/comm_fit_asilb_config.json" + write_remote_file(target, manifest_path, render_asilb_config(allowed_uid)) + + send_log = "/tmp/fit_acl_send.log" + allowed_log = "/tmp/fit_acl_recv_allowed.log" + denied_log = "/tmp/fit_acl_recv_denied.log" + denied_prefix = as_uid_prefix(target, DENIED_CONSUMER_UID) + + # As in run_event_exchange, capture the producer PID and kill it after both + # consumers finish so it does not leak onto the shared target. + command = ( + f"cd {COMM_CWD} && rm -f {send_log} {allowed_log} {denied_log} && " + f"chmod 666 {manifest_path} && " + f"{{ {FIT_SENDER_BIN} -n {send_cycles} -t {interval_ms} -s {manifest_path} " + f"-i {ASILB_INSTANCE_SPECIFIER} > {send_log} 2>&1 & SEND_PID=$!; }} && " + f"sleep {startup_delay_s} && " + f"( {FIT_RECEIVER_BIN} -n {recv_cycles} -t {interval_ms} -s {manifest_path} " + f"-i {ASILB_INSTANCE_SPECIFIER} > {allowed_log} 2>&1 & ALLOWED_PID=$! ; " + f"{denied_prefix}timeout 8 {FIT_RECEIVER_BIN} -n {recv_cycles} -t {interval_ms} " + f"--max-polls 20 -s {manifest_path} -i {ASILB_INSTANCE_SPECIFIER} " + f"> {denied_log} 2>&1 ; DENIED_EXIT=$? ; " + f"wait $ALLOWED_PID ; ALLOWED_EXIT=$? ; " + f"echo ALLOWED_EXIT=$ALLOWED_EXIT ; echo DENIED_EXIT=$DENIED_EXIT ) ; " + f"kill $SEND_PID 2>/dev/null ; wait $SEND_PID 2>/dev/null" + ) + _, out = target.execute(command) + decoded = out.decode(errors="replace") + allowed_exit = _shell_exit_marker(decoded, "ALLOWED_EXIT") + denied_exit = _shell_exit_marker(decoded, "DENIED_EXIT") + + _, allowed_bytes = target.execute(f"cat {allowed_log}") + _, denied_bytes = target.execute(f"cat {denied_log}") + + allowed_result = CommResult( + recv_exit_code=allowed_exit, + recv_stdout=allowed_bytes.decode(errors="replace"), + send_stdout="", + ) + denied_result = CommResult( + recv_exit_code=denied_exit, + recv_stdout=denied_bytes.decode(errors="replace"), + send_stdout="", + ) + return allowed_result, denied_result, allowed_uid diff --git a/feature_integration_tests/itf/test_communication.py b/feature_integration_tests/itf/test_communication.py new file mode 100644 index 00000000000..39010ac57c8 --- /dev/null +++ b/feature_integration_tests/itf/test_communication.py @@ -0,0 +1,273 @@ +# ******************************************************************************* +# 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 tests for the communication module (LoLa / mw::com). + +Upstream ships only the single-process ``com-api-example`` demo, which cannot +exercise producer/consumer as separate processes. Per the maintainer's +guidance these tests drive our own two-process scenario +(``showcases/standalone/comm_fit``: ``fit_sender`` + ``fit_receiver``) over +real shared memory and assert on the data the consumer actually observes -- +sequence and value-integrity -- rather than on log strings the application +prints about its own configuration. + +Scope covers delivery, ordering, integrity, service discovery, runtime +deployment config, late join, deployment-config integrity (negative +scenarios), and ASIL-B/ACL isolation -- the requirement set the removed +ipc_bridge example exercised, now reproduced against fit_sender/fit_receiver. +""" + +import logging + +import comm_helpers as comm +import pytest +from test_properties import add_test_properties + +logger = logging.getLogger(__name__) + + +def test_fit_binaries_are_deployed(target): + """The communication sender and receiver binaries are present in the image.""" + for binary in (comm.FIT_SENDER_BIN, comm.FIT_RECEIVER_BIN): + exit_code, _ = target.execute(f"test -f {binary}") + assert exit_code == 0, f"{binary} not found on target" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__event_type", + "feat_req__com__producer_consumer", + "feat_req__com__interfaces", + "feat_req__com__data_driven_arch", + "feat_req__com__service_discovery", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_event_exchange_delivers_data(target): + """ + Verify that a producer and consumer, running as two separate processes, + exchange cyclic events over shared memory: the consumer discovers the + offered service, receives samples, and exits cleanly. + """ + result = comm.run_event_exchange(target, send_cycles=50, recv_cycles=10) + logger.info("consumer stdout:\n%s", result.recv_stdout) + + assert result.recv_exit_code == 0, "consumer did not exit cleanly" + assert result.found_service, "service discovery did not locate the provider" + assert result.received_samples, "consumer received no samples" + assert result.completed, "consumer did not report a clean completion" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__data_corruption", + "feat_req__com__data_reordering", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_received_samples_are_ordered_and_intact(target): + """ + Verify that received samples are neither reordered nor corrupted: the + sequence numbers the producer embedded in each payload arrive in + non-decreasing order and every received value is one the producer actually + sent (never a garbled value). + """ + send_cycles = 80 + result = comm.run_event_exchange(target, send_cycles=send_cycles, recv_cycles=20) + samples = result.received_samples + logger.info("received sample sequence: %s", samples) + + assert result.recv_exit_code == 0, "consumer did not exit cleanly" + assert samples, "consumer received no samples" + assert comm.is_non_decreasing(samples), f"received samples out of order: {samples}" + assert comm.samples_are_intact(samples, send_cycles), ( + f"received a value the producer never sent (corruption): {samples}" + ) + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_deployment_config_is_read_from_runtime_path(target): + """ + Verify that the SHM binding and service identity are taken from a + deployment manifest read at runtime from an explicit on-target path + (``-s ``), not compiled in. The deployed config is staged at a + non-default runtime path and both roles are pointed at it; the consumer + then discovers the service and receives samples over the config-declared + SHM binding. + """ + staged = "/tmp/comm_fit_runtime_manifest.json" + copy_rc, _ = target.execute(f"cp {comm.DEFAULT_MANIFEST} {staged}") + assert copy_rc == 0, "could not stage a runtime deployment manifest on target" + + result = comm.run_event_exchange(target, send_cycles=30, recv_cycles=5, manifest=staged) + logger.info("runtime-config consumer stdout:\n%s", result.recv_stdout) + + assert result.recv_exit_code == 0, "consumer did not exit cleanly with a runtime-path config" + assert result.found_service, "service not discovered using the runtime-path config" + assert result.received_samples, "no samples received over the config-declared SHM binding" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__stateless_communication", + "feat_req__com__producer_consumer", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_late_joining_consumer_receives_data(target): + """ + Verify that a consumer subscribing well after the producer has started + still binds and receives valid samples, proving communication does not + depend on the consumer being present from the first cycle. + """ + result = comm.run_event_exchange(target, send_cycles=80, recv_cycles=10, startup_delay_s=3) + logger.info("late-join consumer stdout:\n%s", result.recv_stdout) + + assert result.recv_exit_code == 0, "late-joining consumer did not exit cleanly" + assert result.found_service, "late-joining consumer failed to discover the service" + assert result.received_samples, "late-joining consumer received no samples" + + +def _assert_fails_on_load_without_hang(result: comm.CommResult) -> None: + """ + Shared check for the config-integrity tests below: the process must fail + deterministically -- bounded by the shell `timeout` in + run_receiver_with_manifest -- never silently succeed, and never proceed + far enough to discover a service or process a sample. + """ + assert result.recv_exit_code != 0, "consumer did not fail on an invalid/missing config" + assert not result.found_service, "consumer proceeded past config load to service discovery" + assert not result.received_samples, "consumer proceeded to receive samples despite bad config" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_missing_config_file_fails_deterministically(target): + """ + Verify that a missing deployment manifest causes the process to fail + deterministically and quickly rather than hang or silently continue. + """ + result = comm.run_receiver_with_manifest(target, "/tmp/comm_fit_missing_config.json") + logger.info("missing-config consumer output:\n%s", result.recv_stdout) + _assert_fails_on_load_without_hang(result) + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_truncated_config_fails_deterministically(target): + """ + Verify that syntactically invalid JSON in the deployment manifest fails + deterministically rather than hang or silently continue. + """ + manifest_path = "/tmp/comm_fit_truncated_config.json" + comm.write_remote_file(target, manifest_path, comm.TRUNCATED_CONFIG_JSON) + + result = comm.run_receiver_with_manifest(target, manifest_path) + logger.info("truncated-config consumer output:\n%s", result.recv_stdout) + _assert_fails_on_load_without_hang(result) + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_schema_invalid_config_fails_deterministically(target): + """ + Verify that well-formed JSON missing the required schema structure fails + deterministically rather than hang or silently continue. + """ + manifest_path = "/tmp/comm_fit_schema_invalid_config.json" + comm.write_remote_file(target, manifest_path, comm.SCHEMA_INVALID_CONFIG_JSON) + + result = comm.run_receiver_with_manifest(target, manifest_path) + logger.info("schema-invalid-config consumer output:\n%s", result.recv_stdout) + _assert_fails_on_load_without_hang(result) + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__asil", + "feat_req__com__acl_for_consumer", + "feat_req__com__acl_for_producer", + "feat_req__com__acl_per_service_instance", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_mixed_criticality_acl_isolation(target): + """ + Verify ASIL-B mixed-criticality isolation: an ASIL-B provider offers to + exactly one allowed UID (the target's own default identity). Two + consumers subscribe concurrently -- one running as that allowed UID, one + running as a UID deliberately excluded from allowedConsumer. + + The allowed consumer receives samples while the excluded consumer is + active concurrently, proving the excluded consumer cannot affect the + allowed stream. The excluded consumer fails deterministically (non-zero + exit, never discovers the service) and never receives a sample. + + Obtaining the excluded consumer's distinct UID uses `setpriv` on Linux, + which lets an unprivileged UID still bring up its LoLa endpoint and so + reach the shared-memory ACL check the assertions below rely on. + + Skipped on QNX: there, LoLa's MessagePassingService endpoint registration + itself requires privilege, so a consumer launched under a lower-privilege + UID (`on -u `) aborts at message-passing setup before reaching the + shared-memory ACL -- a limitation of the QNX IPC layer itself, not of + allowedConsumer enforcement, already documented for the removed + ipc_bridge example. + """ + if comm.is_qnx(target): + pytest.skip( + "allowedConsumer ACL enforcement cannot be exercised via a " + "lower-privilege UID on QNX: LoLa's MessagePassingService endpoint " + "registration requires privilege, so a consumer run under `on -u` " + "aborts at message-passing setup before reaching the shared-memory " + "ACL check." + ) + + allowed_result, denied_result, allowed_uid = comm.run_acl_isolation_scenario(target) + logger.info("ACL scenario allowed_uid=%s", allowed_uid) + logger.info("allowed consumer stdout:\n%s", allowed_result.recv_stdout) + logger.info("denied consumer stdout:\n%s", denied_result.recv_stdout) + + # Allowed consumer: unaffected by the concurrently-running denied one. + assert allowed_result.recv_exit_code == 0, "allowed consumer did not exit cleanly" + assert allowed_result.received_samples, "allowed consumer received no samples" + + # Denied consumer: fails deterministically, never receives data. + assert denied_result.recv_exit_code != 0, "consumer with an excluded UID was not denied" + assert not denied_result.found_service, "consumer with an excluded UID reached service discovery" + assert not denied_result.received_samples, "consumer with an excluded UID received samples" diff --git a/feature_integration_tests/itf/test_properties.py b/feature_integration_tests/itf/test_properties.py new file mode 100644 index 00000000000..b02ec02cb98 --- /dev/null +++ b/feature_integration_tests/itf/test_properties.py @@ -0,0 +1,22 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +try: + from attribute_plugin import add_test_properties # type: ignore[import-untyped] +except ImportError: + # Define no-op decorator if attribute_plugin is not available (outside bazel) + # Keeps IDE debugging functionality + def add_test_properties(*args, **kwargs): + def decorator(func): + return func # No-op decorator + + return decorator diff --git a/known_good.json b/known_good.json index 708b6bfdcbd..33e00cfcad6 100644 --- a/known_good.json +++ b/known_good.json @@ -29,6 +29,7 @@ "hash": "0e0640998096049a8dd65a4173b03a988d5a85a7", "bazel_patches": [ "//patches/communication:001-expose-comm-examples.patch", + "//patches/communication:002-expose-com-api-gen.patch", "//patches/communication:003-module-deps-visibility.patch" ], "metadata": { diff --git a/patches/communication/002-expose-com-api-gen.patch b/patches/communication/002-expose-com-api-gen.patch new file mode 100644 index 00000000000..f67dd42cd65 --- /dev/null +++ b/patches/communication/002-expose-com-api-gen.patch @@ -0,0 +1,13 @@ +diff --git a/score/mw/com/example/com-api-example/com-api-gen/BUILD b/score/mw/com/example/com-api-example/com-api-gen/BUILD +index b855bdf5..9217e658 100644 +--- a/score/mw/com/example/com-api-example/com-api-gen/BUILD ++++ b/score/mw/com/example/com-api-example/com-api-gen/BUILD +@@ -21,7 +21,7 @@ rust_library( + crate_name = "com_api_gen", + features = ["link_std_cpp_lib"], + visibility = [ +- "//score/mw/com:__subpackages__", ++ "//visibility:public", + ], + deps = [ + ":vehicle_gen_cpp", diff --git a/showcases/BUILD b/showcases/BUILD index e8e8c0649ca..29fd979185d 100644 --- a/showcases/BUILD +++ b/showcases/BUILD @@ -17,6 +17,7 @@ score_pkg_bundle( bins = ["//showcases/cli"], other_package_files = [ "//showcases/standalone:comm_pkg_files", + "//showcases/standalone/comm_fit:comm_fit_pkg_files", "//showcases/standalone:kyron_pkg_files", "//showcases/standalone:time_pkg_files", "//showcases/orchestration_persistency:orch_per_pkg_files", diff --git a/showcases/standalone/comm_fit/BUILD b/showcases/standalone/comm_fit/BUILD new file mode 100644 index 00000000000..39ba8320b5e --- /dev/null +++ b/showcases/standalone/comm_fit/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_rust//rust:defs.bzl", "rust_binary") +load("//bazel_common:bundlers.bzl", "score_pkg_bundle") + +# Separate producer/receiver binaries for the communication ITF. Upstream +# replaced the two-process ipc_bridge example with the single-process +# com-api-example, so these provide the distinct sender/receiver processes the +# ITF needs to observe delivery, ordering and value-integrity over real shared +# memory. They reuse com-api-example's VehicleInterface (exposed via +# //patches/communication:002-expose-com-api-gen.patch) and its deployed +# mw_com_config.json, so no separate deployment config is required. + +_COM_API_DEPS = [ + "@score_communication//score/mw/com/example/com-api-example/com-api-gen", + "@score_communication//score/mw/com/impl/rust/com-api/com-api", +] + +rust_binary( + name = "fit_sender", + srcs = ["fit_sender.rs"], + features = ["link_std_cpp_lib"], + visibility = ["//visibility:public"], + deps = _COM_API_DEPS, +) + +rust_binary( + name = "fit_receiver", + srcs = ["fit_receiver.rs"], + features = ["link_std_cpp_lib"], + visibility = ["//visibility:public"], + deps = _COM_API_DEPS, +) + +score_pkg_bundle( + name = "comm_fit", + bins = [ + ":fit_sender", + ":fit_receiver", + ], + package_dir = "standalone", +) diff --git a/showcases/standalone/comm_fit/fit_receiver.rs b/showcases/standalone/comm_fit/fit_receiver.rs new file mode 100644 index 00000000000..96a4c58dbd1 --- /dev/null +++ b/showcases/standalone/comm_fit/fit_receiver.rs @@ -0,0 +1,191 @@ +// ******************************************************************************* +// 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 +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +//! Standalone *consumer* for the communication feature integration tests. +//! +//! It discovers the `VehicleInterface` service instance offered by `fit_sender` +//! (retrying, so start order does not matter), subscribes to `left_tire`, and +//! prints each received sequence number as `FIT_RECV seq=`. Because the +//! sender encodes a monotonically increasing counter in the payload, the ITF +//! can assert on this output that delivery happened, that samples arrived in +//! order, and that their values were not corrupted -- all from data the +//! consumer actually observed. +//! +//! Exit code: 0 when it received the requested number of samples, 1 otherwise +//! (so a delivery regression surfaces as a non-zero exit, not a silent +//! under-count). + +use std::path::{Path, PathBuf}; +use std::thread::sleep; +use std::time::Duration; + +use com_api::{ + Builder, FindServiceSpecifier, InstanceSpecifier, LolaRuntimeBuilderImpl, LolaRuntimeImpl, Runtime, RuntimeBuilder, + SampleContainer, ServiceDiscovery, Subscriber, Subscription, +}; +use com_api_gen::VehicleInterface; + +/// Default instance specifier, matching com-api-example's deployment manifest. +/// Overridable via `-i` so tests can target a dedicated (e.g. ASIL-B) instance +/// without disturbing the default exchange scenario. +const DEFAULT_INSTANCE_SPECIFIER: &str = "/Vehicle/Service1/Instance"; + +struct Arguments { + /// Deployment manifest read at runtime (feat_req__com__depl_config_runtime). + service_instance_manifest: PathBuf, + /// Number of samples to receive before exiting successfully. + cycles: u32, + /// Poll interval in milliseconds (discovery retry and receive loop). + interval_ms: u64, + /// Maximum number of poll iterations before giving up (bounds the run). + max_polls: u32, + /// Service instance specifier to subscribe to. + instance_specifier: String, +} + +fn parse_args() -> Arguments { + let mut manifest = PathBuf::from("./etc/mw_com_config.json"); + let mut cycles: u32 = 10; + let mut interval_ms: u64 = 100; + let mut max_polls: u32 = 100; + let mut instance_specifier = DEFAULT_INSTANCE_SPECIFIER.to_string(); + + let argv: Vec = std::env::args().collect(); + let mut i = 1; + while i < argv.len() { + match argv[i].as_str() { + "-s" | "--service-instance-manifest" => { + i += 1; + manifest = PathBuf::from(argv.get(i).expect("missing value for -s")); + }, + "-n" | "--cycles" => { + i += 1; + cycles = argv + .get(i) + .expect("missing value for -n") + .parse() + .expect("invalid -n/--cycles"); + }, + "-t" | "--interval-ms" => { + i += 1; + interval_ms = argv + .get(i) + .expect("missing value for -t") + .parse() + .expect("invalid -t/--interval-ms"); + }, + "--max-polls" => { + i += 1; + max_polls = argv + .get(i) + .expect("missing value for --max-polls") + .parse() + .expect("invalid --max-polls"); + }, + "-i" | "--instance-specifier" => { + i += 1; + instance_specifier = argv.get(i).expect("missing value for -i").clone(); + }, + other => eprintln!("FIT_RECV_IGNORED_ARG {other}"), + } + i += 1; + } + + Arguments { + service_instance_manifest: manifest, + cycles, + interval_ms, + max_polls, + instance_specifier, + } +} + +fn init_lola_runtime(config_path: &Path) -> LolaRuntimeImpl { + let mut builder = LolaRuntimeBuilderImpl::new(); + if config_path.exists() { + builder.load_config(config_path); + } else { + eprintln!("FIT_RECV_CONFIG_MISSING path={}", config_path.display()); + } + builder.build().expect("Failed to build Lola runtime") +} + +/// Discover and build the consumer, retrying so start order does not matter. +fn discover_consumer( + runtime: &LolaRuntimeImpl, + service_id: &InstanceSpecifier, + retries: u32, + interval_ms: u64, +) -> Option<::Consumer> { + for _ in 0..retries { + let discovery = runtime.find_service::(FindServiceSpecifier::Specific(service_id.clone())); + if let Ok(instances) = discovery.get_available_instances() { + if let Some(builder) = instances.into_iter().next() { + return builder.build().ok(); + } + } + sleep(Duration::from_millis(interval_ms)); + } + None +} + +fn main() { + let args = parse_args(); + let runtime = init_lola_runtime(&args.service_instance_manifest); + + let service_id = InstanceSpecifier::new(&args.instance_specifier).expect("Failed to create InstanceSpecifier"); + + let consumer = match discover_consumer(&runtime, &service_id, args.max_polls, args.interval_ms) { + Some(consumer) => consumer, + None => { + eprintln!("FIT_RECV_NO_SERVICE"); + std::process::exit(1); + }, + }; + println!("FIT_FOUND_SERVICE"); + + let subscription = consumer + .left_tire + .subscribe(args.cycles as usize) + .expect("Failed to subscribe"); + + let mut received: u32 = 0; + let mut buffer = SampleContainer::new(args.cycles as usize); + for _ in 0..args.max_polls { + if received >= args.cycles { + break; + } + match subscription.try_receive(&mut buffer, args.cycles as usize) { + Ok(0) => sleep(Duration::from_millis(args.interval_ms)), + Ok(_) => { + while let Some(sample) = buffer.pop_front() { + // Sequence number was encoded in the payload by fit_sender; + // the sample derefs to the generated Tire type. + let pressure = sample.pressure; + println!("FIT_RECV seq={}", pressure as i64); + received += 1; + } + }, + Err(error) => eprintln!("FIT_RECV_ERROR {error:?}"), + } + } + + let _ = subscription.unsubscribe(); + + if received >= args.cycles { + println!("FIT_RECV_DONE count={received}"); + } else { + eprintln!("FIT_RECV_INCOMPLETE count={received} expected={}", args.cycles); + std::process::exit(1); + } +} diff --git a/showcases/standalone/comm_fit/fit_sender.rs b/showcases/standalone/comm_fit/fit_sender.rs new file mode 100644 index 00000000000..e35e64e1c68 --- /dev/null +++ b/showcases/standalone/comm_fit/fit_sender.rs @@ -0,0 +1,135 @@ +// ******************************************************************************* +// 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 +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +//! Standalone *producer* for the communication feature integration tests. +//! +//! It offers the `VehicleInterface` service instance and publishes `left_tire` +//! samples whose `pressure` field carries a monotonically increasing sequence +//! number (0, 1, 2, ...). Running the sender and `fit_receiver` as two separate +//! OS processes over real shared memory lets the ITF assert delivery, ordering +//! and value-integrity on the data the consumer actually observes, rather than +//! on strings the app prints about itself. +//! +//! Kept deliberately close to `com-api-example` (score/mw/com) so it tracks the +//! same public `com_api` surface BMW ships. Arguments are parsed with `std` +//! (no `clap`) to avoid depending on the communication module's crate index. +//! +//! Usage: `fit_sender [-s ] [-n ] [-t ]` + +use std::path::{Path, PathBuf}; +use std::thread::sleep; +use std::time::Duration; + +use com_api::{ + Builder, InstanceSpecifier, LolaRuntimeBuilderImpl, LolaRuntimeImpl, Producer, Publisher, Runtime, RuntimeBuilder, + SampleMaybeUninit, SampleMut, +}; +use com_api_gen::{Tire, VehicleInterface}; + +/// Default instance specifier, matching com-api-example's deployment manifest. +/// Overridable via `-i` so tests can target a dedicated (e.g. ASIL-B) instance +/// without disturbing the default exchange scenario. +const DEFAULT_INSTANCE_SPECIFIER: &str = "/Vehicle/Service1/Instance"; + +struct Arguments { + /// Deployment manifest read at runtime (feat_req__com__depl_config_runtime). + service_instance_manifest: PathBuf, + /// Number of samples to send. + cycles: u32, + /// Delay between samples in milliseconds. + interval_ms: u64, + /// Service instance specifier to offer. + instance_specifier: String, +} + +fn parse_args() -> Arguments { + let mut manifest = PathBuf::from("./etc/mw_com_config.json"); + let mut cycles: u32 = 50; + let mut interval_ms: u64 = 100; + let mut instance_specifier = DEFAULT_INSTANCE_SPECIFIER.to_string(); + + let argv: Vec = std::env::args().collect(); + let mut i = 1; + while i < argv.len() { + match argv[i].as_str() { + "-s" | "--service-instance-manifest" => { + i += 1; + manifest = PathBuf::from(argv.get(i).expect("missing value for -s")); + }, + "-n" | "--cycles" => { + i += 1; + cycles = argv + .get(i) + .expect("missing value for -n") + .parse() + .expect("invalid -n/--cycles"); + }, + "-t" | "--interval-ms" => { + i += 1; + interval_ms = argv + .get(i) + .expect("missing value for -t") + .parse() + .expect("invalid -t/--interval-ms"); + }, + "-i" | "--instance-specifier" => { + i += 1; + instance_specifier = argv.get(i).expect("missing value for -i").clone(); + }, + other => eprintln!("FIT_SEND_IGNORED_ARG {other}"), + } + i += 1; + } + + Arguments { + service_instance_manifest: manifest, + cycles, + interval_ms, + instance_specifier, + } +} + +fn init_lola_runtime(config_path: &Path) -> LolaRuntimeImpl { + let mut builder = LolaRuntimeBuilderImpl::new(); + if config_path.exists() { + builder.load_config(config_path); + } else { + eprintln!("FIT_SEND_CONFIG_MISSING path={}", config_path.display()); + } + builder.build().expect("Failed to build Lola runtime") +} + +fn main() { + let args = parse_args(); + let runtime = init_lola_runtime(&args.service_instance_manifest); + + let service_id = InstanceSpecifier::new(&args.instance_specifier).expect("Failed to create InstanceSpecifier"); + let producer = runtime + .producer_builder::(service_id) + .build() + .expect("Failed to build producer instance"); + let offered = producer.offer().expect("Failed to offer producer instance"); + println!("FIT_SEND_OFFERED"); + + for seq in 0..args.cycles { + let uninit_sample = offered.left_tire.allocate().expect("Failed to allocate sample"); + // Encode the sequence number in the payload so the receiver can verify + // ordering and value-integrity independently of anything printed here. + let sample = uninit_sample.write(Tire { pressure: seq as f32 }); + sample.send().expect("Failed to send sample"); + println!("FIT_SEND seq={seq}"); + sleep(Duration::from_millis(args.interval_ms)); + } + + println!("FIT_SEND_DONE count={}", args.cycles); +}