diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 00000000..02954734 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,114 @@ +name: Conformance Suite + +on: + push: + branches: ["main", "develop"] + pull_request: + branches: ["main", "develop"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + inprocess: + name: Conformance (in-process fixture) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + + - name: Build conformance CLI + run: cargo build --profile conformance -p mqtt5-conformance-cli + + - name: Run conformance suite (in-process) + run: ./target/conformance/mqtt5-conformance --report conformance-report.json + + - name: Upload report + uses: actions/upload-artifact@v4 + with: + name: conformance-report-inprocess + path: conformance-report.json + if-no-files-found: error + + external-mqtt5: + name: Conformance (external mqtt5 broker) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + + - name: Build mqttv5 broker + run: cargo build --release -p mqttv5-cli + + - name: Build conformance CLI + run: cargo build --profile conformance -p mqtt5-conformance-cli + + - name: Start mqttv5 broker + run: | + ./target/release/mqttv5 broker \ + --host 127.0.0.1:1883 \ + --allow-anonymous true \ + > broker.log 2>&1 & + echo $! > broker.pid + sleep 2 + + - name: Run conformance suite against external broker + run: | + ./target/conformance/mqtt5-conformance \ + --sut crates/mqtt5-conformance/tests/fixtures/external-mqtt5.toml \ + --report conformance-report.json + + - name: Stop broker + if: always() + run: | + if [ -f broker.pid ]; then + kill "$(cat broker.pid)" || true + fi + + - name: Upload broker log + if: always() + uses: actions/upload-artifact@v4 + with: + name: broker-log-external-mqtt5 + path: broker.log + if-no-files-found: ignore + + - name: Upload report + uses: actions/upload-artifact@v4 + with: + name: conformance-report-external-mqtt5 + path: conformance-report.json + if-no-files-found: error + + fixtures: + name: Validate fixtures and profiles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + + - name: Run schema unit tests + run: cargo test -p mqtt5-conformance --lib --features inprocess-fixture diff --git a/Cargo.toml b/Cargo.toml index 260b9917..abd17212 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ members = [ "crates/mqtt5-wasm", "crates/mqttv5-cli", "crates/mqtt5-conformance", + "crates/mqtt5-conformance-macros", + "crates/mqtt5-conformance-cli", ] resolver = "2" @@ -26,6 +28,11 @@ opt-level = "z" [profile.dev] debug = true +[profile.conformance] +inherits = "release" +panic = "unwind" +lto = false + [profile.profiling] inherits = "release" debug = 2 diff --git a/README.md b/README.md index 7a18a7c7..a0f16690 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The platform is organized into four crates: - **mqtt5-protocol** - Platform-agnostic MQTT v5.0 core (packets, types, Transport trait). Supports `no_std` for embedded targets. - **mqtt5** - Native client and broker for Linux, macOS, Windows - **mqtt5-wasm** - WebAssembly client and broker for browsers -- **mqtt5-conformance** - OASIS specification conformance test suite (247 normative statements) +- **mqtt5-conformance** - OASIS specification conformance test suite (183 automated tests, 247 normative statements) ## Quick Start @@ -138,6 +138,17 @@ Four authentication methods (Password, SCRAM-SHA-256, JWT, Federated JWT), role- See the [Authentication & Authorization Guide](AUTHENTICATION.md) for configuration details and security hardening. +### Conformance Testing + +The conformance test suite validates any MQTT v5.0 broker against the OASIS specification. Create a TOML descriptor for your broker and run the CLI runner: + +```bash +cargo build --release -p mqtt5-conformance-cli +./target/release/mqtt5-conformance --sut your-broker.toml --report report.json +``` + +See the [Conformance Test Suite](crates/mqtt5-conformance/README.md) for test organization and architecture, and the [CLI Reference](crates/mqtt5-conformance-cli/README.md) for the full SUT descriptor schema and report format. + ## Publications - **Evaluating Stream Mapping Strategies for MQTT over QUIC** — Computer Networks (Elsevier), 2026. Defines three stream mapping strategies (control-only, per-topic, per-publish) and evaluates them across five experiments on GCP infrastructure. Experiment data archived at [Zenodo](https://doi.org/10.5281/zenodo.19098820). See the [paper directory on GitHub](https://github.com/LabOverWire/mqtt-lib/tree/main/publications/comnet) for the paper, experiment scripts, and reproduction guide. diff --git a/crates/mqtt5-conformance-cli/Cargo.toml b/crates/mqtt5-conformance-cli/Cargo.toml new file mode 100644 index 00000000..f32d6a65 --- /dev/null +++ b/crates/mqtt5-conformance-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mqtt5-conformance-cli" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +publish = false +description = "CLI runner for the MQTT v5 conformance test suite" + +[[bin]] +name = "mqtt5-conformance" +path = "src/main.rs" + +[dependencies] +futures-util = "0.3.32" +libtest-mimic = "0.8.2" +mqtt5-conformance = { version = "0.1.0", path = "../mqtt5-conformance" } +serde_json = "1.0.149" +tokio = { version = "1.51.1", features = ["rt-multi-thread", "macros"] } diff --git a/crates/mqtt5-conformance-cli/README.md b/crates/mqtt5-conformance-cli/README.md new file mode 100644 index 00000000..62ef278e --- /dev/null +++ b/crates/mqtt5-conformance-cli/README.md @@ -0,0 +1,185 @@ +# mqtt5-conformance-cli + +Standalone CLI runner for the MQTT v5.0 conformance test suite. Runs 183 conformance tests against any MQTT v5.0 broker described by a TOML descriptor file. + +## Installation + +```bash +cargo build --release -p mqtt5-conformance-cli +# Binary: ./target/release/mqtt5-conformance +``` + +## Usage + +Run against the built-in in-process broker: + +```bash +./target/release/mqtt5-conformance +``` + +Run against an external broker: + +```bash +./target/release/mqtt5-conformance --sut tests/fixtures/mosquitto-2.x.toml +``` + +Generate a JSON conformance report: + +```bash +./target/release/mqtt5-conformance --sut sut.toml --report report.json +``` + +Filter tests by name substring: + +```bash +./target/release/mqtt5-conformance connect +``` + +List all tests without running them: + +```bash +./target/release/mqtt5-conformance --list +``` + +## CLI Arguments + +| Argument | Description | +|----------|-------------| +| `--sut ` | Path to a TOML file describing the System Under Test. If omitted, uses the built-in in-process mqtt-lib broker with WebSocket support. | +| `--report ` | Write a JSON conformance report to this path after the test run completes. | +| All other args | Forwarded to the `libtest_mimic` test harness. Supports `--list`, `--ignored`, `--nocapture`, name substring filters, and all standard test flags. | + +## SUT Descriptor Format + +The SUT descriptor is a TOML file that tells the runner how to reach the broker and what it supports. See `tests/fixtures/external-mqtt5.toml` for a fully annotated reference template, and [BROKER_SETUP.md](../mqtt5-conformance/BROKER_SETUP.md) for step-by-step broker configuration guides. + +```toml +# Human-readable broker identifier shown in reports +name = "my-broker" + +# TCP address (scheme: "mqtt" or bare host:port) +address = "mqtt://127.0.0.1:1883" + +# TLS address (scheme: "mqtts"); empty string = not available +tls_address = "mqtts://127.0.0.1:8883" + +# WebSocket address (scheme: "ws" or "wss"); empty string = not available +ws_address = "ws://127.0.0.1:8080/mqtt" + +[capabilities] +# Spec-advertised values (from CONNACK properties) +max_qos = 2 # u8: 0, 1, or 2 [default: 2] +retain_available = true # bool [default: true] +wildcard_subscription_available = true # bool [default: true] +subscription_identifier_available = true # bool [default: true] +shared_subscription_available = true # bool [default: true] +topic_alias_maximum = 65535 # u16 [default: 65535] +server_keep_alive = 0 # u16 [default: 0] +server_receive_maximum = 65535 # u16 [default: 65535] +maximum_packet_size = 268435456 # u32 [default: 268435456] +assigned_client_id_supported = true # bool [default: true] + +# Broker-imposed limits +max_clients = 0 # u32, 0 = unlimited [default: 0] +max_subscriptions_per_client = 0 # u32, 0 = unlimited [default: 0] +session_expiry_interval_max_secs = 4294967295 # u32 [default: u32::MAX] +enforces_inbound_receive_maximum = true # bool [default: true] + +# Broker-specific behaviors +injected_user_properties = [] # string[] [default: []] +auth_failure_uses_disconnect = false # bool [default: false] +unsupported_property_behavior = "DisconnectMalformed" # string [default: "DisconnectMalformed"] +shared_subscription_distribution = "RoundRobin" # string [default: "RoundRobin"] + +# Access control +acl = false # bool [default: false] + +[capabilities.transports] +tcp = true # bool [default: true] +tls = false # bool [default: false] +websocket = false # bool [default: false] +quic = false # bool [default: false] + +[capabilities.enhanced_auth] +methods = [] # string[] of supported auth methods [default: []] + +[capabilities.hooks] +restart = false # bool: broker can be restarted between tests [default: false] +cleanup = false # bool: broker state can be cleaned up [default: false] + +[hooks] +restart_command = "" # shell command to restart the broker +cleanup_command = "" # shell command to clean up broker state +``` + +Fields not present in the TOML file use their defaults. Most defaults are permissive (max values, features enabled), so you only need to declare fields where your broker diverges from the protocol maximums. + +## Capability-Based Skipping + +Each conformance test declares its requirements via `requires = [...]` in the `#[conformance_test]` attribute. At startup, the CLI evaluates every test's requirements against the SUT's declared capabilities: + +- **All requirements met** — test runs normally +- **Any requirement unmet** — test is marked as "ignored" with a label showing the missing capability (e.g., `[missing: transport.tls]`) + +Ignored tests appear in the report but do not count as failures. This allows the same test suite to validate brokers with different feature sets without false negatives. + +## Report Format + +When `--report ` is provided, the CLI writes a JSON file with this structure: + +```json +{ + "summary": { + "passed": 175, + "failed": 0, + "ignored": 8, + "filtered_out": 0, + "measured": 0 + }, + "capabilities": { + "max_qos": 2, + "retain_available": true, + "wildcard_subscription_available": true, + "subscription_identifier_available": true, + "shared_subscription_available": true, + "injected_user_properties": ["x-mqtt-sender", "x-mqtt-client-id"] + }, + "tests": [ + { + "name": "connect_first_packet_must_be_connect", + "module_path": "mqtt5_conformance::conformance_tests::section3_connect", + "file": "src/conformance_tests/section3_connect.rs", + "line": 42, + "ids": ["MQTT-3.1.0-1"], + "status": "planned", + "unmet_requirement": null + }, + { + "name": "websocket_binary_frames", + "module_path": "mqtt5_conformance::conformance_tests::section6_websocket", + "file": "src/conformance_tests/section6_websocket.rs", + "line": 10, + "ids": ["MQTT-6.0.0-1"], + "status": "skipped", + "unmet_requirement": "transport.websocket" + } + ] +} +``` + +| Field | Description | +|-------|-------------| +| `summary` | Aggregate pass/fail/ignore/filter counts from the test run | +| `capabilities` | Snapshot of the SUT's declared capabilities | +| `tests` | Per-test entries with conformance IDs, source location, and skip status | +| `status` | `"planned"` (ran or will run) or `"skipped"` (requirement unmet) | +| `unmet_requirement` | The first unmet requirement string, or `null` if all requirements met | + +## CI Integration + +```bash +cargo build --release -p mqtt5-conformance-cli +./target/release/mqtt5-conformance --sut sut.toml --report conformance.json +``` + +The CLI exits with code 0 if all non-skipped tests pass, nonzero otherwise. diff --git a/crates/mqtt5-conformance-cli/src/main.rs b/crates/mqtt5-conformance-cli/src/main.rs new file mode 100644 index 00000000..2a9dead3 --- /dev/null +++ b/crates/mqtt5-conformance-cli/src/main.rs @@ -0,0 +1,257 @@ +//! CLI runner for the MQTT v5 conformance test suite. +//! +//! Walks the `mqtt5_conformance::registry::CONFORMANCE_TESTS` distributed +//! slice at startup and turns every registered test into a +//! [`libtest_mimic::Trial`]. Tests whose capability requirements are not +//! satisfied by the configured SUT are wrapped in an ignored trial so the +//! conclusion cleanly distinguishes pass / fail / skipped. +//! +//! ```text +//! mqtt5-conformance # in-process broker +//! mqtt5-conformance --sut external.toml # external broker +//! mqtt5-conformance --sut external.toml --report report.json +//! ``` +//! +//! Any unrecognised argument is forwarded to libtest-mimic, so `--list`, +//! `--ignored`, `--nocapture`, and substring filters behave exactly like +//! `cargo test` output. + +#![warn(clippy::pedantic)] + +use futures_util::FutureExt; +use libtest_mimic::{Arguments, Conclusion, Failed, Trial}; +use mqtt5_conformance::{ + capabilities::{Capabilities, Requirement}, + registry::{ConformanceTest, CONFORMANCE_TESTS}, + sut::{SutDescriptor, SutHandle}, +}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::Arc; +use tokio::runtime::Runtime; + +fn main() -> ExitCode { + let (options, libtest_args) = CliOptions::from_env(); + + let plan = match SutPlan::from_options(&options) { + Ok(plan) => Arc::new(plan), + Err(message) => { + eprintln!("error: {message}"); + return ExitCode::from(2); + } + }; + + let runtime = match Runtime::new() { + Ok(runtime) => Arc::new(runtime), + Err(error) => { + eprintln!("error: failed to start tokio runtime: {error}"); + return ExitCode::from(2); + } + }; + + let capabilities = plan.capabilities_snapshot(&runtime); + let trials = build_trials(&capabilities, &plan, &runtime); + let conclusion = libtest_mimic::run(&libtest_args, trials); + + if let Some(report_path) = options.report.as_deref() { + if let Err(error) = write_report(report_path, &capabilities, &conclusion) { + eprintln!( + "warning: failed to write report {}: {error}", + report_path.display() + ); + } + } + + conclusion.exit_code() +} + +struct CliOptions { + sut: Option, + report: Option, +} + +impl CliOptions { + fn from_env() -> (Self, Arguments) { + let mut sut: Option = None; + let mut report: Option = None; + let mut forwarded: Vec = Vec::new(); + + let mut raw = std::env::args(); + let program = raw.next().unwrap_or_default(); + forwarded.push(program); + + while let Some(arg) = raw.next() { + if let Some(value) = arg.strip_prefix("--sut=") { + sut = Some(PathBuf::from(value)); + } else if arg == "--sut" { + sut = raw.next().map(PathBuf::from); + } else if let Some(value) = arg.strip_prefix("--report=") { + report = Some(PathBuf::from(value)); + } else if arg == "--report" { + report = raw.next().map(PathBuf::from); + } else { + forwarded.push(arg); + } + } + + let libtest = Arguments::from_iter(forwarded); + (Self { sut, report }, libtest) + } +} + +enum SutPlan { + External(Arc), + InProcess, +} + +impl SutPlan { + fn from_options(options: &CliOptions) -> Result { + match options.sut.as_deref() { + Some(path) => { + let descriptor = SutDescriptor::from_file(path) + .map_err(|error| format!("failed to load {}: {error}", path.display()))?; + Ok(Self::External(Arc::new(descriptor))) + } + None => Ok(Self::InProcess), + } + } + + fn capabilities_snapshot(&self, runtime: &Runtime) -> Capabilities { + match self { + Self::External(descriptor) => descriptor.capabilities.clone(), + Self::InProcess => { + let handle = + runtime.block_on(mqtt5_conformance::sut::inprocess_sut_with_websocket()); + handle.capabilities().clone() + } + } + } + + fn build_handle(&self, runtime: &Runtime) -> SutHandle { + match self { + Self::External(descriptor) => SutHandle::External(Box::new((**descriptor).clone())), + Self::InProcess => { + runtime.block_on(mqtt5_conformance::sut::inprocess_sut_with_websocket()) + } + } + } +} + +fn build_trials( + capabilities: &Capabilities, + plan: &Arc, + runtime: &Arc, +) -> Vec { + let mut trials = Vec::with_capacity(CONFORMANCE_TESTS.len()); + for test in CONFORMANCE_TESTS.iter().copied() { + let name = trial_name(&test); + let trial = if let Some(requirement) = first_unmet_requirement(capabilities, test.requires) + { + let skip_label = format!("{name} [missing: {}]", requirement.label()); + Trial::test(skip_label, || Ok(())).with_ignored_flag(true) + } else { + let plan = Arc::clone(plan); + let runtime = Arc::clone(runtime); + Trial::test(name, move || run_trial(&plan, &runtime, &test)) + }; + trials.push(trial); + } + trials +} + +fn trial_name(test: &ConformanceTest) -> String { + match test.ids.first() { + Some(primary) => format!("{}::{} [{primary}]", test.module_path, test.name), + None => format!("{}::{}", test.module_path, test.name), + } +} + +fn first_unmet_requirement( + capabilities: &Capabilities, + requirements: &[Requirement], +) -> Option { + requirements + .iter() + .copied() + .find(|requirement| !requirement.matches(capabilities)) +} + +fn run_trial(plan: &SutPlan, runtime: &Runtime, test: &ConformanceTest) -> Result<(), Failed> { + let handle = plan.build_handle(runtime); + let test = *test; + let future = (test.runner)(handle); + let outcome: Result<(), Box> = runtime.block_on(async { + let result = std::panic::AssertUnwindSafe(future).catch_unwind().await; + tokio::task::yield_now().await; + result + }); + match outcome { + Ok(()) => Ok(()), + Err(payload) => Err(Failed::from(format!( + "{}: {}", + test.name, + panic_message(payload.as_ref()) + ))), + } +} + +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&'static str>() { + (*message).to_owned() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "".to_owned() + } +} + +fn write_report( + path: &Path, + capabilities: &Capabilities, + conclusion: &Conclusion, +) -> std::io::Result<()> { + let tests_by_id = collect_test_results(capabilities); + let report = serde_json::json!({ + "summary": { + "passed": conclusion.num_passed, + "failed": conclusion.num_failed, + "ignored": conclusion.num_ignored, + "filtered_out": conclusion.num_filtered_out, + "measured": conclusion.num_measured, + }, + "capabilities": { + "max_qos": capabilities.max_qos, + "retain_available": capabilities.retain_available, + "wildcard_subscription_available": capabilities.wildcard_subscription_available, + "subscription_identifier_available": capabilities.subscription_identifier_available, + "shared_subscription_available": capabilities.shared_subscription_available, + "injected_user_properties": capabilities.injected_user_properties, + }, + "tests": tests_by_id, + }); + let serialized = serde_json::to_string_pretty(&report).map_err(std::io::Error::other)?; + std::fs::write(path, serialized) +} + +fn collect_test_results(capabilities: &Capabilities) -> Vec { + CONFORMANCE_TESTS + .iter() + .map(|test| { + let unmet = first_unmet_requirement(capabilities, test.requires); + let status = if unmet.is_some() { + "skipped" + } else { + "planned" + }; + serde_json::json!({ + "name": test.name, + "module_path": test.module_path, + "file": test.file, + "line": test.line, + "ids": test.ids, + "status": status, + "unmet_requirement": unmet.map(Requirement::label), + }) + }) + .collect() +} diff --git a/crates/mqtt5-conformance-macros/Cargo.toml b/crates/mqtt5-conformance-macros/Cargo.toml new file mode 100644 index 00000000..9c652d9f --- /dev/null +++ b/crates/mqtt5-conformance-macros/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mqtt5-conformance-macros" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +publish = false +description = "Procedural macros for the MQTT v5 conformance test suite" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0.106" +quote = "1.0.45" +syn = { version = "2.0.117", features = ["full"] } diff --git a/crates/mqtt5-conformance-macros/README.md b/crates/mqtt5-conformance-macros/README.md new file mode 100644 index 00000000..b3c47c73 --- /dev/null +++ b/crates/mqtt5-conformance-macros/README.md @@ -0,0 +1,77 @@ +# mqtt5-conformance-macros + +Proc-macro crate providing the `#[conformance_test]` attribute for the MQTT v5.0 conformance test suite. + +## Usage + +```rust +use mqtt5_conformance_macros::conformance_test; +use mqtt5_conformance::sut::SutHandle; + +#[conformance_test( + ids = ["MQTT-3.1.0-1"], + requires = ["transport.tcp"], +)] +async fn connect_first_packet_must_be_connect(sut: SutHandle) { + // test body runs against any SUT that supports TCP +} +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `ids` | Yes | Array of MQTT conformance statement IDs. Each must start with `MQTT-` (e.g., `"MQTT-3.1.0-1"`). At least one ID is required. | +| `requires` | No | Array of capability requirement strings. Tests are skipped when the SUT lacks a declared requirement. Defaults to empty (no requirements). | + +## Requirement Strings + +The `requires` parameter accepts these capability strings: + +| String | Description | +|--------|-------------| +| `transport.tcp` | SUT supports TCP connections | +| `transport.tls` | SUT supports TLS connections | +| `transport.websocket` | SUT supports WebSocket connections | +| `transport.quic` | SUT supports QUIC connections | +| `max_qos>=0` | SUT supports QoS 0 (always true) | +| `max_qos>=1` | SUT supports QoS 1 or higher | +| `max_qos>=2` | SUT supports QoS 2 | +| `retain_available` | SUT supports retained messages | +| `wildcard_subscription_available` | SUT supports wildcard topic filters | +| `subscription_identifier_available` | SUT supports subscription identifiers | +| `shared_subscription_available` | SUT supports shared subscriptions | +| `enhanced_auth.` | SUT supports a specific enhanced auth method (e.g., `enhanced_auth.SCRAM-SHA-256`) | +| `hooks.restart` | SUT can be restarted between tests | +| `hooks.cleanup` | SUT state can be cleaned up between tests | +| `acl` | SUT supports topic-level access control | + +All requirement strings are validated at compile time. Invalid strings produce a compilation error. + +## Function Signature + +The annotated function must be: + +- **`async`** — synchronous functions are rejected at compile time +- **Exactly one parameter**: `sut: SutHandle` +- **Return type**: `()` (implicit unit return) + +```rust +// Correct +async fn my_test(sut: SutHandle) { ... } + +// Compile errors: +fn not_async(sut: SutHandle) { ... } // must be async +async fn too_many(sut: SutHandle, x: u8) { ... } // exactly one parameter +async fn wrong_return(sut: SutHandle) -> bool { ... } // must return () +``` + +## Generated Output + +The macro generates three items from each annotated function: + +1. **Implementation function** — the user's async body, renamed with a `__conformance_impl_` prefix. + +2. **`#[tokio::test]` wrapper** — compiled under `#[cfg(test)]`, creates a fresh in-process SUT and calls the implementation. This allows `cargo test` to run conformance tests without an external broker. + +3. **Registry entry** — a static `ConformanceTest` struct registered in a `linkme::distributed_slice`. The CLI runner iterates over all entries, evaluates capability requirements against the target SUT, and runs or skips each test accordingly. No runtime registration step is needed. diff --git a/crates/mqtt5-conformance-macros/src/lib.rs b/crates/mqtt5-conformance-macros/src/lib.rs new file mode 100644 index 00000000..411c738d --- /dev/null +++ b/crates/mqtt5-conformance-macros/src/lib.rs @@ -0,0 +1,303 @@ +//! Procedural macros for the MQTT v5 conformance test suite. +//! +//! Provides the `#[conformance_test]` attribute, which both registers a +//! conformance test in the `mqtt5_conformance::registry::CONFORMANCE_TESTS` +//! distributed slice and wraps the body in `#[tokio::test]` so the file +//! still works under `cargo test`. +//! +//! Example: +//! +//! ```ignore +//! use mqtt5_conformance::sut::SutHandle; +//! use mqtt5_conformance_macros::conformance_test; +//! +//! #[conformance_test( +//! ids = ["MQTT-3.12.4-1"], +//! requires = ["transport.tcp"], +//! )] +//! async fn pingresp_sent_on_pingreq(sut: SutHandle) { +//! // ... +//! } +//! ``` +//! +//! The annotated function MUST be `async`, take a single `SutHandle` +//! argument by value, and return `()`. The macro emits both: +//! +//! 1. A `#[tokio::test]` wrapper that constructs an in-process SUT and +//! invokes the body — this is what `cargo test` runs. +//! 2. A `static` registration in `CONFORMANCE_TESTS` that the CLI runner +//! uses to enumerate every test across the workspace at link time. + +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::{format_ident, quote}; +use syn::{ + parse::{Parse, ParseStream}, + parse_macro_input, + punctuated::Punctuated, + Error, Expr, ExprArray, ExprLit, ItemFn, Lit, LitStr, Meta, ReturnType, Token, +}; + +struct ConformanceTestArgs { + ids: Vec, + requires: Vec, +} + +impl Parse for ConformanceTestArgs { + fn parse(input: ParseStream) -> syn::Result { + let metas: Punctuated = Punctuated::parse_terminated(input)?; + let mut ids: Option> = None; + let mut requires: Option> = None; + + for meta in metas { + let Meta::NameValue(nv) = meta else { + return Err(Error::new_spanned( + meta, + "expected `key = [..]` arguments to #[conformance_test]", + )); + }; + let key = nv + .path + .get_ident() + .ok_or_else(|| Error::new_spanned(&nv.path, "expected identifier key"))? + .clone(); + let lits = parse_string_array(&nv.value)?; + + if key == "ids" { + if ids.is_some() { + return Err(Error::new_spanned(key, "duplicate `ids` argument")); + } + ids = Some(lits); + } else if key == "requires" { + if requires.is_some() { + return Err(Error::new_spanned(key, "duplicate `requires` argument")); + } + requires = Some(lits); + } else { + return Err(Error::new_spanned( + key, + "unknown key (expected `ids` or `requires`)", + )); + } + } + + let ids = + ids.ok_or_else(|| Error::new(Span::call_site(), "missing required `ids = [..]`"))?; + if ids.is_empty() { + return Err(Error::new( + Span::call_site(), + "`ids` must contain at least one statement identifier", + )); + } + Ok(Self { + ids, + requires: requires.unwrap_or_default(), + }) + } +} + +fn parse_string_array(value: &Expr) -> syn::Result> { + let array: &ExprArray = match value { + Expr::Array(a) => a, + other => { + return Err(Error::new_spanned( + other, + "expected an array literal of string literals", + )); + } + }; + let mut out = Vec::with_capacity(array.elems.len()); + for elem in &array.elems { + let Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) = elem + else { + return Err(Error::new_spanned( + elem, + "array elements must be string literals", + )); + }; + out.push(s.clone()); + } + Ok(out) +} + +fn validate_id_format(id: &LitStr) -> syn::Result<()> { + let value = id.value(); + if !value.starts_with("MQTT-") { + return Err(Error::new_spanned( + id, + "conformance id must start with `MQTT-` (e.g. `MQTT-3.12.4-1`)", + )); + } + Ok(()) +} + +fn validate_require_spec(spec: &LitStr) -> syn::Result<()> { + let value = spec.value(); + if let Some(rest) = value.strip_prefix("max_qos>=") { + if !matches!(rest, "0" | "1" | "2") { + return Err(Error::new_spanned(spec, "max_qos>= must be 0, 1, or 2")); + } + return Ok(()); + } + if value.starts_with("enhanced_auth.") { + return Ok(()); + } + match value.as_str() { + "transport.tcp" + | "transport.tls" + | "transport.websocket" + | "transport.quic" + | "retain_available" + | "wildcard_subscription_available" + | "subscription_identifier_available" + | "shared_subscription_available" + | "hooks.restart" + | "hooks.cleanup" + | "acl" => Ok(()), + _ => Err(Error::new_spanned( + spec, + "unknown requirement spec (see Requirement::from_spec for accepted forms)", + )), + } +} + +fn requirement_to_tokens(spec: &LitStr) -> syn::Result { + let value = spec.value(); + let path = quote! { ::mqtt5_conformance::capabilities::Requirement }; + if let Some(rest) = value.strip_prefix("max_qos>=") { + let n: u8 = match rest { + "0" => 0, + "1" => 1, + "2" => 2, + _ => { + return Err(Error::new_spanned(spec, "max_qos>= must be 0, 1, or 2")); + } + }; + return Ok(quote! { #path::MinQos(#n) }); + } + if let Some(method) = value.strip_prefix("enhanced_auth.") { + return Ok(quote! { #path::EnhancedAuthMethod(#method) }); + } + let variant = match value.as_str() { + "transport.tcp" => quote! { TransportTcp }, + "transport.tls" => quote! { TransportTls }, + "transport.websocket" => quote! { TransportWebSocket }, + "transport.quic" => quote! { TransportQuic }, + "retain_available" => quote! { RetainAvailable }, + "wildcard_subscription_available" => quote! { WildcardSubscriptionAvailable }, + "subscription_identifier_available" => quote! { SubscriptionIdentifierAvailable }, + "shared_subscription_available" => quote! { SharedSubscriptionAvailable }, + "hooks.restart" => quote! { HookRestart }, + "hooks.cleanup" => quote! { HookCleanup }, + "acl" => quote! { Acl }, + _ => { + return Err(Error::new_spanned( + spec, + "unknown requirement spec (see Requirement::from_spec for accepted forms)", + )); + } + }; + Ok(quote! { #path::#variant }) +} + +fn validate_signature(item_fn: &ItemFn) -> syn::Result<()> { + if item_fn.sig.asyncness.is_none() { + return Err(Error::new_spanned( + &item_fn.sig, + "#[conformance_test] requires an async fn", + )); + } + if item_fn.sig.inputs.len() != 1 { + return Err(Error::new_spanned( + &item_fn.sig.inputs, + "#[conformance_test] async fn must take exactly one argument: `sut: SutHandle`", + )); + } + if !matches!(item_fn.sig.output, ReturnType::Default) { + return Err(Error::new_spanned( + &item_fn.sig.output, + "#[conformance_test] async fn must return ()", + )); + } + Ok(()) +} + +#[proc_macro_attribute] +pub fn conformance_test(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(attr as ConformanceTestArgs); + let item_fn = parse_macro_input!(item as ItemFn); + + if let Err(e) = validate_signature(&item_fn) { + return e.to_compile_error().into(); + } + for id in &args.ids { + if let Err(e) = validate_id_format(id) { + return e.to_compile_error().into(); + } + } + for req in &args.requires { + if let Err(e) = validate_require_spec(req) { + return e.to_compile_error().into(); + } + } + + let user_name = item_fn.sig.ident.clone(); + let user_name_str = user_name.to_string(); + let impl_ident = format_ident!("__conformance_impl_{}", user_name_str); + let runner_ident = format_ident!("__conformance_runner_{}", user_name_str); + let registry_ident = format_ident!("__CONFORMANCE_TEST_{}", user_name_str.to_uppercase()); + + let mut impl_fn = item_fn.clone(); + impl_fn.sig.ident = impl_ident.clone(); + impl_fn.vis = syn::Visibility::Inherited; + + let ids = args.ids.iter().map(|s| { + let v = s.value(); + quote! { #v } + }); + let mut requires_tokens = Vec::with_capacity(args.requires.len()); + for spec in &args.requires { + match requirement_to_tokens(spec) { + Ok(tokens) => requires_tokens.push(tokens), + Err(e) => return e.to_compile_error().into(), + } + } + + let expanded = quote! { + #impl_fn + + #[cfg(test)] + #[::tokio::test] + async fn #user_name() { + let sut = ::mqtt5_conformance::sut::inprocess_sut_with_websocket().await; + #impl_ident(sut).await; + } + + fn #runner_ident( + sut: ::mqtt5_conformance::sut::SutHandle, + ) -> ::mqtt5_conformance::registry::TestFuture { + ::std::boxed::Box::pin(async move { + #impl_ident(sut).await; + }) + } + + #[::mqtt5_conformance::registry::linkme::distributed_slice( + ::mqtt5_conformance::registry::CONFORMANCE_TESTS + )] + #[linkme(crate = ::mqtt5_conformance::registry::linkme)] + static #registry_ident: ::mqtt5_conformance::registry::ConformanceTest = + ::mqtt5_conformance::registry::ConformanceTest { + name: #user_name_str, + module_path: ::std::module_path!(), + file: ::std::file!(), + line: ::std::line!(), + ids: &[#(#ids),*], + requires: &[#(#requires_tokens),*], + runner: #runner_ident, + }; + }; + + expanded.into() +} diff --git a/crates/mqtt5-conformance/BROKER_SETUP.md b/crates/mqtt5-conformance/BROKER_SETUP.md new file mode 100644 index 00000000..fdda7d12 --- /dev/null +++ b/crates/mqtt5-conformance/BROKER_SETUP.md @@ -0,0 +1,236 @@ +# Broker Setup Guide for Conformance Testing + +The conformance suite ships with fixture TOML files that describe each broker's address, capabilities, and behavior. This guide shows how to configure Mosquitto, EMQX, and HiveMQ CE to match those fixtures so the test runner produces accurate results. + +## General Requirements + +Every broker must have these features enabled: + +- MQTT v5.0 protocol support +- QoS 0, 1, and 2 +- Retained messages +- Wildcard subscriptions (`+` and `#`) +- Subscription identifiers +- Assigned client IDs (server assigns an ID when the client sends an empty string) + +### Test Coverage by Feature + +Most tests require only a TCP listener. The counts below show how many tests gate on each capability, so you can prioritize which features to configure first: + +| Capability | Tests | +|------------|-------| +| TCP only (core protocol) | ~118 | +| QoS 1 | ~21 | +| QoS 2 | ~19 | +| Retained messages | ~13 | +| Shared subscriptions | ~11 | +| WebSocket | 3 | + +Total: **183 conformance tests**. + +## Mosquitto 2.x + +**Fixture file:** `tests/fixtures/mosquitto-2.x.toml` + +### Installation + +```bash +# Debian/Ubuntu +sudo apt install mosquitto + +# macOS +brew install mosquitto +``` + +### Configuration + +Create or edit `mosquitto.conf`: + +``` +listener 1883 +protocol mqtt + +listener 8883 +protocol mqtt +certfile /path/to/server.pem +keyfile /path/to/server.key +cafile /path/to/ca.pem + +listener 8080 +protocol websockets + +allow_anonymous true +max_topic_alias 10 +max_inflight_messages 20 +``` + +Key values that match the fixture: + +| Fixture field | Value | Mosquitto setting | +|---------------|-------|-------------------| +| `topic_alias_maximum` | 10 | `max_topic_alias 10` | +| `server_receive_maximum` | 20 | `max_inflight_messages 20` | +| `maximum_packet_size` | 268435456 | Mosquitto default (256 MB) | + +### Start + +```bash +mosquitto -c /path/to/mosquitto.conf +``` + +### Expected Skips + +Mosquitto's default config does not support enhanced authentication or ACL, so tests requiring those capabilities are skipped automatically. + +### Run + +```bash +./target/release/mqtt5-conformance --sut tests/fixtures/mosquitto-2.x.toml +``` + +## EMQX 5.x + +**Fixture file:** `tests/fixtures/emqx.toml` + +### Installation (Docker) + +```bash +docker run -d --name emqx \ + -p 1883:1883 \ + -p 8083:8083 \ + -p 8883:8883 \ + -p 18083:18083 \ + emqx/emqx:5.8 +``` + +Port 18083 is the EMQX Dashboard (HTTP API) used for configuration. + +### Key Configuration + +EMQX defaults work for most settings. Verify or adjust via the Dashboard or `emqx.conf`: + +| Fixture field | Value | Notes | +|---------------|-------|-------| +| `topic_alias_maximum` | 65535 | EMQX default | +| `server_receive_maximum` | 32 | EMQX default | +| `maximum_packet_size` | 1048576 | EMQX default is 1 MB (not 256 MB) | +| WebSocket port | 8083 | EMQX default (not the common 8080) | + +#### Enhanced Auth (SCRAM-SHA-256) + +The fixture declares `methods = ["SCRAM-SHA-256", "SCRAM-SHA-512"]`. To enable SCRAM authentication, configure it through the EMQX Dashboard under **Authentication** → **SCRAM** or via the REST API. + +#### ACL + +The fixture sets `acl = true`. Configure publish/subscribe ACL rules through the Dashboard under **Authorization** or via `emqx.conf`. + +### Expected Capabilities + +EMQX supports nearly the full conformance profile: TCP, TLS, WebSocket, QUIC, shared subscriptions, enhanced auth, and ACL. + +### Run + +```bash +./target/release/mqtt5-conformance --sut tests/fixtures/emqx.toml +``` + +## HiveMQ CE + +**Fixture file:** `tests/fixtures/hivemq-ce.toml` + +### Installation + +**Docker:** + +```bash +docker run -d --name hivemq \ + -p 1883:1883 \ + -p 8000:8000 \ + hivemq/hivemq-ce:latest +``` + +**Zip download:** extract and run `bin/run.sh` from the [HiveMQ CE releases](https://github.com/hivemq/hivemq-community-edition/releases). + +### Configuration + +Edit `conf/config.xml` to add a WebSocket listener: + +```xml + + + + + 1883 + 0.0.0.0 + + + 8000 + 0.0.0.0 + /mqtt + + + +``` + +Key values that match the fixture: + +| Fixture field | Value | Notes | +|---------------|-------|-------| +| `topic_alias_maximum` | 5 | HiveMQ CE default | +| `server_receive_maximum` | 10 | HiveMQ CE default | +| `maximum_packet_size` | 268435456 | HiveMQ CE default (256 MB) | +| WebSocket port | 8000 | Not the common 8080 | +| TLS | not available | Enterprise Edition only | + +### Expected Skips + +HiveMQ CE does not support TLS (Enterprise only), enhanced authentication, or ACL. Tests requiring those capabilities are skipped automatically. + +### Run + +```bash +./target/release/mqtt5-conformance --sut tests/fixtures/hivemq-ce.toml +``` + +## mqtt-lib (External Process) + +**Fixture file:** `tests/fixtures/external-mqtt5.toml` + +This runs the mqtt-lib broker as a standalone process and tests it over real TCP, rather than the in-process mode used by `cargo test`. + +### Build + +```bash +cargo build --release -p mqttv5-cli +``` + +### Start + +```bash +./target/release/mqttv5 broker --host 0.0.0.0:1883 +``` + +### Run + +```bash +./target/release/mqtt5-conformance --sut tests/fixtures/external-mqtt5.toml +``` + +## Custom Broker Setup + +To test a broker not listed here: + +1. Copy `tests/fixtures/external-mqtt5.toml` — it is the fully annotated reference template with every field documented. +2. Adjust addresses and capabilities to match your broker's actual configuration. +3. Start your broker and run: + ```bash + ./target/release/mqtt5-conformance --sut your-broker.toml + ``` + +Tests whose requirements exceed your broker's declared capabilities are skipped automatically. + +## Further Reading + +- [Conformance suite README](README.md) — test organization, requirement strings, writing new tests +- [CLI README](../mqtt5-conformance-cli/README.md) — full SUT descriptor schema, report format, CI integration +- [`external-mqtt5.toml`](tests/fixtures/external-mqtt5.toml) — annotated reference template for custom descriptors diff --git a/crates/mqtt5-conformance/CONFORMANCE_DIARY.md b/crates/mqtt5-conformance/CONFORMANCE_DIARY.md index 37e5a3f6..38194842 100644 --- a/crates/mqtt5-conformance/CONFORMANCE_DIARY.md +++ b/crates/mqtt5-conformance/CONFORMANCE_DIARY.md @@ -38,6 +38,475 @@ ## Diary Entries +### Fix: FileBackend session expiry deadlock (Rust 2021 edition) + +`FileBackend::get_session()` had a deadlock triggered by expired sessions. +The code used an `if let` with a temporary `RwLockReadGuard`: + +```rust +if let Some(session) = self.sessions_cache.read().await.get(client_id).cloned() { + if session.is_expired() { + self.remove_session(client_id).await?; // DEADLOCK +``` + +In Rust 2021 edition, temporaries in `if let` conditions live for the entire +block body. The read guard was still held when `remove_session()` tried to +acquire a write lock on the same `sessions_cache`. Fix: extract the read into +a `let` binding so the guard drops at the semicolon before the body executes. + +This only manifested with `clean_start=false` after a session with short +expiry had expired — the exact scenario tested by `session_discarded_after_expiry`. + +Discovery: the `mqttv5-cli` binary was using `mqtt5 = "0.29"` from crates.io +instead of the local code. The workspace `[patch.crates-io]` didn't apply +because the local version `0.31.1` didn't satisfy `^0.29`. Updated to +`mqtt5 = "0.31"` so the patch resolves correctly. + +### Fix: shared subscription message matching in RawTestClient + +`RawTestClient::subscribe()` stored the full `$share/group/topic` filter for +local message routing. When messages arrived on topic `topic`, the matching +check `topic_matches_filter("topic", "$share/group/topic")` returned false. + +Fix: import `strip_shared_subscription_prefix()` and use the extracted topic +filter for local matching. + +### Fix: conformance test isolation for parallel execution + +Multiple shared subscription tests used hardcoded topic names (`tasks`, +`topic`) causing cross-pollination when tests ran in parallel. Each test +now generates unique topic names via `unique_client_id()`. + +The `$SYS` wildcard test (`dollar_topics_not_matched_by_root_wildcards`) +subscribed to `#` which caught messages from all parallel tests. Changed +from count-based assertion to content filtering — checks that no received +messages have `$`-prefixed topics, ignoring non-`$SYS` messages from +parallel tests. + +### Fix: MQTT message ordering violation in `CallbackManager::dispatch()` + +The conformance CLI runner exposed a real bug in `crates/mqtt5/src/callback.rs`. +`CallbackManager::dispatch()` was spawning a separate `tokio::spawn` per +callback invocation, which does not guarantee execution order on a multi-threaded +runtime. This violates `MQTT-4.6.0-5` (message ordering per-topic, per-QoS). + +The bug was masked during `cargo test` because each `#[tokio::test]` spins up +a per-test current-thread runtime where spawned tasks are executed in FIFO +order. The CLI runner uses one shared multi-threaded `Runtime::new()`, which +exposed the race: 4 out of 10 runs of `message_ordering_preserved_same_qos` +failed with out-of-order delivery. + +**Fix**: replaced per-callback `tokio::spawn` with a single FIFO worker task +per `CallbackManager`. A `tokio::sync::mpsc::unbounded_channel` queues +`DispatchItem` batches (callbacks + message); a single consumer task drains +the channel and invokes callbacks sequentially. This preserves both invariants: + +1. **Non-blocking dispatch** — `dispatch()` returns immediately after channel send +2. **Sequential ordering** — a single worker processes items in submission order + +The worker is lazily spawned on first dispatch via `OnceLock`. + +Verification: 20/20 deterministic passes of `message_ordering` via CLI runner +(was 6/10 before fix). Full suite: 181/181 passing. All 411 mqtt5 unit tests +passing including `test_dispatch_does_not_block_on_slow_callback`. + +### Phase I — Bulk migration of test files into `src/conformance_tests/` — COMPLETE + +Phase I migrates every vendor-neutral test from `tests/section*.rs` to +`src/conformance_tests/section*.rs`, rewriting each test to use the +`#[conformance_test(ids = [...], requires = [...])]` proc-macro and take +`sut: SutHandle` as its only parameter. After migration the tests live +inside the library crate so the CLI runner can walk +`linkme::distributed_slice` to enumerate every test at link time, and +the same bodies are picked up by `cargo test --lib --features +inprocess-fixture` for development. + +1. **21 files migrated** (in completion order): + - `section4_error_handling` (2 tests, 70 lines) + - `section6_websocket` (3 tests, 136 lines) + - `section3_publish_flow` (5 tests, 187 lines) + - `section1_data_repr` (5 tests, 195 lines) + - `section4_flow_control` (3 tests, 250 lines) + - `section3_disconnect` (8 tests, 264 lines) + - `section3_publish_alias` (4 tests, 319 lines) + - `section4_enhanced_auth` (7 tests, 342 lines) + - `section3_final_conformance` (7 tests, 369 lines) + - `section3_qos_ack` (8 tests, 376 lines) + - `section3_unsubscribe` (10 tests, 441 lines) + - `section4_shared_sub` (10 tests, 454 lines) + - `section3_publish_advanced` (7 tests, 490 lines) + - `section4_topic` (10 tests, 527 lines) + - `section3_connack` (11 tests, 532 lines) + - `section3_subscribe` (12 tests, 651 lines) + - `section3_connect_extended` (14 tests, 660 lines) + - `section3_connect` (21 tests, 707 lines) + - `section4_qos` (11 tests, 743 lines) + - `section3_publish` (21 tests, 929 lines) + - `section3_ping` (5 tests, completed in Phase G as the proof-of-concept) + +2. **4 files retained in `tests/`** as vendor-specific stragglers gated + by `inprocess_sut_with_config(...)`. These exercise broker + configuration flags (`max_qos`, `topic_alias_maximum`, ACL grammar, + enhanced-auth provider injection) that are not expressible in the + vendor-neutral capability matrix, so they remain + in-process-only `#[tokio::test]` cases until v2 of the capability + DSL: `section3_publish_flow.rs`, `section3_connack.rs`, + `section3_subscribe.rs`, `section4_enhanced_auth.rs`. + +3. **Vendor-neutrality split rule**: a test migrates iff it touches + only `inprocess_sut()` and never calls + `inprocess_sut_with_config(...)`. Tests that mutate broker config + stay behind because they cannot run against an external SUT — the + capability matrix can only describe what a broker advertises, not + reconfigure it on the fly. + +4. **Standard import block** for every migrated file: + ```rust + use crate::conformance_test; + use crate::harness::unique_client_id; + use crate::raw_client::{RawMqttClient, RawPacketBuilder}; + use crate::sut::SutHandle; + use crate::test_client::TestClient; + use mqtt5_protocol::types::*; + ``` + No `use mqtt5::*` anywhere — `mqtt5_protocol` is the + vendor-neutral types crate. The migrated bodies have zero + dependency on the in-tree broker implementation; the + `inprocess-fixture` feature only matters at SUT-construction time. + +5. **Capability assertions**: every migrated test annotates its + `requires = [...]` list against the strings recognised by + `Requirement::parse` in `capabilities.rs` (`transport.tcp`, + `max_qos>=1`, `max_qos>=2`, `retain_available`, + `wildcard_subscription_available`, `subscription_identifier_available`, + `shared_subscription_available`, `assigned_client_id_supported`). + The proc-macro validates the requirement string at compile time, so + typos and unknown capabilities are caught at `cargo check`. + +6. **Property-comparison anti-pattern fix**: `section3_publish.rs` — + the largest file at 929 lines and the worst offender — used to + `assert_eq!` raw user-property vectors that included broker-injected + properties (`x-mqtt-sender`, `x-mqtt-client-id`). The migrated + version uses `assertions::expect_user_properties_subset(actual, + expected, sut.injected_user_properties())` which automatically + tolerates declared injected properties on top of the asserted set. + This is the single most important fix for vendor neutrality: + without it every property-comparison test would fail spuriously + against any non-mqtt-lib broker. + +7. **Result**: 218 lib tests + 16 integration tests = 234 tests + passing under `cargo test -p mqtt5-conformance --features + inprocess-fixture`. Pedantic clippy clean + (`cargo clippy -p mqtt5-conformance --all-targets --features + inprocess-fixture -- -D warnings -W clippy::pedantic`). + +8. **Clippy doc_markdown cleanup**: 14 missing-backticks errors found + on the first pedantic pass — `QoS`, `ShareName`, `ShareNames`, + `no_local`, `TopicFilterInvalid` — across six newly-touched files. + Fixed by wrapping each identifier in backticks per the + `clippy::doc_markdown` lint rules. ALL MODIFIED CODE IS MY CODE: + even pre-existing doc strings copied from the old `tests/` files + had to be cleaned up because the migration touched the file. + +9. **Module declaration order** in `src/conformance_tests/mod.rs` is + alphabetical within each section group. The 21 modules now declared: + `section1_data_repr`, `section3_connack`, `section3_connect`, + `section3_connect_extended`, `section3_disconnect`, + `section3_final_conformance`, `section3_ping`, `section3_publish`, + `section3_publish_advanced`, `section3_publish_alias`, + `section3_publish_flow`, `section3_qos_ack`, `section3_subscribe`, + `section3_unsubscribe`, `section4_enhanced_auth`, + `section4_error_handling`, `section4_flow_control`, `section4_qos`, + `section4_shared_sub`, `section4_topic`, `section6_websocket`. + +10. **Next phase**: Phase I extraction. Once the four straggler + `tests/section*.rs` files have either grown an external-SUT + capability path or been quarantined behind a feature, we can + `git subtree split -P crates/mqtt5-conformance -b + conformance-extract` and push the result to a standalone repo + `mqtt5-conformance-platform`. The `inprocess-fixture` feature + becomes the only path that depends on `mqtt5`, and downstream + consumers wire their own broker behind a `SutHandle::External`. + +### Phase H — Profiles + example SUTs — COMPLETE + +Phase H ships the vendor-neutral descriptor ecosystem that closes out the +in-tree refactor ahead of the standalone-repo extraction in Phase I. + +1. **`profiles.toml`** — top-level conformance profiles deserializable as + `BTreeMap`: + - `core-broker` — TCP-only, QoS 2, retain, subscription identifiers. + No shared subs, no TLS, no WebSocket, no QUIC. This is the "minimum + bar" a broker must clear to claim MQTT v5 core conformance. + - `core-broker-tls` — same as `core-broker` plus `transports.tls`. The + second-tier profile most production brokers target. + - `full-broker` — every optional capability enabled: TCP + TLS + WS, + shared subs, `topic_alias_maximum = 65535`, ACL, enhanced auth with + `SCRAM-SHA-256`, restart + cleanup hooks. Anchors the upper bound + for "100% conformance" claims. + +2. **`tests/fixtures/*.toml`** — five ready-to-run SUT descriptors: + - `inprocess.toml` — default in-process fixture used by `cargo test + --features inprocess-fixture`. Empty addresses (filled by the + harness), TCP-only, broker-injected user properties declared + (`x-mqtt-sender`, `x-mqtt-client-id`). + - `external-mqtt5.toml` — mqtt-lib's own `mqttv5` binary running + standalone on `127.0.0.1:1883`. TCP-only to match the CI broker + launched by `conformance.yml`. + - `mosquitto-2.x.toml` — Eclipse Mosquitto 2.x with TCP, TLS, and + WebSocket. `topic_alias_maximum = 10` reflects Mosquitto's + conservative default. Includes a `restart_command` stanza. + - `emqx.toml` — EMQX broker with every transport including QUIC, + ACL enabled, and SCRAM-SHA-{256,512} as declared enhanced-auth + methods. + - `hivemq-ce.toml` — HiveMQ Community Edition with TCP + WebSocket. + No TLS in CE; ACL off; `topic_alias_maximum = 5` matching HiveMQ's + CE cap. + +3. **Schema unit tests** — six new tests keep the fixtures honest at + `cargo test` time, without ever contacting a broker: + - `capabilities::tests::profiles_toml_parses_against_capabilities_schema` + — `include_str!("../profiles.toml")`, deserialize to + `BTreeMap`, verify every required profile + key is present and the TCP / TLS / WebSocket / ACL flags match + expectations. + - `sut::tests::fixture_{inprocess,external_mqtt5,mosquitto,emqx,hivemq_ce}_parses` + — one test per fixture, each loading via `SutDescriptor::from_str` + and asserting the name, the relevant transport flags, and any + broker-specific claims (EMQX has QUIC + ACL, HiveMQ CE has + WebSocket but no TLS, etc.). `external-mqtt5` asserts the + `127.0.0.1:1883` socket address round-trips through + `tcp_socket_addr()`. + +4. **`.github/workflows/conformance.yml`** — three-job CI workflow that + wires the CLI runner into the pipeline: + - `inprocess` — `cargo build --release -p mqtt5-conformance-cli`, + `./target/release/mqtt5-conformance`, then a second run with + `--report conformance-report.json` uploaded as an artifact. + Exercises the default in-process fixture path end-to-end. + - `external-mqtt5` — builds `mqttv5-cli` and the conformance CLI, + spawns `mqttv5 broker --host 127.0.0.1:1883 --allow-anonymous true` + in the background with PID captured via `echo $!`, runs + `mqtt5-conformance --sut + crates/mqtt5-conformance/tests/fixtures/external-mqtt5.toml + --report conformance-report.json`, uploads both broker.log and the + report, and kills the broker in an `if: always()` step. + - `fixtures` — `cargo test -p mqtt5-conformance --lib --features + inprocess-fixture` to run the six schema unit tests in isolation + so fixture regressions show up even on PRs that don't touch the + CLI. + +5. **Fixture / CI-broker alignment gotcha** — the first draft of + `external-mqtt5.toml` declared `tls = true` and `websocket = true`, + but the CI broker launches with only `--host 127.0.0.1:1883` (no TLS + cert, no WS port). That would skip zero tests locally but fail every + TLS/WS-gated test in CI. Trimmed the fixture to TCP-only and + tightened `fixture_external_mqtt5_parses` to assert + `!transports.tls`. Follow-up: when Phase I (or earlier) introduces a + CI job that generates test certs and launches the broker on + `--tls-port`, the fixture flips back and a dedicated TLS job can + exercise the gated tests. + +6. **End-to-end verification** — with the `mqttv5` release binary + running locally on `127.0.0.1:1883`: + - `./target/release/mqtt5-conformance --sut + crates/mqtt5-conformance/tests/fixtures/external-mqtt5.toml` + → 5 passed, 0 failed, 0 ignored. + - `./target/release/mqtt5-conformance --sut + crates/mqtt5-conformance/tests/fixtures/hivemq-ce.toml` + → 5 passed, 0 failed, 0 ignored (the HiveMQ fixture still declares + `tcp = true` so every POC test qualifies). + Full suite: `cargo test -p mqtt5-conformance --lib --features + inprocess-fixture` reports 42 passed (36 prior + 6 new schema tests). + `cargo clippy --all-targets --workspace -- -D warnings -W + clippy::pedantic` is clean. + +7. **Deferred to Phase I** — migrating the 21 integration test files + under `tests/section*.rs` (8,681 lines) from the legacy + `ConformanceBroker` / `TestClient`-in-tests pattern onto the + registry-aware `#[conformance_test]` macro. The POC under + `src/conformance_tests/section3_ping.rs` is sufficient to prove the + runner works; bulk migration is a mechanical refactor best done as + its own phase. + +### Phase G — Proc-macro + CLI runner — COMPLETE + +Two new workspace crates land `#[conformance_test]` and the external-SUT +runner on top of the Phase F work: + +1. **`crates/mqtt5-conformance-macros`** — proc-macro crate exposing + `#[conformance_test(ids = [...], requires = [...])]`. The macro: + - Validates every `id` begins with `MQTT-` at compile time. + - Validates every `requires` string against the `Requirement` DSL at + compile time and emits **literal enum variant tokens** (no runtime + `from_spec` calls — static initializers can't call non-const fns). + - Rewrites the annotated fn into `__conformance_impl_` once, and + emits: + - A `#[cfg(test)] #[tokio::test]` wrapper so `cargo test` still runs + the suite against the default in-process fixture during development. + - A fn-pointer runner registered in + `linkme::distributed_slice(CONFORMANCE_TESTS)` so the CLI runner + observes every test across the workspace at link time. + +2. **`crates/mqtt5-conformance-cli`** — `libtest-mimic`-backed binary + `mqtt5-conformance` that walks `CONFORMANCE_TESTS`, evaluates + capabilities against a `SutPlan`, and builds a `Vec`: + - `--sut PATH` loads an external `SutDescriptor`; absent means the + in-process fixture (via `inprocess_sut().await`). + - Each trial constructs its own fresh `SutHandle` inside the runner so + tests can't leak broker state to each other — identical to the + per-test-fresh-broker semantics the existing `#[tokio::test]` + wrappers provide. + - Tests whose `requires` aren't satisfied are emitted as + `Trial::test(..., || Ok(())).with_ignored_flag(true)` with the unmet + capability in the trial name, so libtest-mimic's output correctly + reports `passed / failed / ignored` without running a broker per + skipped test. + - `--report PATH` writes a JSON summary with per-test status and the + capability matrix used to evaluate skips. + - Any non-`--sut`/`--report` argument is forwarded to libtest-mimic, + so `--list`, `--ignored`, filter substrings all Just Work. + +3. **Registry + `extern crate self`** — `src/registry.rs` exposes the + `ConformanceTest` struct and the `CONFORMANCE_TESTS` distributed slice. + Because the macro emits `::mqtt5_conformance::...` paths, the library + adds `extern crate self as mqtt5_conformance;` so those paths resolve + when the macro is used inside the crate itself. + +4. **POC migration** — `section3_ping` (5 tests) moved to + `src/conformance_tests/section3_ping.rs` as a library module. Integration + tests under `tests/*.rs` compile as separate test binaries, so their + `distributed_slice` entries would be invisible to the CLI binary — tests + must live under `src/` to link into the runner. The old + `tests/section3_ping.rs` was deleted to avoid duplicate registration. + +**Verification:** + +- `cargo test -p mqtt5-conformance --lib --features inprocess-fixture` — + 36 passing, includes 5 POC tests via the `#[cfg(test)] #[tokio::test]` + wrappers. +- `./target/release/mqtt5-conformance` (in-process) — 5 passed, 0 failed, + 0 ignored. Same 5 tests, now driven by the linkme registry. +- `./target/release/mqtt5-conformance --sut /tmp/external.toml` against a + standalone `mqttv5 broker` instance — 5 passed, 0 failed. Validates the + external-SUT path end-to-end. +- Restricted SUT (`transports.tcp = false`) — all 5 tests correctly + marked `ignored` with `[missing: transport.tcp]` in the trial name. +- `cargo clippy --all-targets --workspace -- -D warnings -W clippy::pedantic` — clean. + +**Follow-up for Phase H:** + +- Migrate the remaining 21 test files from `tests/*.rs` to + `src/conformance_tests/*.rs`, annotating each test with + `#[conformance_test(...)]`. Mechanical; Phase F already did the hard + vendor-neutrality work. +- Add `profiles.toml` + example `sut.toml` fixtures (mqtt-lib, Mosquitto, + EMQX, HiveMQ CE). +- Wire CI to run the CLI against a freshly-built `mqttv5` binary and + assert 100% pass on the declared statement set. + +### Phase F — Migrate all test files off mqtt5 re-exports — COMPLETE + +All 22 integration test files now compile against the vendor-neutral `SutHandle` + `TestClient` API with **zero `use mqtt5::*` imports** in test bodies. The only remaining `mqtt5` dependency is behind the `inprocess-fixture` feature (in `harness.rs` and `sut/inprocess.rs`), exactly as Phase C planned. + +**Final test count: 229 passing integration tests** across 22 files: + +| File | Tests | Notes | +|---|---|---| +| section1_5_data_representation | 5 | | +| section3_connect | 21 | | +| section3_connack | 11 | | +| section3_publish | 22 | Property-assertion anti-pattern fixed via `expect_user_properties_subset` | +| section3_publish_advanced | 7 | Overlapping subs, message expiry, response topic | +| section3_subscribe | 14 | ACL tests quarantined behind `requires = ["acl"]` | +| section3_suback | 8 | | +| section3_unsubscribe | 7 | | +| section3_unsuback | 7 | | +| section3_pingreq | 5 | | +| section3_disconnect | 8 | | +| section3_qos_ack | 9 | Clean QoS state-machine tests, migrated first | +| section3_flow_control | 3 | | +| section3_auth_reserved | 1 | | +| section4_topic | 13 | Wildcard, `$`-prefix, multi-level, message ordering | +| section4_qos | 12 | Full QoS1/QoS2 outbound state-machine coverage | +| section4_shared_sub | 10 | `$share/` routing, round-robin, PUBACK rejection, granted-QoS downgrade | +| section4_enhanced_auth | 7 | Operator pre-configures `CHALLENGE-RESPONSE` per `sut.toml` | +| section4_13_error_handling | 2 | | +| section6_websocket | 3 | Validates `transport.rs` abstraction | +| extras_phase1 | 14 | Extended CONNECT / will / session | +| extras_final | 30 | Misc normative-statement coverage | + +**Verification passed:** +- `cargo check -p mqtt5-conformance --features inprocess-fixture --tests` — clean +- `cargo test -p mqtt5-conformance --features inprocess-fixture` — 229/229 pass +- `cargo clippy --all-targets --workspace -- -D warnings -W clippy::pedantic` — clean + +**Gotchas from the final migration pass (section4_topic, section4_qos, section4_shared_sub):** + +1. **`TestClient::publish` takes `&[u8]`, not `Vec`** — strip `.to_vec()` from all call sites. `b"literal".to_vec()` → `b"literal"`. +2. **`format!("msg-{i}").as_bytes()`** — works as a rvalue-temporary borrow because the `String` lives until end-of-statement, but cleaner is `let payload = format!("msg-{i}"); publisher.publish(&topic, payload.as_bytes()).await`. +3. **`MessageCollector` → `Subscription`** — `collector.get_messages()` becomes `subscription.snapshot()`, `collector.count()`/`wait_for_messages` move onto the `Subscription` struct. +4. **Unused subscription handles** — in `qos2_pubrec_error_allows_packet_id_reuse` the test never reads from the subscription but needs the broker-side route alive until end of scope. `let _subscription =` is the right pattern (lifetime-driven, not silencing missing logic). +5. **Bulk perl replacements** for mechanical patterns: `let broker = ConformanceBroker::start().await;` → `let sut = inprocess_sut().await;` and `RawMqttClient::connect_tcp(broker.socket_addr())` → `RawMqttClient::connect_tcp(sut.expect_tcp_addr())`. Then per-test `connected_client(name, &broker)` → `TestClient::connect_with_prefix(&sut, name)` edits. +6. **`RawTestClient::connect` needed a `# Panics` doc section** (clippy pedantic) — `.unwrap()` on a local-only `StdMutex` that can only be poisoned if a prior holder panicked. + +**What's intentionally not yet vendor-neutral:** the test files still use `inprocess_sut()` directly — they'll swap to `harness::sut()` once the proc-macro runner in Phase G passes an abstract `SutHandle` into each test via dependency injection. That's Phase G work, not Phase F. + +**Phase F is now the final refactor step that leaves the entire conformance suite running against the vendor-neutral test harness.** Phase G (proc-macro + CLI runner) and Phase H (profiles + external SUT fixtures) are architectural additions on top of this stable base. + +--- + +### Phase E — TestClient (raw-backed) — COMPLETE + +Implemented `RawTestClient` in `src/test_client/raw.rs`: a self-contained MQTT v5 client that speaks the protocol directly over a `TcpTransport`, with no dependency on `mqtt5::MqttClient`. This is the backend the standalone conformance runner will use against third-party brokers described by a `SutDescriptor`. + +Architecture mirrors the in-process backend's public surface (`connect`, `publish` / `publish_with_options`, `subscribe`, `unsubscribe`, `disconnect`, `disconnect_abnormally`) so callers can swap `InProcessTestClient` ↔ `RawTestClient` without touching test bodies. Internals: + +- **Reader task**: a single `tokio::spawn`ed loop holds the `OwnedReadHalf`, decodes incoming packets via `Packet::decode_from_body`, and dispatches to ack channels or subscription queues. `Drop` aborts the task; `disconnect`/`disconnect_abnormally` stop it explicitly. +- **Ack correlation**: `pending_acks: Arc>>>`. Each outbound op that needs an ack inserts a oneshot, then `await_ack()` waits with a 10s timeout. `packet_id=0` is the sentinel for CONNACK arrival, polled by `await_connack`. +- **Subscription dispatch**: `subscriptions: Arc>>` keyed by topic filter; the reader walks the list per inbound PUBLISH, runs `topic_matches_filter(topic, filter)`, and pushes a `ReceivedMessage` clone into every matching queue. +- **QoS state machines**: QoS0 fire-and-forget; QoS1 awaits PUBACK; QoS2 sends PUBLISH → awaits PUBREC → sends PUBREL → awaits PUBCOMP. Inbound QoS1/QoS2 trigger PUBACK/PUBREC from the reader task. PUBREL → PUBCOMP is handled inline in the dispatcher. +- **Writer arbitration**: `writer: AsyncMutex>` so write ops from publish/subscribe and the reader's PUBACK responses serialize cleanly without holding a lock across `await` boundaries on the application side. + +Key gotchas hit and fixed: + +1. `mqtt5::MqttError` and `mqtt5_protocol::error::MqttError` are the **same type** (pure re-export). Two `From` impls in `TestClientError` collided. Removed the `Client(mqtt5::MqttError)` variant entirely; the single `From` covers both backends. +2. `Properties::set_correlation_data` takes `bytes::Bytes` but `PublishProperties.correlation_data` is `Option>` — needs `.into()`. +3. `RetainHandling` exists in two versions with different variant names: types-level `SendAtSubscribe`/`SendIfNew`/`DontSend` vs packet-level `SendAtSubscribe`/`SendAtSubscribeIfNew`/`DoNotSend`. Wrote `convert_retain_handling()` helper. +4. The `try_parse_packet` helper handles incremental TCP reads — peeks the buffer, decodes a `FixedHeader`, returns `None` if the body isn't fully buffered yet, otherwise splits off and decodes via `Packet::decode_from_body`. +5. Removed feature gate from `pub mod test_client;` in `lib.rs` — the module is now feature-independent because the raw backend doesn't need `mqtt5`. + +Added 3 raw-backend tests in `test_client/mod.rs` (`raw_backend_roundtrip_against_inprocess_fixture`, `raw_backend_qos2_roundtrip`, `raw_backend_unsubscribe_stops_delivery`) plus a fourth (`raw_qos0_roundtrip`) inside `raw.rs` itself. All 6 test_client tests pass; the full 229-test conformance suite still passes; `cargo clippy --all-targets --workspace -- -D warnings -W clippy::pedantic` is clean. + +Phase E acceptance criteria met: `RawTestClient` covers the §4 API; `SutHandle::External` is ready to wire into `RawTestClient::connect` (the raw backend takes a plain `SocketAddr` and `ConnectOptions`, both already produced by `SutDescriptor::tcp_socket_addr`); `TestClient` now has two backends and tests written against the unified API are agnostic. Phase F can begin — migrating the remaining 18 test files off direct `mqtt5::MqttClient` usage onto `TestClient`. + +### Phase D — TestClient (in-process backed) — COMPLETE + +Created `src/test_client.rs` gated by the `inprocess-fixture` feature. Wraps `mqtt5::MqttClient` behind a vendor-neutral API that accepts a `SutHandle` instead of a raw `ConformanceBroker`. Key design decisions: + +- `TestClient::subscribe()` returns a self-contained `Subscription` handle holding its own `Arc>>`. Cleaner than the pre-existing `MessageCollector` pattern — every subscription gets its own queue with `expect_publish(timeout)`, `wait_for_messages(count, timeout)`, `snapshot()`, `count()`, `clear()`. +- `ReceivedMessage` carries the full property set specified in the plan: `dup`, `subscription_identifiers: Vec` (plural, already matches `MessageProperties`), `user_properties`, `content_type`, `response_topic`, `correlation_data`, `message_expiry_interval`, `payload_format_indicator`. The `dup` field is currently always `false` since `MessageProperties` doesn't carry inbound DUP — this is a fidelity gap to close in a later phase. +- Types re-exported via `mqtt5::{QoS, SubscribeOptions, PublishOptions}` are really from `mqtt5_protocol`, so using them directly in `TestClient` keeps vendor neutrality once Phase E swaps the backend for `RawTestClient`. +- `connect_with_options` accepts `mqtt5::ConnectOptions` (wrapper with session/reconnect config) — Phase E will introduce a vendor-neutral alternative for the raw backend. + +Migrated 4 test files to `SutHandle` + `TestClient` + `Subscription`: +- `section3_qos_ack.rs` — 9 tests. Mix of pure raw (`RawMqttClient` against `sut_tcp_addr(&sut)`) and mixed raw+high-level (single `sut` serving both clients). +- `section3_disconnect.rs` — 8 tests. 3 will-message tests use `TestClient + subscription.expect_publish()`; 5 pure-raw tests swap `broker.socket_addr()` for `sut_tcp_addr(&sut)`. +- `section3_ping.rs` — 5 tests. Pure raw, mechanical swap. +- `section1_data_repr.rs` — 5 tests. Pure raw, mechanical swap (including the BOM test that spawns two raw clients against the same SUT). + +Clippy fixes needed: +- Removed redundant `#![cfg(feature = "inprocess-fixture")]` inside `test_client.rs`; the `#[cfg(...)]` on the `pub mod test_client` line in `lib.rs` is sufficient and the inner attribute triggered `duplicated attribute`. +- Added `# Panics` doc sections to `Subscription::{expect_publish, wait_for_messages, snapshot, count, clear}` and `TestClient::subscribe` for their `Mutex::lock().unwrap()` call sites. +- Changed "QoS 0" → "`QoS` 0" in the `publish` doc to satisfy `clippy::doc_markdown`. + +Verification: +- `cargo check -p mqtt5-conformance --tests` — clean +- `cargo clippy -p mqtt5-conformance --all-targets -- -D warnings -W clippy::pedantic` — clean +- `cargo test -p mqtt5-conformance --tests` — **225 passed, 0 failed** (205 original tests + 2 `test_client` unit tests + other crate tests). Migrated files: 5/5, 8/8, 5/5, 9/9. + +Phase D complete. Ready for Phase E (raw-backed `TestClient` for external SUTs). + ### 2026-02-19 — Final 7 untested conformance statements resolved (zero remaining) Added `section3_final_conformance.rs` with 7 tests covering the last 7 Untested normative statements. All 247 statements now accounted for: 174 Tested, 27 CrossRef, 44 NotApplicable, 2 Skipped. diff --git a/crates/mqtt5-conformance/Cargo.toml b/crates/mqtt5-conformance/Cargo.toml index c28e969f..34e23a34 100644 --- a/crates/mqtt5-conformance/Cargo.toml +++ b/crates/mqtt5-conformance/Cargo.toml @@ -12,8 +12,12 @@ description = "MQTT v5.0 OASIS specification conformance test suite" name = "mqtt5_conformance" path = "src/lib.rs" +[features] +default = ["inprocess-fixture"] +inprocess-fixture = ["dep:mqtt5"] + [dependencies] -mqtt5 = { path = "../mqtt5" } +mqtt5 = { path = "../mqtt5", optional = true } mqtt5-protocol = { path = "../mqtt5-protocol" } tokio = { version = "1.47", features = ["full"] } tracing = "0.1" @@ -23,9 +27,11 @@ serde_json = "1.0" toml = "0.9" bytes = "1.10" ulid = "1.2.1" - -[dev-dependencies] -tempfile = "3.25.0" +linkme = "0.3.35" +mqtt5-conformance-macros = { version = "0.1.0", path = "../mqtt5-conformance-macros" } tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect"] } futures-util = "0.3" http = "1.4.0" + +[dev-dependencies] +tempfile = "3.25.0" diff --git a/crates/mqtt5-conformance/README.md b/crates/mqtt5-conformance/README.md index 284ccd54..e3a07fa1 100644 --- a/crates/mqtt5-conformance/README.md +++ b/crates/mqtt5-conformance/README.md @@ -1,49 +1,68 @@ # mqtt5-conformance -MQTT v5.0 OASIS specification conformance test suite for the `mqtt5` broker. +Vendor-neutral MQTT v5.0 conformance test suite that validates any broker against the OASIS specification. -## Overview +## Features -Tests every normative statement (`[MQTT-x.x.x-y]`) from the -[OASIS MQTT Version 5.0 specification](https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html) -against the real broker running in-process on loopback TCP. +- **183 conformance tests** covering MQTT v5.0 sections 1, 3, 4, and 6 +- **247 normative statements** tracked in `conformance.toml` with per-statement coverage status +- **Two execution modes**: in-process via `cargo test` or against any external broker via the CLI +- **Capability-based skipping**: tests declare requirements; the runner skips tests the SUT cannot support +- **Raw TCP packet builder** for malformed and edge-case protocol testing +- **CLI with JSON reports** for CI integration and cross-vendor comparison -- **247 normative statements** tracked in `conformance.toml` -- **197 tests** across 22 test files covering sections 1, 3, 4, and 6 -- Raw TCP packet builder for malformed/edge-case testing -- Machine-readable coverage reports (text and JSON) +## Quick Start -## Running +Run conformance tests against the built-in in-process broker: ```bash -cargo test -p mqtt5-conformance +cargo test -p mqtt5-conformance --features inprocess-fixture --lib ``` -## Architecture +Build the standalone CLI runner: + +```bash +cargo build --release -p mqtt5-conformance-cli +``` + +Run against an external broker: -### Test Harness +```bash +./target/release/mqtt5-conformance --sut tests/fixtures/mosquitto-2.x.toml +``` + +Generate a JSON conformance report: + +```bash +./target/release/mqtt5-conformance --sut sut.toml --report report.json +``` -`ConformanceBroker` spins up a real `MqttBroker` with memory-backed storage on a random loopback port. Each test gets its own isolated broker instance. +## Testing Your Own Broker -### Raw Client +1. **Create a SUT descriptor** — copy `tests/fixtures/external-mqtt5.toml` (the fully annotated reference template) and adjust addresses and capabilities to match your broker. See [BROKER_SETUP.md](BROKER_SETUP.md) for step-by-step configuration guides for Mosquitto, EMQX, and HiveMQ CE. -`RawMqttClient` operates at the TCP byte level, bypassing the normal client's packet validation. `RawPacketBuilder` constructs both valid and deliberately invalid MQTT v5.0 packets for testing broker rejection behavior. +2. **Start your broker** — ensure it is listening on the addresses declared in the descriptor. -### Conformance Manifest +3. **Run the CLI**: + ```bash + ./target/release/mqtt5-conformance --sut your-broker.toml --report results.json + ``` -`conformance.toml` maps every normative statement to its test status (`Tested`, `NotApplicable`, `Untested`) and associated test names. The `report` module generates coverage reports from this manifest. +4. **Review results** — tests whose requirements exceed your broker's capabilities are skipped automatically. See the [CLI README](../mqtt5-conformance-cli/README.md) for the full SUT descriptor schema and report format. ## Test Organization -| File | Section | Scope | -|------|---------|-------| +Tests are organized by MQTT v5.0 specification section. All 21 modules live in `src/conformance_tests/`: + +| Module | Section | Scope | +|--------|---------|-------| | `section1_data_repr` | 1 | Data representation, UTF-8 | | `section3_connect` | 3.1 | CONNECT packet | -| `section3_connect_extended` | 3.1 | CONNECT edge cases | +| `section3_connect_extended` | 3.1 | CONNECT edge cases, will messages, keep-alive | | `section3_connack` | 3.2 | CONNACK packet | -| `section3_publish` | 3.3 | PUBLISH packet | -| `section3_publish_advanced` | 3.3 | PUBLISH edge cases | -| `section3_publish_alias` | 3.3 | Topic aliases | +| `section3_publish` | 3.3 | PUBLISH packet basics | +| `section3_publish_advanced` | 3.3 | PUBLISH properties and user properties | +| `section3_publish_alias` | 3.3 | Topic alias handling | | `section3_publish_flow` | 3.3 | PUBLISH flow control | | `section3_qos_ack` | 3.4-3.7 | PUBACK, PUBREC, PUBREL, PUBCOMP | | `section3_subscribe` | 3.8-3.9 | SUBSCRIBE, SUBACK | @@ -58,3 +77,108 @@ cargo test -p mqtt5-conformance | `section4_enhanced_auth` | 4.12 | Enhanced authentication | | `section4_error_handling` | 4.13 | Error handling | | `section6_websocket` | 6 | WebSocket conformance | + +Additionally, 16 vendor-specific integration tests in `tests/` validate the conformance infrastructure itself. + +## Writing Conformance Tests + +Each test is an async function annotated with `#[conformance_test]`: + +```rust +use mqtt5_conformance_macros::conformance_test; +use mqtt5_conformance::sut::SutHandle; + +#[conformance_test( + ids = ["MQTT-3.1.0-1"], + requires = ["transport.tcp"], +)] +async fn connect_first_packet_must_be_connect(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + + raw.send_raw(&RawPacketBuilder::publish_without_connect()) + .await + .unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-3.1.0-1] Server must disconnect client that sends non-CONNECT first" + ); +} +``` + +The function must be `async`, take exactly one parameter `sut: SutHandle`, and return `()`. See the [macros README](../mqtt5-conformance-macros/README.md) for parameter details, requirement string reference, and generated output. + +## Requirement Strings + +Tests declare capability requirements via the `requires` parameter. The runner skips tests whose requirements the SUT cannot satisfy. + +| String | Description | +|--------|-------------| +| `transport.tcp` | TCP transport available | +| `transport.tls` | TLS transport available | +| `transport.websocket` | WebSocket transport available | +| `transport.quic` | QUIC transport available | +| `max_qos>=0` | QoS 0 supported (always true) | +| `max_qos>=1` | QoS 1 or higher supported | +| `max_qos>=2` | QoS 2 supported | +| `retain_available` | Retained messages supported | +| `wildcard_subscription_available` | Wildcard topic filters supported | +| `subscription_identifier_available` | Subscription identifiers supported | +| `shared_subscription_available` | Shared subscriptions supported | +| `enhanced_auth.` | Specific enhanced auth method supported | +| `hooks.restart` | Broker restart hook available | +| `hooks.cleanup` | Broker cleanup hook available | +| `acl` | Topic-level access control supported | + +## Conformance Profiles + +`profiles.toml` defines minimum capability sets for broker classes. Profiles are reference configurations — they document what a broker at a given tier must support, and can be used to validate that a SUT meets a profile's minimum bar. + +| Profile | Description | +|---------|-------------| +| `core-broker` | Minimal spec-compliant broker. TCP only, no shared subscriptions, no ACL, no hooks. | +| `core-broker-tls` | Core broker with TLS transport enabled. | +| `full-broker` | Feature-complete broker. All transports except QUIC, shared subscriptions, ACL, SCRAM-SHA-256, lifecycle hooks. | + +## Architecture + +### SutHandle + +The `SutHandle` enum abstracts over `InProcess` (in-process mqtt-lib broker) and `External` (any broker described by a TOML descriptor). Tests receive a `SutHandle` and never know which backend they run against. + +### TestClient + +`TestClient` provides a high-level API for conformance tests: connect, publish, subscribe, receive messages. It wraps either the mqtt5 client library (in-process) or a raw MQTT v5 protocol implementation (external). `Subscription` handles collect received messages and support timeout-based assertions. + +### RawMqttClient and RawPacketBuilder + +`RawMqttClient` operates at the TCP byte level, bypassing client-side validation. `RawPacketBuilder` constructs both valid and deliberately malformed MQTT v5.0 packets. Together they test broker rejection behavior for protocol violations. + +### Registry + +Tests self-register at link time via `linkme::distributed_slice`. The `CONFORMANCE_TESTS` slice collects all `ConformanceTest` entries across the workspace with zero runtime cost. The CLI iterates over this slice to discover and run tests. + +### Capability Gating + +Each `ConformanceTest` carries a `requires` array of `Requirement` values. Before running a test, the runner checks each requirement against the SUT's `Capabilities`. If any requirement fails, the test is skipped with a label identifying the missing capability. + +## Crate Structure + +| File | Purpose | +|------|---------| +| `lib.rs` | Public API surface and module exports | +| `sut.rs` | `SutDescriptor`, `SutHandle`, TOML parsing, address accessors | +| `capabilities.rs` | `Capabilities` struct, `Requirement` enum, `TransportSupport`, `EnhancedAuthSupport`, `HookSupport` | +| `registry.rs` | `ConformanceTest` record, `CONFORMANCE_TESTS` distributed slice, `TestRunner` type | +| `skip.rs` | `skip_if_missing()` evaluator, `SkipReason` | +| `test_client/` | `TestClient` enum, `Subscription`, `ReceivedMessage`, in-process and raw backends | +| `raw_client.rs` | `RawMqttClient` (byte-level transport), `RawPacketBuilder` (packet construction) | +| `transport.rs` | `Transport` trait, `TcpTransport` | +| `packet_parser.rs` | `ParsedProperties`, CONNACK/PUBLISH/SUBACK parsers, variable-length integer decoder | +| `assertions.rs` | Reusable assertion helpers for CONNACK reason codes, SUBACK codes, DISCONNECT, PUBLISH matching | +| `harness.rs` | `ConformanceBroker` — in-process broker fixture with random port binding | +| `manifest.rs` | `ConformanceManifest` — TOML-based statement tracking, coverage statistics | +| `report.rs` | `ConformanceReport` — text and JSON report generation | +| `conformance_tests/` | 21 test modules organized by specification section | diff --git a/crates/mqtt5-conformance/profiles.toml b/crates/mqtt5-conformance/profiles.toml new file mode 100644 index 00000000..dd56a746 --- /dev/null +++ b/crates/mqtt5-conformance/profiles.toml @@ -0,0 +1,67 @@ +# Conformance profiles define minimum capability sets for broker classes. +# Each profile specifies what a broker must support to qualify for that tier. +# Tests declare requirements via `requires = [...]`; the CLI skips tests +# whose requirements exceed the SUT's declared capabilities. + +# Minimal spec-compliant MQTT v5.0 broker. +# TCP only, no shared subscriptions, no ACL, no lifecycle hooks. +[core-broker] +max_qos = 2 +retain_available = true +wildcard_subscription_available = true +subscription_identifier_available = true +shared_subscription_available = false +topic_alias_maximum = 0 +assigned_client_id_supported = true +acl = false + +[core-broker.transports] +tcp = true +tls = false +websocket = false +quic = false + +# Core broker with TLS transport enabled. +# Same baseline as core-broker, plus TLS on a second listener. +[core-broker-tls] +max_qos = 2 +retain_available = true +wildcard_subscription_available = true +subscription_identifier_available = true +shared_subscription_available = false +topic_alias_maximum = 0 +assigned_client_id_supported = true +acl = false + +[core-broker-tls.transports] +tcp = true +tls = true +websocket = false +quic = false + +# Feature-complete broker with all transports except QUIC. +# Shared subscriptions, ACL, SCRAM-SHA-256 enhanced auth, and lifecycle hooks. +[full-broker] +max_qos = 2 +retain_available = true +wildcard_subscription_available = true +subscription_identifier_available = true +shared_subscription_available = true +topic_alias_maximum = 65535 +server_receive_maximum = 65535 +maximum_packet_size = 268435456 +assigned_client_id_supported = true +acl = true + +[full-broker.transports] +tcp = true +tls = true +websocket = true +quic = false + +[full-broker.enhanced_auth] +methods = ["SCRAM-SHA-256"] + +[full-broker.hooks] +restart = true +cleanup = true diff --git a/crates/mqtt5-conformance/src/assertions.rs b/crates/mqtt5-conformance/src/assertions.rs new file mode 100644 index 00000000..8fd62fc2 --- /dev/null +++ b/crates/mqtt5-conformance/src/assertions.rs @@ -0,0 +1,151 @@ +//! Reusable assertions for conformance tests. +//! +//! Each helper takes a `[MQTT-x.x.x-y]` conformance ID string so the panic +//! message points directly at the spec statement that failed. The helpers +//! decode raw broker responses through [`packet_parser`](crate::packet_parser) +//! and compare against the test expectation. + +#![allow(clippy::missing_panics_doc)] + +use crate::packet_parser::{ParsedConnAck, ParsedProperties, ParsedPublish}; +use crate::raw_client::RawMqttClient; +use crate::transport::Transport; +use std::time::Duration; + +/// Reads a CONNACK and asserts the reason code matches `expected`. +pub async fn expect_connack_reason( + raw: &mut RawMqttClient, + expected: u8, + statement_id: &str, + timeout: Duration, +) -> ParsedConnAck { + let bytes = raw + .read_packet_bytes(timeout) + .await + .unwrap_or_else(|| panic!("[{statement_id}] expected CONNACK, got nothing")); + let connack = ParsedConnAck::parse(&bytes) + .unwrap_or_else(|| panic!("[{statement_id}] expected valid CONNACK, got {bytes:?}")); + assert_eq!( + connack.reason_code, expected, + "[{statement_id}] expected CONNACK reason 0x{expected:02X}, got 0x{:02X}", + connack.reason_code + ); + connack +} + +/// Reads a SUBACK and asserts every reason code matches `expected_codes`. +pub async fn expect_suback_codes( + raw: &mut RawMqttClient, + expected_codes: &[u8], + statement_id: &str, + timeout: Duration, +) -> u16 { + let (packet_id, codes) = raw + .expect_suback(timeout) + .await + .unwrap_or_else(|| panic!("[{statement_id}] expected SUBACK")); + assert_eq!( + codes, expected_codes, + "[{statement_id}] expected SUBACK reason codes {expected_codes:?}, got {codes:?}" + ); + packet_id +} + +/// Asserts the broker closes the connection or sends DISCONNECT within the timeout. +pub async fn expect_disconnect( + raw: &mut RawMqttClient, + statement_id: &str, + timeout: Duration, +) { + assert!( + raw.expect_disconnect(timeout).await, + "[{statement_id}] server must disconnect" + ); +} + +/// Reads a PUBLISH and asserts the topic name equals `expected_topic`. +pub async fn expect_publish_with_topic( + raw: &mut RawMqttClient, + expected_topic: &str, + statement_id: &str, + timeout: Duration, +) -> ParsedPublish { + let bytes = raw + .read_packet_bytes(timeout) + .await + .unwrap_or_else(|| panic!("[{statement_id}] expected PUBLISH, got nothing")); + let publish = ParsedPublish::parse(&bytes) + .unwrap_or_else(|| panic!("[{statement_id}] expected valid PUBLISH, got {bytes:?}")); + assert_eq!( + publish.topic, expected_topic, + "[{statement_id}] expected topic '{expected_topic}', got '{}'", + publish.topic + ); + publish +} + +/// Asserts that `actual` user properties contain every entry in `expected`, +/// allowing additional properties whose keys appear in `injected_keys`. +/// +/// This encodes the broker-injected user property accommodation: brokers like +/// mqtt-lib inject `x-mqtt-sender` / `x-mqtt-client-id` on every PUBLISH, and +/// the conformance suite must tolerate them when comparing forwarded +/// application-level user properties. +pub fn expect_user_properties_subset( + actual: &[(String, String)], + expected: &[(String, String)], + injected_keys: &[&str], + statement_id: &str, +) { + let filtered: Vec<(String, String)> = actual + .iter() + .filter(|(k, _)| !injected_keys.contains(&k.as_str())) + .cloned() + .collect(); + assert_eq!( + filtered, expected, + "[{statement_id}] user properties (excluding injected {injected_keys:?}) mismatch" + ); +} + +/// Returns `true` if `props` contains a `(key, value)` pair matching `expected`. +#[must_use] +pub fn user_properties_contain( + props: &ParsedProperties, + expected_key: &str, + expected_value: &str, +) -> bool { + props + .user_properties + .iter() + .any(|(k, v)| k == expected_key && v == expected_value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_properties_subset_filters_injected() { + let actual = vec![ + ("x-mqtt-sender".to_owned(), "alice".to_owned()), + ("k".to_owned(), "v".to_owned()), + ("x-mqtt-client-id".to_owned(), "client-1".to_owned()), + ]; + let expected = vec![("k".to_owned(), "v".to_owned())]; + expect_user_properties_subset( + &actual, + &expected, + &["x-mqtt-sender", "x-mqtt-client-id"], + "MQTT-3.3.2-17", + ); + } + + #[test] + #[should_panic(expected = "MQTT-3.3.2-17")] + fn user_properties_subset_panics_on_mismatch() { + let actual = vec![("k".to_owned(), "wrong".to_owned())]; + let expected = vec![("k".to_owned(), "v".to_owned())]; + expect_user_properties_subset(&actual, &expected, &[], "MQTT-3.3.2-17"); + } +} diff --git a/crates/mqtt5-conformance/src/capabilities.rs b/crates/mqtt5-conformance/src/capabilities.rs new file mode 100644 index 00000000..fb13445a --- /dev/null +++ b/crates/mqtt5-conformance/src/capabilities.rs @@ -0,0 +1,369 @@ +//! Vendor-neutral capability matrix for a System Under Test. +//! +//! The conformance suite must run against any MQTT v5 broker. Different +//! brokers advertise different capabilities (spec-advertised values on +//! CONNACK), impose different resource limits, and behave differently within +//! the space the specification leaves open. This module models all three: +//! +//! * **Spec-advertised values** — `max_qos`, `retain_available`, etc. +//! * **Broker-imposed limits** — `max_clients`, `session_expiry_interval_max_secs` +//! * **Broker-specific behaviors** — `injected_user_properties`, `auth_failure_uses_disconnect` +//! +//! [`Requirement`] expresses a capability precondition for a single +//! conformance test; [`Requirement::matches`] checks it against a +//! [`Capabilities`] record. Tests that depend on optional features use +//! [`crate::skip::skip_if_missing`] to self-skip when the SUT doesn't meet +//! the preconditions. + +use serde::Deserialize; + +/// Full capability matrix for a System Under Test. +/// +/// Deserialized from the `[capabilities]` table of a `sut.toml` descriptor. +/// All fields have defaults so partial TOML files are accepted; operators +/// only need to specify values that deviate from the permissive defaults. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Capabilities { + pub max_qos: u8, + pub retain_available: bool, + pub wildcard_subscription_available: bool, + pub subscription_identifier_available: bool, + pub shared_subscription_available: bool, + pub topic_alias_maximum: u16, + pub server_keep_alive: u16, + pub server_receive_maximum: u16, + pub maximum_packet_size: u32, + pub assigned_client_id_supported: bool, + + pub max_clients: u32, + pub max_subscriptions_per_client: u32, + pub session_expiry_interval_max_secs: u32, + pub enforces_inbound_receive_maximum: bool, + + pub injected_user_properties: Vec, + pub auth_failure_uses_disconnect: bool, + pub unsupported_property_behavior: String, + pub shared_subscription_distribution: String, + + pub transports: TransportSupport, + pub enhanced_auth: EnhancedAuthSupport, + pub acl: bool, + pub hooks: HookSupport, +} + +impl Default for Capabilities { + fn default() -> Self { + Self { + max_qos: 2, + retain_available: true, + wildcard_subscription_available: true, + subscription_identifier_available: true, + shared_subscription_available: true, + topic_alias_maximum: 65535, + server_keep_alive: 0, + server_receive_maximum: 65535, + maximum_packet_size: 268_435_456, + assigned_client_id_supported: true, + max_clients: 0, + max_subscriptions_per_client: 0, + session_expiry_interval_max_secs: u32::MAX, + enforces_inbound_receive_maximum: true, + injected_user_properties: Vec::new(), + auth_failure_uses_disconnect: false, + unsupported_property_behavior: "DisconnectMalformed".to_owned(), + shared_subscription_distribution: "RoundRobin".to_owned(), + transports: TransportSupport::default(), + enhanced_auth: EnhancedAuthSupport::default(), + acl: false, + hooks: HookSupport::default(), + } + } +} + +/// Transport protocol support matrix. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct TransportSupport { + pub tcp: bool, + pub tls: bool, + pub websocket: bool, + pub quic: bool, +} + +impl Default for TransportSupport { + fn default() -> Self { + Self { + tcp: true, + tls: false, + websocket: false, + quic: false, + } + } +} + +/// Enhanced authentication support declared by the SUT. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct EnhancedAuthSupport { + pub methods: Vec, +} + +/// Harness hook support declared by the SUT. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct HookSupport { + pub restart: bool, + pub cleanup: bool, +} + +/// A single capability precondition for a conformance test. +/// +/// Tests enumerate their requirements; the test runner evaluates each against +/// the SUT's [`Capabilities`] and either runs the test or marks it skipped. +/// +/// Variants intentionally form a typed enum rather than a string DSL so every +/// requirement is compile-time checked. All payloads are `Copy`/`'static` so +/// requirement arrays can be placed in `static` slots and collected via +/// `linkme::distributed_slice` for the CLI runner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Requirement { + TransportTcp, + TransportTls, + TransportWebSocket, + TransportQuic, + MinQos(u8), + RetainAvailable, + WildcardSubscriptionAvailable, + SubscriptionIdentifierAvailable, + SharedSubscriptionAvailable, + EnhancedAuthMethod(&'static str), + HookRestart, + HookCleanup, + Acl, +} + +impl Requirement { + /// Returns `true` if `capabilities` satisfies this requirement. + #[must_use] + pub fn matches(self, capabilities: &Capabilities) -> bool { + match self { + Self::TransportTcp => capabilities.transports.tcp, + Self::TransportTls => capabilities.transports.tls, + Self::TransportWebSocket => capabilities.transports.websocket, + Self::TransportQuic => capabilities.transports.quic, + Self::MinQos(q) => capabilities.max_qos >= q, + Self::RetainAvailable => capabilities.retain_available, + Self::WildcardSubscriptionAvailable => capabilities.wildcard_subscription_available, + Self::SubscriptionIdentifierAvailable => capabilities.subscription_identifier_available, + Self::SharedSubscriptionAvailable => capabilities.shared_subscription_available, + Self::EnhancedAuthMethod(method) => capabilities + .enhanced_auth + .methods + .iter() + .any(|m| m == method), + Self::HookRestart => capabilities.hooks.restart, + Self::HookCleanup => capabilities.hooks.cleanup, + Self::Acl => capabilities.acl, + } + } + + /// Returns a human-readable label for skip reasons. + #[must_use] + pub fn label(self) -> String { + match self { + Self::TransportTcp => "transport.tcp".to_owned(), + Self::TransportTls => "transport.tls".to_owned(), + Self::TransportWebSocket => "transport.websocket".to_owned(), + Self::TransportQuic => "transport.quic".to_owned(), + Self::MinQos(q) => format!("max_qos >= {q}"), + Self::RetainAvailable => "retain_available".to_owned(), + Self::WildcardSubscriptionAvailable => "wildcard_subscription_available".to_owned(), + Self::SubscriptionIdentifierAvailable => "subscription_identifier_available".to_owned(), + Self::SharedSubscriptionAvailable => "shared_subscription_available".to_owned(), + Self::EnhancedAuthMethod(method) => format!("enhanced_auth.{method}"), + Self::HookRestart => "hooks.restart".to_owned(), + Self::HookCleanup => "hooks.cleanup".to_owned(), + Self::Acl => "acl".to_owned(), + } + } + + /// Parses a string DSL form (as used in `#[conformance_test(requires = [...])]`) + /// into a [`Requirement`]. + /// + /// Accepted forms: + /// * `transport.tcp`, `transport.tls`, `transport.websocket`, `transport.quic` + /// * `max_qos>=N` where `N` is 0, 1, or 2 + /// * `retain_available`, `wildcard_subscription_available`, + /// `subscription_identifier_available`, `shared_subscription_available` + /// * `enhanced_auth.` — `` is passed through as a + /// `&'static str` (callers must use string literals) + /// * `hooks.restart`, `hooks.cleanup` + /// * `acl` + /// + /// # Errors + /// Returns the unrecognised spec string on failure. Intended for use at + /// compile time inside the `#[conformance_test]` proc-macro to turn a + /// string literal into the matching enum variant; runtime callers can + /// also use this for dynamic configuration files. + pub fn from_spec(spec: &'static str) -> Result { + if let Some(rest) = spec.strip_prefix("max_qos>=") { + return match rest { + "0" => Ok(Self::MinQos(0)), + "1" => Ok(Self::MinQos(1)), + "2" => Ok(Self::MinQos(2)), + _ => Err(spec), + }; + } + if let Some(method) = spec.strip_prefix("enhanced_auth.") { + return Ok(Self::EnhancedAuthMethod(method)); + } + match spec { + "transport.tcp" => Ok(Self::TransportTcp), + "transport.tls" => Ok(Self::TransportTls), + "transport.websocket" => Ok(Self::TransportWebSocket), + "transport.quic" => Ok(Self::TransportQuic), + "retain_available" => Ok(Self::RetainAvailable), + "wildcard_subscription_available" => Ok(Self::WildcardSubscriptionAvailable), + "subscription_identifier_available" => Ok(Self::SubscriptionIdentifierAvailable), + "shared_subscription_available" => Ok(Self::SharedSubscriptionAvailable), + "hooks.restart" => Ok(Self::HookRestart), + "hooks.cleanup" => Ok(Self::HookCleanup), + "acl" => Ok(Self::Acl), + _ => Err(spec), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_capabilities_are_permissive() { + let caps = Capabilities::default(); + assert_eq!(caps.max_qos, 2); + assert!(caps.retain_available); + assert!(caps.transports.tcp); + assert!(!caps.transports.quic); + } + + #[test] + fn min_qos_requirement_matches_when_supported() { + let qos2 = Capabilities { + max_qos: 2, + ..Capabilities::default() + }; + assert!(Requirement::MinQos(1).matches(&qos2)); + assert!(Requirement::MinQos(2).matches(&qos2)); + let qos0 = Capabilities { + max_qos: 0, + ..Capabilities::default() + }; + assert!(!Requirement::MinQos(1).matches(&qos0)); + } + + #[test] + fn transport_requirement_matches_declared_transport() { + let ws_on = Capabilities { + transports: TransportSupport { + websocket: true, + ..TransportSupport::default() + }, + ..Capabilities::default() + }; + assert!(Requirement::TransportWebSocket.matches(&ws_on)); + let ws_off = Capabilities::default(); + assert!(!Requirement::TransportWebSocket.matches(&ws_off)); + } + + #[test] + fn enhanced_auth_requirement_matches_declared_method() { + let caps = Capabilities { + enhanced_auth: EnhancedAuthSupport { + methods: vec!["CHALLENGE-RESPONSE".to_owned()], + }, + ..Capabilities::default() + }; + assert!(Requirement::EnhancedAuthMethod("CHALLENGE-RESPONSE").matches(&caps)); + assert!(!Requirement::EnhancedAuthMethod("SCRAM-SHA-256").matches(&caps)); + } + + #[test] + fn requirement_label_is_stable() { + assert_eq!(Requirement::TransportTcp.label(), "transport.tcp"); + assert_eq!(Requirement::MinQos(2).label(), "max_qos >= 2"); + assert_eq!( + Requirement::EnhancedAuthMethod("CR").label(), + "enhanced_auth.CR" + ); + } + + #[test] + fn from_spec_parses_all_variants() { + assert_eq!( + Requirement::from_spec("transport.tcp").unwrap(), + Requirement::TransportTcp + ); + assert_eq!( + Requirement::from_spec("max_qos>=2").unwrap(), + Requirement::MinQos(2) + ); + assert_eq!( + Requirement::from_spec("enhanced_auth.CHALLENGE-RESPONSE").unwrap(), + Requirement::EnhancedAuthMethod("CHALLENGE-RESPONSE") + ); + assert_eq!( + Requirement::from_spec("shared_subscription_available").unwrap(), + Requirement::SharedSubscriptionAvailable + ); + assert_eq!(Requirement::from_spec("acl").unwrap(), Requirement::Acl); + assert!(Requirement::from_spec("unknown").is_err()); + } + + #[test] + fn profiles_toml_parses_against_capabilities_schema() { + use std::collections::BTreeMap; + let text = include_str!("../profiles.toml"); + let profiles: BTreeMap = toml::from_str(text) + .expect("profiles.toml must deserialize as a map of name -> Capabilities"); + for required in ["core-broker", "core-broker-tls", "full-broker"] { + assert!( + profiles.contains_key(required), + "profiles.toml is missing required profile `{required}`" + ); + } + let core = profiles.get("core-broker").unwrap(); + assert!(core.transports.tcp); + assert!(!core.transports.tls); + let core_tls = profiles.get("core-broker-tls").unwrap(); + assert!(core_tls.transports.tcp); + assert!(core_tls.transports.tls); + let full = profiles.get("full-broker").unwrap(); + assert!(full.transports.tcp); + assert!(full.transports.tls); + assert!(full.transports.websocket); + assert!(full.acl); + } + + #[test] + fn capabilities_deserialize_from_partial_toml() { + let toml_str = r" + max_qos = 1 + retain_available = false + + [transports] + tcp = true + tls = true + "; + let caps: Capabilities = toml::from_str(toml_str).unwrap(); + assert_eq!(caps.max_qos, 1); + assert!(!caps.retain_available); + assert!(caps.transports.tcp); + assert!(caps.transports.tls); + assert!(!caps.transports.websocket); + assert!(caps.wildcard_subscription_available); + } +} diff --git a/crates/mqtt5-conformance/src/conformance_tests/mod.rs b/crates/mqtt5-conformance/src/conformance_tests/mod.rs new file mode 100644 index 00000000..810d5c24 --- /dev/null +++ b/crates/mqtt5-conformance/src/conformance_tests/mod.rs @@ -0,0 +1,39 @@ +//! Library-hosted conformance test bodies. +//! +//! Tests live inside the library crate (instead of under `tests/`) so they +//! are compiled into any downstream binary that depends on +//! `mqtt5-conformance`. The CLI runner walks the +//! [`crate::registry::CONFORMANCE_TESTS`] distributed slice to enumerate +//! every registered test at link time without runtime discovery. +//! +//! Each `#[conformance_test]` function emits both a `#[cfg(test)] #[tokio::test]` +//! wrapper (so `cargo test --lib --features inprocess-fixture` runs the +//! suite against the default in-process fixture during development) and a +//! runner function registered in the distributed slice (so the CLI runner +//! can execute the same test against any SUT declared in `sut.toml`). +//! +//! Phase G uses [`section3_ping`] as the proof-of-concept module while the +//! broader migration pulls the rest of the existing `tests/section*.rs` +//! files in alongside it. + +pub mod section1_data_repr; +pub mod section3_connack; +pub mod section3_connect; +pub mod section3_connect_extended; +pub mod section3_disconnect; +pub mod section3_final_conformance; +pub mod section3_ping; +pub mod section3_publish; +pub mod section3_publish_advanced; +pub mod section3_publish_alias; +pub mod section3_publish_flow; +pub mod section3_qos_ack; +pub mod section3_subscribe; +pub mod section3_unsubscribe; +pub mod section4_enhanced_auth; +pub mod section4_error_handling; +pub mod section4_flow_control; +pub mod section4_qos; +pub mod section4_shared_sub; +pub mod section4_topic; +pub mod section6_websocket; diff --git a/crates/mqtt5-conformance/tests/section1_data_repr.rs b/crates/mqtt5-conformance/src/conformance_tests/section1_data_repr.rs similarity index 69% rename from crates/mqtt5-conformance/tests/section1_data_repr.rs rename to crates/mqtt5-conformance/src/conformance_tests/section1_data_repr.rs index feed21af..1143ef37 100644 --- a/crates/mqtt5-conformance/tests/section1_data_repr.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section1_data_repr.rs @@ -1,5 +1,9 @@ -use mqtt5_conformance::harness::{unique_client_id, ConformanceBroker}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Section 1.5 — Data Representation: UTF-8 strings, variable byte integers. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); @@ -8,13 +12,12 @@ const TIMEOUT: Duration = Duration::from_secs(3); /// well-formed UTF-8 as defined by the Unicode specification and restated /// in RFC 3629. In particular it MUST NOT include encodings of code points /// between U+D800 and U+DFFF. -/// -/// Sends a CONNECT with `client_id` containing UTF-16 surrogate bytes -/// (0xED 0xA0 0x80 = U+D800). Server must disconnect (malformed packet). -#[tokio::test] -async fn utf8_surrogate_codepoints_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-1.5.4-1"], + requires = ["transport.tcp"], +)] +async fn utf8_surrogate_codepoints_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -31,12 +34,11 @@ async fn utf8_surrogate_codepoints_rejected() { /// [MQTT-1.5.4-3] A UTF-8 Encoded String MUST NOT include an encoding of the /// null character U+0000. A Byte Order Mark (BOM, U+FEFF) is valid and MUST NOT /// be stripped by the server. -/// -/// Subscribes to a topic with BOM prefix and publishes to the same topic, -/// verifying the subscriber receives the message (BOM is preserved, not stripped). -#[tokio::test] -async fn bom_preserved_in_topic() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-1.5.4-3"], + requires = ["transport.tcp"], +)] +async fn bom_preserved_in_topic(sut: SutHandle) { let tag = unique_client_id("bom"); let bom_prefix: &[u8] = &[0xEF, 0xBB, 0xBF]; @@ -45,7 +47,7 @@ async fn bom_preserved_in_topic() { topic_with_bom.extend_from_slice(bom_prefix); topic_with_bom.extend_from_slice(topic_suffix.as_bytes()); - let mut subscriber = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut subscriber = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("bom-sub"); @@ -69,7 +71,7 @@ async fn bom_preserved_in_topic() { tokio::time::sleep(Duration::from_millis(100)).await; - let mut publisher = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut publisher = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("bom-pub"); @@ -99,14 +101,12 @@ async fn bom_preserved_in_topic() { /// [MQTT-1.5.5-1] The encoded value MUST use the minimum number of bytes /// necessary to represent the value. A variable byte integer MUST NOT use /// more than 4 bytes. -/// -/// Sends a raw CONNECT packet with a 5-byte variable byte integer in the -/// remaining length field (exceeds the 4-byte maximum). Server should -/// disconnect. -#[tokio::test] -async fn non_minimal_varint_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-1.5.5-1"], + requires = ["transport.tcp"], +)] +async fn non_minimal_varint_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -122,13 +122,12 @@ async fn non_minimal_varint_rejected() { /// [MQTT-1.5.7-1] Both the key and value of a UTF-8 String Pair MUST comply /// with the requirements for UTF-8 Encoded Strings. -/// -/// Sends a PUBLISH with a user property key containing invalid UTF-8 bytes. -/// Server must disconnect (malformed). -#[tokio::test] -async fn invalid_utf8_user_property_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-1.5.7-1"], + requires = ["transport.tcp"], +)] +async fn invalid_utf8_user_property_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("utf8prop"); @@ -149,14 +148,12 @@ async fn invalid_utf8_user_property_rejected() { /// [MQTT-4.7.3-3] Topic Names and Topic Filters are UTF-8 Encoded Strings; /// they MUST NOT encode to more than 65,535 bytes. -/// -/// Sends a PUBLISH with a topic of 65535 'A' characters (the maximum -/// allowed by the 2-byte length prefix). The broker should either accept -/// the packet or cleanly reject it. The connection must not hang or crash. -#[tokio::test] -async fn max_length_topic_handled() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-4.7.3-3"], + requires = ["transport.tcp"], +)] +async fn max_length_topic_handled(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("bigtopic"); @@ -168,9 +165,7 @@ async fn max_length_topic_handled() { let response = raw.expect_any_packet(TIMEOUT).await; match response { - Some(data) if !data.is_empty() && data[0] == 0xE0 => { - // pass - } + Some(data) if !data.is_empty() && data[0] == 0xE0 => {} Some(_) | None => { raw.send_raw(&RawPacketBuilder::pingreq()).await.unwrap(); let alive = raw.expect_pingresp(TIMEOUT).await; diff --git a/crates/mqtt5-conformance/src/conformance_tests/section3_connack.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_connack.rs new file mode 100644 index 00000000..3d687f97 --- /dev/null +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_connack.rs @@ -0,0 +1,234 @@ +//! Section 3.2 — CONNACK packet structure and behaviour. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{put_mqtt_string, wrap_fixed_header, RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use mqtt5_protocol::protocol::v5::properties::PropertyId; +use mqtt5_protocol::protocol::v5::reason_codes::ReasonCode; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(3); + +/// `[MQTT-3.2.2-1]` Byte 1 of CONNACK is the Connect Acknowledge Flags. +/// Bits 7-1 are reserved and MUST be set to 0. +#[conformance_test( + ids = ["MQTT-3.2.2-1"], + requires = ["transport.tcp"], +)] +async fn connack_reserved_flags_zero(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + + let client_id = unique_client_id("flags"); + raw.send_raw(&RawPacketBuilder::valid_connect(&client_id)) + .await + .unwrap(); + + let connack = raw.expect_connack(TIMEOUT).await; + assert!(connack.is_some(), "Must receive CONNACK"); + let (flags, reason) = connack.unwrap(); + assert_eq!(reason, 0x00, "Reason code must be Success"); + assert_eq!( + flags & 0xFE, + 0, + "[MQTT-3.2.2-1] CONNACK flags bits 7-1 must be zero" + ); +} + +/// `[MQTT-3.2.0-2]` The Server MUST NOT send more than one CONNACK in a +/// Network Connection. +#[conformance_test( + ids = ["MQTT-3.2.0-2"], + requires = ["transport.tcp"], +)] +async fn connack_only_one_per_connection(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + + let client_id = unique_client_id("one-connack"); + raw.send_raw(&RawPacketBuilder::valid_connect(&client_id)) + .await + .unwrap(); + + let connack = raw.expect_connack(TIMEOUT).await; + assert!(connack.is_some(), "Must receive first CONNACK"); + let (_, reason) = connack.unwrap(); + assert_eq!(reason, 0x00, "Must accept connection"); + + raw.send_raw(&RawPacketBuilder::subscribe("test/connack", 0)) + .await + .unwrap(); + + raw.send_raw(&RawPacketBuilder::publish_qos0("test/connack", b"hello")) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut connack_count = 0u32; + loop { + let data = raw.read_packet_bytes(Duration::from_millis(200)).await; + match data { + Some(bytes) if !bytes.is_empty() => { + if bytes[0] == 0x20 { + connack_count += 1; + } + } + _ => break, + } + } + + assert_eq!( + connack_count, 0, + "[MQTT-3.2.0-2] Server must not send a second CONNACK" + ); +} + +/// `[MQTT-3.2.2-6]` If a Server sends a CONNACK packet containing a +/// non-zero Reason Code it MUST set Session Present to 0. +#[conformance_test( + ids = ["MQTT-3.2.2-6"], + requires = ["transport.tcp"], +)] +async fn connack_session_present_zero_on_error(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + + raw.send_raw(&RawPacketBuilder::connect_with_protocol_version(99)) + .await + .unwrap(); + + let connack = raw.expect_connack(TIMEOUT).await; + assert!(connack.is_some(), "Must receive error CONNACK"); + let (flags, reason) = connack.unwrap(); + assert_ne!(reason, 0x00, "Must be a non-success reason code"); + assert_eq!( + flags & 0x01, + 0, + "[MQTT-3.2.2-6] Session Present must be 0 when reason code is non-zero" + ); +} + +/// `[MQTT-3.2.2-7]` If a Server sends a CONNACK packet containing a Reason +/// Code of 128 or greater it MUST then close the Network Connection. +#[conformance_test( + ids = ["MQTT-3.2.2-7"], + requires = ["transport.tcp"], +)] +async fn connack_error_code_closes_connection(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + + raw.send_raw(&RawPacketBuilder::connect_with_protocol_version(99)) + .await + .unwrap(); + + let response = raw.read_packet_bytes(TIMEOUT).await; + assert!(response.is_some(), "Must receive CONNACK"); + let data = response.unwrap(); + assert_eq!(data[0], 0x20, "Must be CONNACK"); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-3.2.2-7] Server must close connection after error CONNACK (reason >= 128)" + ); +} + +/// `[MQTT-3.2.2-8]` The Server sending the CONNACK packet MUST use one of +/// the Connect Reason Code values. +#[conformance_test( + ids = ["MQTT-3.2.2-8"], + requires = ["transport.tcp"], +)] +async fn connack_uses_valid_reason_code(sut: SutHandle) { + let mut raw_ok = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("valid-rc"); + raw_ok + .send_raw(&RawPacketBuilder::valid_connect(&client_id)) + .await + .unwrap(); + let ok_connack = raw_ok.expect_connack_packet(TIMEOUT).await; + assert!(ok_connack.is_some(), "Must receive success CONNACK"); + assert_eq!( + ok_connack.unwrap().reason_code, + ReasonCode::Success, + "[MQTT-3.2.2-8] Success CONNACK must have reason code 0x00" + ); + + let mut raw_err = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + raw_err + .send_raw(&RawPacketBuilder::connect_with_protocol_version(99)) + .await + .unwrap(); + let err_connack = raw_err.expect_connack_packet(TIMEOUT).await; + assert!(err_connack.is_some(), "Must receive error CONNACK"); + assert_eq!( + err_connack.unwrap().reason_code, + ReasonCode::UnsupportedProtocolVersion, + "[MQTT-3.2.2-8] Unsupported protocol version must use reason code 0x84" + ); +} + +/// `[MQTT-3.2.2-16]` When two clients connect with empty client IDs, the +/// server must assign different unique `ClientID`s. +#[conformance_test( + ids = ["MQTT-3.2.2-16"], + requires = ["transport.tcp"], +)] +async fn connack_assigned_client_id_unique(sut: SutHandle) { + let mut ids = Vec::new(); + for _ in 0..2 { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + raw.send_raw(&build_connect_empty_client_id()) + .await + .unwrap(); + + let connack = raw + .expect_connack_packet(TIMEOUT) + .await + .expect("Must receive CONNACK"); + + assert_eq!(connack.reason_code, ReasonCode::Success); + let assigned = connack.properties.get(PropertyId::AssignedClientIdentifier); + assert!( + assigned.is_some(), + "[MQTT-3.2.2-16] CONNACK must include Assigned Client Identifier" + ); + if let Some(mqtt5_protocol::PropertyValue::Utf8String(id)) = assigned { + ids.push(id.clone()); + } else { + panic!("Assigned Client Identifier has wrong type"); + } + } + + assert_ne!( + ids[0], ids[1], + "[MQTT-3.2.2-16] Assigned Client Identifiers must be unique" + ); +} + +fn build_connect_empty_client_id() -> Vec { + use bytes::{BufMut, BytesMut}; + + let mut body = BytesMut::new(); + body.put_u16(4); + body.put_slice(b"MQTT"); + body.put_u8(5); + body.put_u8(0x02); + body.put_u16(60); + body.put_u8(0); + put_mqtt_string(&mut body, ""); + + wrap_fixed_header(0x10, &body) +} diff --git a/crates/mqtt5-conformance/tests/section3_connect.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_connect.rs similarity index 59% rename from crates/mqtt5-conformance/tests/section3_connect.rs rename to crates/mqtt5-conformance/src/conformance_tests/section3_connect.rs index 7fa7f07b..8284b8d2 100644 --- a/crates/mqtt5-conformance/tests/section3_connect.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_connect.rs @@ -1,29 +1,26 @@ -//! MQTT v5.0 Section 3.1 — CONNECT packet conformance tests. -//! -//! Each test verifies a specific normative statement from the OASIS MQTT v5.0 -//! specification. Tests use either the high-level [`MqttClient`] API for -//! valid-path scenarios, or [`RawMqttClient`] for sending malformed packets -//! that the normal API would reject. - -use mqtt5::{ConnectOptions, MqttClient, WillMessage}; -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; -use mqtt5_conformance::raw_client::{ +//! Section 3.1 — CONNECT packet conformance tests. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{ encode_variable_int, put_mqtt_string, wrap_fixed_header, RawMqttClient, RawPacketBuilder, }; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{ConnectOptions, SubscribeOptions, WillMessage}; use std::time::Duration; +const TIMEOUT: Duration = Duration::from_secs(3); + /// `[MQTT-3.1.0-1]` After a Network Connection is established by a Client to /// a Server, the first packet sent from the Client to the Server MUST be a /// CONNECT packet. -/// -/// Sends a PUBLISH as the first packet and verifies the server closes the -/// connection. -#[tokio::test] -async fn connect_first_packet_must_be_connect() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.0-1"], + requires = ["transport.tcp"], +)] +async fn connect_first_packet_must_be_connect(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -32,20 +29,19 @@ async fn connect_first_packet_must_be_connect() { .unwrap(); assert!( - raw.expect_disconnect(Duration::from_secs(3)).await, + raw.expect_disconnect(TIMEOUT).await, "[MQTT-3.1.0-1] Server must disconnect client that sends non-CONNECT first" ); } /// `[MQTT-3.1.0-2]` The Server MUST process a second CONNECT packet sent from /// a Client as a Protocol Error and close the Network Connection. -/// -/// Sends two CONNECT packets on the same connection and verifies the server -/// disconnects after the second. -#[tokio::test] -async fn connect_second_connect_is_protocol_error() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.0-2"], + requires = ["transport.tcp"], +)] +async fn connect_second_connect_is_protocol_error(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -68,13 +64,12 @@ async fn connect_second_connect_is_protocol_error() { } /// `[MQTT-3.1.2-1]` The protocol name MUST be the UTF-8 String "MQTT". -/// A Server which does not receive this MUST close the Network Connection. -/// -/// Sends a CONNECT with protocol name "XXXX" and verifies disconnection. -#[tokio::test] -async fn connect_protocol_name_must_be_mqtt() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.2-1"], + requires = ["transport.tcp"], +)] +async fn connect_protocol_name_must_be_mqtt(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -88,16 +83,15 @@ async fn connect_protocol_name_must_be_mqtt() { ); } -/// `[MQTT-3.1.2-2]` The Server MUST respond to a CONNECT packet with a CONNACK -/// with 0x84 (Unsupported Protocol Version) if the Protocol Version is not -/// supported. -/// -/// Sends a CONNECT with protocol version 99 and verifies the CONNACK contains -/// reason code `0x84`. -#[tokio::test] -async fn connect_unsupported_protocol_version() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.1.2-2]` The Server MUST respond to a CONNECT packet with a +/// CONNACK with 0x84 (Unsupported Protocol Version) if the Protocol Version +/// is not supported. +#[conformance_test( + ids = ["MQTT-3.1.2-2"], + requires = ["transport.tcp"], +)] +async fn connect_unsupported_protocol_version(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -123,13 +117,13 @@ async fn connect_unsupported_protocol_version() { } /// `[MQTT-3.1.2-3]` The Server MUST validate that the reserved flag in the -/// CONNECT packet is set to 0. If it is not 0, treat it as a Malformed Packet. -/// -/// Sends a CONNECT with reserved flag (bit 0) set and verifies disconnection. -#[tokio::test] -async fn connect_reserved_flag_must_be_zero() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// CONNECT packet is set to 0. +#[conformance_test( + ids = ["MQTT-3.1.2-3"], + requires = ["transport.tcp"], +)] +async fn connect_reserved_flag_must_be_zero(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -145,27 +139,25 @@ async fn connect_reserved_flag_must_be_zero() { /// `[MQTT-3.1.2-4]` If `CleanStart` is set to 1, the Client and Server MUST /// discard any existing Session and start a new Session. -/// -/// Connects twice with `clean_start=true` and verifies `session_present=false` -/// both times, confirming the first session was discarded. -#[tokio::test] -async fn connect_clean_start_new_session() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.1.2-4"], + requires = ["transport.tcp"], +)] +async fn connect_clean_start_new_session(sut: SutHandle) { let client_id = unique_client_id("clean"); let opts = ConnectOptions::new(&client_id) .with_clean_start(true) .with_session_expiry_interval(300); - let client = MqttClient::with_options(opts.clone()); - let result = Box::pin(client.connect_with_options(broker.address(), opts)) + let client = TestClient::connect_with_options(&sut, opts) .await .expect("[MQTT-3.1.2-4] Clean start connection must succeed"); assert!( - !result.session_present, + !client.session_present(), "[MQTT-3.1.2-4] First connection with clean_start=true must have session_present=false" ); client - .subscribe("test/topic", |_| {}) + .subscribe("test/topic", SubscribeOptions::default()) .await .expect("subscribe failed"); client.disconnect().await.expect("disconnect failed"); @@ -175,12 +167,11 @@ async fn connect_clean_start_new_session() { let opts2 = ConnectOptions::new(&client_id) .with_clean_start(true) .with_session_expiry_interval(300); - let client2 = MqttClient::with_options(opts2.clone()); - let result2 = Box::pin(client2.connect_with_options(broker.address(), opts2)) + let client2 = TestClient::connect_with_options(&sut, opts2) .await .expect("reconnect failed"); assert!( - !result2.session_present, + !client2.session_present(), "[MQTT-3.1.2-4] CleanStart=1 must discard existing session (session_present=false)" ); client2.disconnect().await.expect("disconnect failed"); @@ -189,23 +180,21 @@ async fn connect_clean_start_new_session() { /// `[MQTT-3.1.2-5]` If `CleanStart` is set to 0 and there is a Session /// associated with the Client Identifier, the Server MUST resume /// communications based on state from the existing Session. -/// -/// Creates a session with `clean_start=true`, disconnects, then reconnects -/// with `clean_start=false` and verifies `session_present=true`. -#[tokio::test] -async fn connect_clean_start_false_resumes_session() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.1.2-5"], + requires = ["transport.tcp"], +)] +async fn connect_clean_start_false_resumes_session(sut: SutHandle) { let client_id = unique_client_id("resume"); let opts = ConnectOptions::new(&client_id) .with_clean_start(true) .with_session_expiry_interval(300); - let client = MqttClient::with_options(opts.clone()); - Box::pin(client.connect_with_options(broker.address(), opts)) + let client = TestClient::connect_with_options(&sut, opts) .await .expect("first connect failed"); client - .subscribe("test/resume", |_| {}) + .subscribe("test/resume", SubscribeOptions::default()) .await .expect("subscribe failed"); client.disconnect().await.expect("disconnect failed"); @@ -215,12 +204,11 @@ async fn connect_clean_start_false_resumes_session() { let opts2 = ConnectOptions::new(&client_id) .with_clean_start(false) .with_session_expiry_interval(300); - let client2 = MqttClient::with_options(opts2.clone()); - let result = Box::pin(client2.connect_with_options(broker.address(), opts2)) + let client2 = TestClient::connect_with_options(&sut, opts2) .await .expect("reconnect failed"); assert!( - result.session_present, + client2.session_present(), "[MQTT-3.1.2-5] CleanStart=0 with existing session must have session_present=true" ); client2.disconnect().await.expect("disconnect failed"); @@ -228,32 +216,29 @@ async fn connect_clean_start_false_resumes_session() { /// `[MQTT-3.1.2-6]` If the Will Flag is set to 1, the Will Properties, Will /// Topic and Will Payload fields MUST be present in the Payload. -/// -/// Connects with a valid Will Message and verifies the connection succeeds. -#[tokio::test] -async fn connect_will_flag_with_will_topic_payload() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.1.2-6"], + requires = ["transport.tcp"], +)] +async fn connect_will_flag_with_will_topic_payload(sut: SutHandle) { let will = WillMessage::new("will/topic", b"will-payload".to_vec()); let opts = ConnectOptions::new(unique_client_id("will")) .with_clean_start(true) .with_will(will); - let client = MqttClient::with_options(opts.clone()); - Box::pin(client.connect_with_options(broker.address(), opts)) + let client = TestClient::connect_with_options(&sut, opts) .await .expect("[MQTT-3.1.2-6] Connection with valid will must succeed"); - assert!(client.is_connected().await); client.disconnect().await.expect("disconnect failed"); } -/// `[MQTT-3.1.2-7]` If the Will Flag is set to 0, then the Will `QoS` MUST be -/// set to 0. -/// -/// Sends a raw CONNECT with connect flags `0x0A` (Will QoS=1 but Will Flag=0) -/// and verifies the server disconnects. -#[tokio::test] -async fn connect_will_qos_zero_without_will_flag() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.1.2-7]` If the Will Flag is set to 0, then the Will `QoS` MUST +/// be set to 0. +#[conformance_test( + ids = ["MQTT-3.1.2-7"], + requires = ["transport.tcp"], +)] +async fn connect_will_qos_zero_without_will_flag(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -268,13 +253,12 @@ async fn connect_will_qos_zero_without_will_flag() { /// `[MQTT-3.1.2-8]` If the Will Flag is set to 0, then Will Retain MUST be /// set to 0. -/// -/// Sends a raw CONNECT with connect flags `0x22` (Will Retain=1 but Will -/// Flag=0) and verifies the server disconnects. -#[tokio::test] -async fn connect_will_retain_zero_without_will_flag() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.2-8"], + requires = ["transport.tcp"], +)] +async fn connect_will_retain_zero_without_will_flag(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -287,15 +271,14 @@ async fn connect_will_retain_zero_without_will_flag() { ); } -/// `[MQTT-3.1.2-9]` If the Will Flag is set to 1, the value of Will `QoS` can -/// be 0, 1, or 2. A value of 3 is a Malformed Packet. -/// -/// Sends a raw CONNECT with connect flags `0x1E` (Will Flag=1, Will QoS=3) -/// and verifies the server disconnects. -#[tokio::test] -async fn connect_will_qos_3_is_malformed() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.1.2-9]` If the Will Flag is set to 1, the value of Will `QoS` +/// can be 0, 1, or 2. A value of 3 is a Malformed Packet. +#[conformance_test( + ids = ["MQTT-3.1.2-9"], + requires = ["transport.tcp"], +)] +async fn connect_will_qos_3_is_malformed(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -310,13 +293,12 @@ async fn connect_will_qos_3_is_malformed() { /// `[MQTT-3.1.2-12]` If the CONNECT packet has a fixed header flags field /// that is not 0x00, the Server MUST treat it as a Malformed Packet. -/// -/// Sends a CONNECT with fixed header byte `0x11` (flags=1) instead of `0x10` -/// (flags=0) and verifies the server disconnects. -#[tokio::test] -async fn connect_invalid_fixed_header_flags() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.2-12"], + requires = ["transport.tcp"], +)] +async fn connect_invalid_fixed_header_flags(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -330,34 +312,29 @@ async fn connect_invalid_fixed_header_flags() { ); } -/// `[MQTT-3.1.3-3]` The Server MUST allow `ClientID`s which are between 1 and 23 -/// UTF-8 encoded bytes in length, and that contain only the characters -/// `0-9`, `a-z`, `A-Z`. -/// -/// Connects with a 12-character alphanumeric client ID and verifies success. -#[tokio::test] -async fn connect_valid_client_id_accepted() { - let broker = ConformanceBroker::start().await; - let client = MqttClient::new("abcABC012345"); - client - .connect(broker.address()) +/// `[MQTT-3.1.3-3]` The Server MUST allow `ClientID`s which are between 1 +/// and 23 UTF-8 encoded bytes in length, and that contain only the +/// characters `0-9`, `a-z`, `A-Z`. +#[conformance_test( + ids = ["MQTT-3.1.3-3"], + requires = ["transport.tcp"], +)] +async fn connect_valid_client_id_accepted(sut: SutHandle) { + let client = TestClient::connect(&sut, "abcABC012345") .await .expect("[MQTT-3.1.3-3] Server must accept valid client ID"); - assert!(client.is_connected().await); client.disconnect().await.expect("disconnect failed"); } -/// `[MQTT-3.1.3-4]` A Server MAY allow a Client to supply a `ClientID` that has -/// a length of zero bytes, however if it does so the Server MUST treat this as -/// a special case and assign a unique `ClientID` to that Client. -/// -/// Sends a raw CONNECT with an empty client ID and `clean_start=true`, then -/// verifies the CONNACK contains an Assigned Client Identifier property -/// (`0x12`). -#[tokio::test] -async fn connect_empty_client_id_server_assigns() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.1.3-4]` A Server MAY allow a Client to supply a `ClientID` that +/// has a length of zero bytes; if so the Server MUST treat this as a special +/// case and assign a unique `ClientID` to that Client. +#[conformance_test( + ids = ["MQTT-3.1.3-4"], + requires = ["transport.tcp"], +)] +async fn connect_empty_client_id_server_assigns(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -382,16 +359,15 @@ async fn connect_empty_client_id_server_assigns() { ); } -/// `[MQTT-3.1.3-5]` If the Server rejects the `ClientID` it MAY respond to the -/// CONNECT packet with a CONNACK using Reason Code 0x85 +/// `[MQTT-3.1.3-5]` If the Server rejects the `ClientID` it MAY respond to +/// the CONNECT packet with a CONNACK using Reason Code 0x85 /// (`ClientIdentifierNotValid`). -/// -/// Sends a CONNECT with a client ID containing `/` which the broker rejects -/// via `is_path_safe_client_id` validation. -#[tokio::test] -async fn client_id_rejected_with_0x85() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.3-5"], + requires = ["transport.tcp"], +)] +async fn client_id_rejected_with_0x85(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -409,15 +385,14 @@ async fn client_id_rejected_with_0x85() { } /// `[MQTT-3.1.4-1]` The Server MUST validate that the CONNECT packet matches -/// the format described in the specification and close the Network Connection -/// if it does not. -/// -/// Sends a truncated CONNECT packet (header claims 50 bytes, only 3 sent) -/// and verifies the server eventually closes the connection. -#[tokio::test] -async fn connect_malformed_packet_closes_connection() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// the format described in the specification and close the Network +/// Connection if it does not. +#[conformance_test( + ids = ["MQTT-3.1.4-1"], + requires = ["transport.tcp"], +)] +async fn connect_malformed_packet_closes_connection(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -434,13 +409,12 @@ async fn connect_malformed_packet_closes_connection() { /// `[MQTT-3.1.4-2]` The Server MAY check the CONNECT Packet contents are /// consistent and if any check fails SHOULD send the CONNACK packet with a /// non-zero return code. -/// -/// Sends a CONNECT with a duplicate Session Expiry Interval property and -/// verifies the server disconnects. -#[tokio::test] -async fn connect_duplicate_property_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.4-2"], + requires = ["transport.tcp"], +)] +async fn connect_duplicate_property_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -454,15 +428,14 @@ async fn connect_duplicate_property_rejected() { ); } -/// `[MQTT-3.1.4-3]` If the Server accepts a connection with `CleanStart` set to -/// 1, the Server MUST set Session Present to 0 in the CONNACK packet. -/// -/// Sends a raw CONNECT with `clean_start=true` and verifies the CONNACK has -/// session present flag = 0 and reason code = Success. -#[tokio::test] -async fn connect_clean_start_session_present_zero() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.1.4-3]` If the Server accepts a connection with `CleanStart` set +/// to 1, the Server MUST set Session Present to 0 in the CONNACK packet. +#[conformance_test( + ids = ["MQTT-3.1.4-3"], + requires = ["transport.tcp"], +)] +async fn connect_clean_start_session_present_zero(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -485,33 +458,30 @@ async fn connect_clean_start_session_present_zero() { ); } -/// `[MQTT-3.1.4-4]` If the Server accepts a connection with `CleanStart` set to -/// 0 and the Server has Session State for the `ClientID`, it MUST set Session -/// Present to 1 in the CONNACK packet. -/// -/// Creates a session via the high-level client, disconnects, then reconnects -/// via raw client with `clean_start=false` and verifies `session_present=1` -/// in the CONNACK. -#[tokio::test] -async fn connect_clean_start_false_session_present_one() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.1.4-4]` If the Server accepts a connection with `CleanStart` set +/// to 0 and the Server has Session State for the `ClientID`, it MUST set +/// Session Present to 1 in the CONNACK packet. +#[conformance_test( + ids = ["MQTT-3.1.4-4"], + requires = ["transport.tcp"], +)] +async fn connect_clean_start_false_session_present_one(sut: SutHandle) { let client_id = unique_client_id("sp1"); let opts = ConnectOptions::new(&client_id) .with_clean_start(true) .with_session_expiry_interval(300); - let client = MqttClient::with_options(opts.clone()); - Box::pin(client.connect_with_options(broker.address(), opts)) + let client = TestClient::connect_with_options(&sut, opts) .await .expect("first connect failed"); client - .subscribe("test/sp1", |_| {}) + .subscribe("test/sp1", SubscribeOptions::default()) .await .expect("subscribe failed"); client.disconnect().await.expect("disconnect failed"); tokio::time::sleep(Duration::from_millis(200)).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&build_connect_resume(&client_id, 300)) @@ -530,21 +500,21 @@ async fn connect_clean_start_false_session_present_one() { } /// `[MQTT-3.1.4-5]` If the Will Flag is set to 1, the Server MUST store the -/// Will Message and publish the Will Message after the Network Connection is -/// subsequently closed unless the Will Message has been deleted on receipt of -/// a DISCONNECT packet with Reason Code 0x00. -/// -/// Connects a publisher with a Will Message, then disconnects abnormally. -/// A subscriber verifies the Will Message is delivered. -#[tokio::test] -async fn connect_will_published_on_abnormal_disconnect() { - let broker = ConformanceBroker::start().await; +/// Will Message and publish it after the Network Connection is subsequently +/// closed unless the Will Message has been deleted on receipt of a DISCONNECT +/// with Reason Code 0x00. +#[conformance_test( + ids = ["MQTT-3.1.4-5"], + requires = ["transport.tcp"], +)] +async fn connect_will_published_on_abnormal_disconnect(sut: SutHandle) { let will_topic = format!("will/{}", unique_client_id("abnormal")); - let collector = MessageCollector::new(); - let subscriber = connected_client("will-sub", &broker).await; - subscriber - .subscribe(&will_topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "will-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&will_topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; @@ -553,8 +523,7 @@ async fn connect_will_published_on_abnormal_disconnect() { let opts = ConnectOptions::new(unique_client_id("will-pub")) .with_clean_start(true) .with_will(will); - let publisher = MqttClient::with_options(opts.clone()); - Box::pin(publisher.connect_with_options(broker.address(), opts)) + let publisher = TestClient::connect_with_options(&sut, opts) .await .expect("connect failed"); @@ -562,13 +531,14 @@ async fn connect_will_published_on_abnormal_disconnect() { .disconnect_abnormally() .await .expect("abnormal disconnect failed"); - tokio::time::sleep(Duration::from_millis(500)).await; assert!( - collector.wait_for_messages(1, Duration::from_secs(3)).await, + subscription + .wait_for_messages(1, Duration::from_secs(3)) + .await, "[MQTT-3.1.4-5] Will message must be published on abnormal disconnect" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!(msgs[0].topic, will_topic); assert_eq!(msgs[0].payload, b"offline"); @@ -577,18 +547,18 @@ async fn connect_will_published_on_abnormal_disconnect() { /// `[MQTT-3.1.4-5]` (negative case) Will Message MUST NOT be published when /// the client sends a normal DISCONNECT with Reason Code 0x00. -/// -/// Connects a publisher with a Will Message, disconnects normally, and -/// verifies no Will Message is delivered to the subscriber. -#[tokio::test] -async fn connect_will_not_published_on_normal_disconnect() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.1.4-5"], + requires = ["transport.tcp"], +)] +async fn connect_will_not_published_on_normal_disconnect(sut: SutHandle) { let will_topic = format!("will/{}", unique_client_id("normal")); - let collector = MessageCollector::new(); - let subscriber = connected_client("will-sub2", &broker).await; - subscriber - .subscribe(&will_topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "will-sub2") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&will_topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; @@ -597,8 +567,7 @@ async fn connect_will_not_published_on_normal_disconnect() { let opts = ConnectOptions::new(unique_client_id("will-pub2")) .with_clean_start(true) .with_will(will); - let publisher = MqttClient::with_options(opts.clone()); - Box::pin(publisher.connect_with_options(broker.address(), opts)) + let publisher = TestClient::connect_with_options(&sut, opts) .await .expect("connect failed"); @@ -606,7 +575,7 @@ async fn connect_will_not_published_on_normal_disconnect() { tokio::time::sleep(Duration::from_millis(500)).await; assert_eq!( - collector.count(), + subscription.count(), 0, "[MQTT-3.1.4-5] Will message must NOT be published on normal disconnect" ); @@ -614,15 +583,15 @@ async fn connect_will_not_published_on_normal_disconnect() { subscriber.disconnect().await.expect("disconnect failed"); } -/// `[MQTT-3.1.4-6]` The Server MUST respond to a CONNECT packet with a CONNACK -/// packet. The CONNACK is the first packet sent from the Server to the Client. -/// -/// Sends a valid CONNECT via raw client and verifies a CONNACK with reason -/// code Success (0x00) is received. -#[tokio::test] -async fn connect_success_connack() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.1.4-6]` The Server MUST respond to a CONNECT packet with a +/// CONNACK packet. The CONNACK is the first packet sent from the Server to +/// the Client. +#[conformance_test( + ids = ["MQTT-3.1.4-6"], + requires = ["transport.tcp"], +)] +async fn connect_success_connack(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); diff --git a/crates/mqtt5-conformance/tests/section3_connect_extended.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_connect_extended.rs similarity index 62% rename from crates/mqtt5-conformance/tests/section3_connect_extended.rs rename to crates/mqtt5-conformance/src/conformance_tests/section3_connect_extended.rs index 11cb1f7c..67c85d5c 100644 --- a/crates/mqtt5-conformance/tests/section3_connect_extended.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_connect_extended.rs @@ -1,40 +1,35 @@ -//! MQTT v5.0 Section 3.1 — Extended CONNECT conformance tests. -//! -//! Covers Will Retain, Username/Password flag consistency, Maximum Packet Size, -//! Session Expiry, Will Delay Interval, Will User Properties, Request Response -//! Information, and reserved fixed header flags on server packets. - -use mqtt5::{ConnectOptions, MqttClient}; -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; -use mqtt5_conformance::raw_client::{ - put_mqtt_string, wrap_fixed_header, RawMqttClient, RawPacketBuilder, -}; +//! Section 3.1 — Extended CONNECT conformance tests. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{put_mqtt_string, wrap_fixed_header, RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{ConnectOptions, SubscribeOptions}; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); /// `[MQTT-3.1.2-14]` If Will Retain is set to 0, the Server MUST publish the /// Will Message as a non-retained message. -/// -/// Connects with Will Flag=1, Will Retain=0 via raw client, drops the -/// connection, and verifies the subscriber receives the will with retain=false. -#[tokio::test] -async fn will_retain_zero_publishes_non_retained() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.1.2-14"], + requires = ["transport.tcp"], +)] +async fn will_retain_zero_publishes_non_retained(sut: SutHandle) { let client_id = unique_client_id("wr0"); let will_topic = format!("will/{client_id}"); - let collector = MessageCollector::new(); - let subscriber = connected_client("wr0-sub", &broker).await; - subscriber - .subscribe(&will_topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "wr0-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&will_topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&RawPacketBuilder::connect_with_will_retain_flag( @@ -51,10 +46,12 @@ async fn will_retain_zero_publishes_non_retained() { tokio::time::sleep(Duration::from_millis(200)).await; assert!( - collector.wait_for_messages(1, Duration::from_secs(5)).await, + subscription + .wait_for_messages(1, Duration::from_secs(5)) + .await, "[MQTT-3.1.2-14] Will message must be published on connection drop" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert!( !msgs[0].retain, "[MQTT-3.1.2-14] Will message with Will Retain=0 must be published as non-retained" @@ -65,16 +62,15 @@ async fn will_retain_zero_publishes_non_retained() { /// `[MQTT-3.1.2-15]` If Will Retain is set to 1, the Server MUST publish the /// Will Message as a retained message. -/// -/// Connects with Will Flag=1, Will Retain=1 via raw client, drops the -/// connection, then subscribes a new client which receives the will as retained. -#[tokio::test] -async fn will_retain_one_publishes_retained() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.1.2-15"], + requires = ["transport.tcp", "retain_available"], +)] +async fn will_retain_one_publishes_retained(sut: SutHandle) { let client_id = unique_client_id("wr1"); let will_topic = format!("will/{client_id}"); - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&RawPacketBuilder::connect_with_will_retain_flag( @@ -90,18 +86,21 @@ async fn will_retain_one_publishes_retained() { drop(raw); tokio::time::sleep(Duration::from_secs(1)).await; - let collector = MessageCollector::new(); - let subscriber = connected_client("wr1-sub", &broker).await; - subscriber - .subscribe(&will_topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "wr1-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&will_topic, SubscribeOptions::default()) .await .expect("subscribe failed"); assert!( - collector.wait_for_messages(1, Duration::from_secs(5)).await, + subscription + .wait_for_messages(1, Duration::from_secs(5)) + .await, "[MQTT-3.1.2-15] New subscriber must receive retained will message" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert!( msgs[0].retain, "[MQTT-3.1.2-15] Will message with Will Retain=1 must be delivered as retained" @@ -112,15 +111,13 @@ async fn will_retain_one_publishes_retained() { /// `[MQTT-3.1.2-16]` If the User Name Flag is set to 1, a User Name MUST be /// present in the Payload. -/// -/// Verifies that Username Flag=1 with username present succeeds with a -/// CONNACK reason code of Success. -#[tokio::test] -async fn username_flag_with_username_succeeds() { - let broker = ConformanceBroker::start().await; - +#[conformance_test( + ids = ["MQTT-3.1.2-16"], + requires = ["transport.tcp"], +)] +async fn username_flag_with_username_succeeds(sut: SutHandle) { let client_id = unique_client_id("uflag-ok"); - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&RawPacketBuilder::connect_with_username( @@ -139,14 +136,12 @@ async fn username_flag_with_username_succeeds() { /// `[MQTT-3.1.2-17]` If the User Name Flag is set to 1 but no Username is /// present in the payload (truncated), the Server MUST treat it as malformed. -/// -/// Sends a CONNECT with Username Flag=1 but truncates the payload before the -/// username field, verifying the server disconnects. -#[tokio::test] -async fn username_flag_without_username_is_malformed() { - let broker = ConformanceBroker::start().await; - - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.2-17"], + requires = ["transport.tcp"], +)] +async fn username_flag_without_username_is_malformed(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&build_connect_username_flag_set_no_username()) @@ -160,15 +155,13 @@ async fn username_flag_without_username_is_malformed() { /// `[MQTT-3.1.2-18]` `[MQTT-3.1.2-19]` If the Password Flag is set to 1, a /// Password MUST be present in the Payload. -/// -/// Verifies that both Username and Password flags set with both fields -/// present in the payload succeeds with CONNACK reason code Success. -#[tokio::test] -async fn password_flag_payload_consistency() { - let broker = ConformanceBroker::start().await; - +#[conformance_test( + ids = ["MQTT-3.1.2-18", "MQTT-3.1.2-19"], + requires = ["transport.tcp"], +)] +async fn password_flag_payload_consistency(sut: SutHandle) { let client_id = unique_client_id("pflag-ok"); - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&build_connect_with_username_and_password( @@ -187,56 +180,56 @@ async fn password_flag_payload_consistency() { /// `[MQTT-3.1.2-23]` If Session Expiry Interval is greater than 0, the Server /// MUST store the Session State after the Network Connection is closed. -/// -/// Connects with `session_expiry=300`, subscribes, disconnects, reconnects with -/// `clean_start=false`, and verifies the subscription survived. -#[tokio::test] -async fn session_stored_when_expiry_positive() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.1.2-23"], + requires = ["transport.tcp"], +)] +async fn session_stored_when_expiry_positive(sut: SutHandle) { let client_id = unique_client_id("sess-store"); let topic = format!("sess/{client_id}"); let opts = ConnectOptions::new(&client_id) .with_clean_start(true) .with_session_expiry_interval(300); - let client1 = MqttClient::with_options(opts.clone()); - Box::pin(client1.connect_with_options(broker.address(), opts)) + let client1 = TestClient::connect_with_options(&sut, opts) .await .expect("first connect failed"); client1 - .subscribe(&topic, |_| {}) + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); client1.disconnect().await.expect("disconnect failed"); tokio::time::sleep(Duration::from_millis(200)).await; - let collector = MessageCollector::new(); let opts2 = ConnectOptions::new(&client_id) .with_clean_start(false) .with_session_expiry_interval(300); - let client2 = MqttClient::with_options(opts2.clone()); - let result = Box::pin(client2.connect_with_options(broker.address(), opts2)) + let client2 = TestClient::connect_with_options(&sut, opts2) .await .expect("reconnect failed"); assert!( - result.session_present, + client2.session_present(), "[MQTT-3.1.2-23] Reconnect with clean_start=false must have session_present=true" ); - client2 - .subscribe(&topic, collector.callback()) + let subscription = client2 + .subscribe(&topic, SubscribeOptions::default()) .await .expect("re-subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("sess-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "sess-pub") + .await + .unwrap(); publisher .publish(&topic, b"hello") .await .expect("publish failed"); assert!( - collector.wait_for_messages(1, Duration::from_secs(3)).await, + subscription + .wait_for_messages(1, Duration::from_secs(3)) + .await, "[MQTT-3.1.2-23] Subscription must survive across session reconnect" ); @@ -245,42 +238,39 @@ async fn session_stored_when_expiry_positive() { } /// `[MQTT-3.1.2-24]` `[MQTT-3.1.2-25]` The Server MUST NOT send packets -/// exceeding the Maximum Packet Size declared by the Client. If a packet -/// would exceed the limit, the Server MUST discard it without sending. -/// -/// Uses the high-level client API with Maximum Packet Size configured via -/// `ConnectOptions`. Publishes an oversized message and a small message, -/// verifying only the small one is delivered. -#[tokio::test] -async fn server_discards_oversized_publish() { - let broker = ConformanceBroker::start().await; +/// exceeding the Maximum Packet Size declared by the Client. +#[conformance_test( + ids = ["MQTT-3.1.2-24", "MQTT-3.1.2-25"], + requires = ["transport.tcp"], +)] +async fn server_discards_oversized_publish(sut: SutHandle) { let client_id = unique_client_id("maxpkt"); let topic = "t/mp"; - let collector = MessageCollector::new(); let mut opts = ConnectOptions::new(&client_id).with_clean_start(true); opts.properties.maximum_packet_size = Some(128); - let subscriber = MqttClient::with_options(opts.clone()); - Box::pin(subscriber.connect_with_options(broker.address(), opts)) + let subscriber = TestClient::connect_with_options(&sut, opts) .await .expect("connect failed"); - subscriber - .subscribe(topic, collector.callback()) + let subscription = subscriber + .subscribe(topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("maxpkt-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "maxpkt-pub") + .await + .unwrap(); let large_payload = vec![b'X'; 200]; publisher - .publish(topic, large_payload) + .publish(topic, &large_payload) .await .expect("publish failed"); tokio::time::sleep(Duration::from_secs(1)).await; assert_eq!( - collector.count(), + subscription.count(), 0, "[MQTT-3.1.2-24] Server must not send packet exceeding client Maximum Packet Size" ); @@ -291,27 +281,28 @@ async fn server_discards_oversized_publish() { .expect("small publish failed"); assert!( - collector.wait_for_messages(1, Duration::from_secs(3)).await, + subscription + .wait_for_messages(1, Duration::from_secs(3)) + .await, "[MQTT-3.1.2-25] Server must still deliver packets within Maximum Packet Size" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!(msgs[0].payload, b"ok"); publisher.disconnect().await.expect("disconnect failed"); subscriber.disconnect().await.expect("disconnect failed"); } -/// `[MQTT-3.1.2-28]` If the Client sets Request Response Information to 0, the -/// Server MUST NOT include Response Information in the CONNACK. -/// -/// Connects with Request Response Information=0 and verifies the CONNACK does -/// not contain a Response Information property. -#[tokio::test] -async fn request_response_info_zero_suppresses() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.1.2-28]` If the Client sets Request Response Information to 0, +/// the Server MUST NOT include Response Information in the CONNACK. +#[conformance_test( + ids = ["MQTT-3.1.2-28"], + requires = ["transport.tcp"], +)] +async fn request_response_info_zero_suppresses(sut: SutHandle) { let client_id = unique_client_id("rri0"); - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&RawPacketBuilder::connect_with_request_response_info( @@ -340,13 +331,12 @@ async fn request_response_info_zero_suppresses() { /// `[MQTT-3.1.3-8]` If the Server rejects the `ClientID`, it sends CONNACK /// with 0x85 and MUST then close the Network Connection. -/// -/// Sends a CONNECT with an invalid client ID, verifies CONNACK 0x85, then -/// verifies the connection is closed. -#[tokio::test] -async fn client_id_rejected_closes_connection() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.3-8"], + requires = ["transport.tcp"], +)] +async fn client_id_rejected_closes_connection(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -365,27 +355,27 @@ async fn client_id_rejected_closes_connection() { ); } -/// `[MQTT-3.1.3-9]` If a new Network Connection to this Session is made before -/// the Will Delay Interval has passed, the Server MUST NOT send the Will -/// Message. -/// -/// Connects with `will_delay=5s`, drops the connection, immediately reconnects -/// with the same client ID, and verifies no will is published. -#[tokio::test] -async fn will_delay_reconnect_suppresses_will() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.1.3-9]` If a new Network Connection to this Session is made +/// before the Will Delay Interval has passed, the Server MUST NOT send the +/// Will Message. +#[conformance_test( + ids = ["MQTT-3.1.3-9"], + requires = ["transport.tcp"], +)] +async fn will_delay_reconnect_suppresses_will(sut: SutHandle) { let client_id = unique_client_id("wdel"); let will_topic = format!("will/{client_id}"); - let collector = MessageCollector::new(); - let subscriber = connected_client("wdel-sub", &broker).await; - subscriber - .subscribe(&will_topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "wdel-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&will_topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&RawPacketBuilder::connect_with_will_delay( @@ -404,15 +394,14 @@ async fn will_delay_reconnect_suppresses_will() { let opts = ConnectOptions::new(&client_id) .with_clean_start(false) .with_session_expiry_interval(300); - let reconnected = MqttClient::with_options(opts.clone()); - Box::pin(reconnected.connect_with_options(broker.address(), opts)) + let reconnected = TestClient::connect_with_options(&sut, opts) .await .expect("reconnect failed"); tokio::time::sleep(Duration::from_secs(2)).await; assert_eq!( - collector.count(), + subscription.count(), 0, "[MQTT-3.1.3-9] Will message must not be sent when client reconnects before will delay expires" ); @@ -424,26 +413,25 @@ async fn will_delay_reconnect_suppresses_will() { /// `[MQTT-3.1.3-10]` The User Property is part of the Will Properties and /// the Server MUST maintain the order of User Properties when publishing the /// Will Message. -/// -/// Connects with will carrying ordered user properties, drops the connection, -/// and verifies the subscriber receives the will with properties in the same -/// order. -#[tokio::test] -async fn will_user_property_order_preserved() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.1.3-10"], + requires = ["transport.tcp"], +)] +async fn will_user_property_order_preserved(sut: SutHandle) { let client_id = unique_client_id("wup"); let will_topic = format!("will/{client_id}"); - let collector = MessageCollector::new(); - let subscriber = connected_client("wup-sub", &broker).await; - subscriber - .subscribe(&will_topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "wup-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&will_topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; let user_props = [("key1", "val1"), ("key2", "val2"), ("key3", "val3")]; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&RawPacketBuilder::connect_with_will_user_properties( @@ -461,12 +449,13 @@ async fn will_user_property_order_preserved() { tokio::time::sleep(Duration::from_millis(200)).await; assert!( - collector.wait_for_messages(1, Duration::from_secs(5)).await, + subscription + .wait_for_messages(1, Duration::from_secs(5)) + .await, "[MQTT-3.1.3-10] Will message must be published on connection drop" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); let received_props: Vec<(&str, &str)> = msgs[0] - .properties .user_properties .iter() .filter(|(k, _)| k.starts_with("key")) @@ -482,21 +471,19 @@ async fn will_user_property_order_preserved() { } /// `[MQTT-4.1.0-1]` The Client and Server MUST store Session State for the -/// entire duration of the Session. A Session MUST last at least as long as its -/// active Network Connection. -/// -/// While connected, subscribes and publishes to the same topic and verifies -/// the subscription delivers the message — confirming session state is active -/// throughout the connection lifetime. -#[tokio::test] -async fn session_not_discarded_while_connected() { - let broker = ConformanceBroker::start().await; +/// entire duration of the Session. +#[conformance_test( + ids = ["MQTT-4.1.0-1"], + requires = ["transport.tcp"], +)] +async fn session_not_discarded_while_connected(sut: SutHandle) { let topic = format!("sess/{}", unique_client_id("active")); - let client = connected_client("sess-active", &broker).await; - let collector = MessageCollector::new(); - client - .subscribe(&topic, collector.callback()) + let client = TestClient::connect_with_prefix(&sut, "sess-active") + .await + .unwrap(); + let subscription = client + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; @@ -507,10 +494,12 @@ async fn session_not_discarded_while_connected() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, Duration::from_secs(3)).await, + subscription + .wait_for_messages(1, Duration::from_secs(3)) + .await, "[MQTT-4.1.0-1] Session state (subscriptions) must persist while connected" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!(msgs[0].payload, b"alive"); client.disconnect().await.expect("disconnect failed"); @@ -518,23 +507,21 @@ async fn session_not_discarded_while_connected() { /// `[MQTT-4.1.0-2]` After the Session Expiry Interval has passed, the Server /// MUST discard the Session State. -/// -/// Connects with `session_expiry=1`, subscribes, disconnects, waits 3 seconds, -/// reconnects with `clean_start=false`, and verifies `session_present=false`. -#[tokio::test] -async fn session_discarded_after_expiry() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.1.0-2"], + requires = ["transport.tcp"], +)] +async fn session_discarded_after_expiry(sut: SutHandle) { let client_id = unique_client_id("sess-exp"); let opts = ConnectOptions::new(&client_id) .with_clean_start(true) .with_session_expiry_interval(1); - let client1 = MqttClient::with_options(opts.clone()); - Box::pin(client1.connect_with_options(broker.address(), opts)) + let client1 = TestClient::connect_with_options(&sut, opts) .await .expect("first connect failed"); client1 - .subscribe("sess/expiry", |_| {}) + .subscribe("sess/expiry", SubscribeOptions::default()) .await .expect("subscribe failed"); client1.disconnect().await.expect("disconnect failed"); @@ -544,12 +531,11 @@ async fn session_discarded_after_expiry() { let opts2 = ConnectOptions::new(&client_id) .with_clean_start(false) .with_session_expiry_interval(300); - let client2 = MqttClient::with_options(opts2.clone()); - let result = Box::pin(client2.connect_with_options(broker.address(), opts2)) + let client2 = TestClient::connect_with_options(&sut, opts2) .await .expect("reconnect failed"); assert!( - !result.session_present, + !client2.session_present(), "[MQTT-4.1.0-2] Session must be discarded after expiry interval passes" ); @@ -557,17 +543,15 @@ async fn session_discarded_after_expiry() { } /// `[MQTT-2.1.3-1]` Where a flag bit is marked as Reserved, it is reserved -/// for future use and MUST be set to the value listed. The Server MUST check -/// fixed header flags are correct. -/// -/// Verifies that CONNACK (0x20), SUBACK (0x90), and PUBACK (0x40) all have -/// the correct reserved fixed header flag byte values. -#[tokio::test] -async fn reserved_flags_correct_on_server_packets() { - let broker = ConformanceBroker::start().await; +/// for future use and MUST be set to the value listed. +#[conformance_test( + ids = ["MQTT-2.1.3-1"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn reserved_flags_correct_on_server_packets(sut: SutHandle) { let client_id = unique_client_id("rflags"); - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); raw.send_raw(&RawPacketBuilder::valid_connect(&client_id)) diff --git a/crates/mqtt5-conformance/src/conformance_tests/section3_disconnect.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_disconnect.rs new file mode 100644 index 00000000..98e144de --- /dev/null +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_disconnect.rs @@ -0,0 +1,268 @@ +//! Section 3.14 — DISCONNECT packet behavior. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::SubscribeOptions; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(3); + +/// `[MQTT-3.14.4-3]` On receipt of DISCONNECT with Reason Code 0x00 the +/// Server MUST discard the Will Message without publishing it. +#[conformance_test( + ids = ["MQTT-3.14.4-3"], + requires = ["transport.tcp"], +)] +async fn disconnect_normal_suppresses_will(sut: SutHandle) { + let will_id = unique_client_id("disc-normal"); + let will_topic = format!("will/{will_id}"); + + let subscriber = TestClient::connect_with_prefix(&sut, "disc-norm-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&will_topic, SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + raw.send_raw(&RawPacketBuilder::connect_with_will_and_keepalive( + &will_id, 60, + )) + .await + .unwrap(); + raw.expect_connack(TIMEOUT).await.expect("expected CONNACK"); + + raw.send_raw(&RawPacketBuilder::disconnect_normal()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(500)).await; + + assert_eq!( + subscription.count(), + 0, + "[MQTT-3.14.4-3] will must NOT be published on normal disconnect (0x00)" + ); + + subscriber.disconnect().await.expect("disconnect failed"); +} + +/// `[MQTT-3.1.4-5]` DISCONNECT with reason code 0x04 +/// (`DisconnectWithWillMessage`) MUST still trigger will publication. +#[conformance_test( + ids = ["MQTT-3.1.4-5"], + requires = ["transport.tcp"], +)] +async fn disconnect_with_will_message_publishes_will(sut: SutHandle) { + let will_id = unique_client_id("disc-0x04"); + let will_topic = format!("will/{will_id}"); + + let subscriber = TestClient::connect_with_prefix(&sut, "disc-04-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&will_topic, SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + raw.send_raw(&RawPacketBuilder::connect_with_will_and_keepalive( + &will_id, 60, + )) + .await + .unwrap(); + raw.expect_connack(TIMEOUT).await.expect("expected CONNACK"); + + raw.send_raw(&RawPacketBuilder::disconnect_with_reason(0x04)) + .await + .unwrap(); + + let msg = subscription + .expect_publish(Duration::from_secs(3)) + .await + .expect("will must be published when DISCONNECT reason is 0x04"); + assert_eq!(msg.topic, will_topic); + assert_eq!(msg.payload, b"offline"); + + subscriber.disconnect().await.expect("disconnect failed"); +} + +/// `[MQTT-3.1.4-5]` Connect with will + keepalive=2s, drop TCP without +/// sending DISCONNECT. Will MUST be published after keep-alive timeout. +#[conformance_test( + ids = ["MQTT-3.1.4-5"], + requires = ["transport.tcp"], +)] +async fn tcp_drop_publishes_will(sut: SutHandle) { + let will_id = unique_client_id("disc-drop"); + let will_topic = format!("will/{will_id}"); + + let subscriber = TestClient::connect_with_prefix(&sut, "disc-drop-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&will_topic, SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + raw.send_raw(&RawPacketBuilder::connect_with_will_and_keepalive( + &will_id, 2, + )) + .await + .unwrap(); + raw.expect_connack(TIMEOUT).await.expect("expected CONNACK"); + + drop(raw); + + let msg = subscription + .expect_publish(Duration::from_secs(6)) + .await + .expect("will must be published after TCP drop"); + assert_eq!(msg.topic, will_topic); + assert_eq!(msg.payload, b"offline"); + + subscriber.disconnect().await.expect("disconnect failed"); +} + +/// `[MQTT-3.14.2-1]` Send DISCONNECT with various valid reason codes +/// (0x00, 0x04, 0x80) and verify the broker accepts them cleanly. +#[conformance_test( + ids = ["MQTT-3.14.2-1"], + requires = ["transport.tcp"], +)] +async fn disconnect_valid_reason_codes_accepted(sut: SutHandle) { + for &reason in &[0x00u8, 0x04, 0x80] { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id(&format!("disc-rc-{reason:02x}")); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + let packet = if reason == 0x00 { + RawPacketBuilder::disconnect_normal() + } else { + RawPacketBuilder::disconnect_with_reason(reason) + }; + raw.send_raw(&packet).await.unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-3.14.2-1] broker must accept valid reason code 0x{reason:02x} and close connection" + ); + } +} + +/// `[MQTT-3.14.2-1]` Send DISCONNECT with an invalid reason code byte +/// (0x03 is not in the valid set). The broker must close the connection. +#[conformance_test( + ids = ["MQTT-3.14.2-1"], + requires = ["transport.tcp"], +)] +async fn disconnect_invalid_reason_code_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("disc-bad-rc"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::disconnect_with_reason(0x03)) + .await + .unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-3.14.2-1] broker must reject invalid reason code 0x03 and close connection" + ); +} + +/// `[MQTT-4.13.2-1]` Sending a second CONNECT packet is a protocol error. +/// The server MUST send DISCONNECT with an error reason code and close +/// the connection. +#[conformance_test( + ids = ["MQTT-4.13.2-1"], + requires = ["transport.tcp"], +)] +async fn server_disconnect_on_protocol_error(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("disc-proto-err"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::valid_connect("second-connect")) + .await + .unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "server must disconnect client after receiving second CONNECT" + ); +} + +/// `[MQTT-3.14.2-1]` When the server disconnects a client due to a +/// protocol error, the DISCONNECT packet MUST use a reason code from the +/// specification's allowed set. +#[conformance_test( + ids = ["MQTT-3.14.2-1"], + requires = ["transport.tcp"], +)] +async fn server_disconnect_uses_valid_reason_code(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("disc-rc-check"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::valid_connect("second-connect-2")) + .await + .unwrap(); + + let valid_disconnect_codes: &[u8] = &[ + 0x00, 0x04, 0x80, 0x81, 0x82, 0x83, 0x87, 0x89, 0x8B, 0x8D, 0x8E, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, 0xA1, 0xA2, + ]; + + if let Some(reason_code) = raw.expect_disconnect_packet(TIMEOUT).await { + assert!( + valid_disconnect_codes.contains(&reason_code), + "[MQTT-3.14.2-1] server DISCONNECT reason code 0x{reason_code:02x} is not in the valid set" + ); + } +} + +/// `[MQTT-3.14.4-1]` / `[MQTT-3.14.4-2]` After sending DISCONNECT, the +/// sender MUST close the connection. Verify no PINGRESP arrives in +/// response to a PINGREQ sent after client DISCONNECT. +#[conformance_test( + ids = ["MQTT-3.14.4-1", "MQTT-3.14.4-2"], + requires = ["transport.tcp"], +)] +async fn no_packets_after_client_disconnect(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("disc-no-pkt"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::disconnect_normal()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + let _ = raw.send_raw(&RawPacketBuilder::pingreq()).await; + + assert!( + !raw.expect_pingresp(Duration::from_secs(1)).await, + "[MQTT-3.14.4-1] no PINGRESP should be received after client sent DISCONNECT" + ); +} diff --git a/crates/mqtt5-conformance/tests/section3_final_conformance.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_final_conformance.rs similarity index 79% rename from crates/mqtt5-conformance/tests/section3_final_conformance.rs rename to crates/mqtt5-conformance/src/conformance_tests/section3_final_conformance.rs index 79d26f69..afd665ba 100644 --- a/crates/mqtt5-conformance/tests/section3_final_conformance.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_final_conformance.rs @@ -1,15 +1,23 @@ -use mqtt5_conformance::harness::{unique_client_id, ConformanceBroker}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Section 3 — Final conformance tests for UTF-8 validation, server +//! DISCONNECT ordering, request-problem-information suppression, and `QoS` 2 +//! message expiry behaviour. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); /// `[MQTT-3.1.3-11]` The Will Topic MUST be a UTF-8 Encoded String. /// Sending invalid UTF-8 bytes in the Will Topic must cause disconnect. -#[tokio::test] -async fn will_topic_invalid_utf8_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.3-11"], + requires = ["transport.tcp"], +)] +async fn will_topic_invalid_utf8_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("will-utf8"); @@ -28,10 +36,12 @@ async fn will_topic_invalid_utf8_rejected() { /// `[MQTT-3.1.3-12]` The User Name MUST be a UTF-8 Encoded String. /// Sending invalid UTF-8 bytes in the Username must cause disconnect. -#[tokio::test] -async fn username_invalid_utf8_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.3-12"], + requires = ["transport.tcp"], +)] +async fn username_invalid_utf8_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("user-utf8"); @@ -50,13 +60,12 @@ async fn username_invalid_utf8_rejected() { /// `[MQTT-3.14.0-1]` Server MUST NOT send DISCONNECT until after it has sent a /// CONNACK with Reason Code < 0x80. -/// -/// Send a CONNECT with an invalid protocol version — the broker should send -/// a CONNACK with error code, NOT a DISCONNECT first. -#[tokio::test] -async fn no_server_disconnect_before_connack() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.14.0-1"], + requires = ["transport.tcp"], +)] +async fn no_server_disconnect_before_connack(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -78,14 +87,14 @@ async fn no_server_disconnect_before_connack() { } } -/// `[MQTT-3.14.2-2]` Session Expiry Interval MUST NOT be sent on a server DISCONNECT. -/// -/// Trigger a server-initiated DISCONNECT (second CONNECT) and verify the -/// DISCONNECT packet properties do not contain Session Expiry Interval (0x11). -#[tokio::test] -async fn server_disconnect_no_session_expiry() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.14.2-2]` Session Expiry Interval MUST NOT be sent on a server +/// DISCONNECT. +#[conformance_test( + ids = ["MQTT-3.14.2-2"], + requires = ["transport.tcp"], +)] +async fn server_disconnect_no_session_expiry(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("disc-noexp"); @@ -176,14 +185,12 @@ async fn server_disconnect_no_session_expiry() { /// `[MQTT-3.1.2-29]` If Request Problem Information is 0, the Server MUST NOT /// send a Reason String or User Property on any packet other than PUBLISH, /// CONNACK, or DISCONNECT. -/// -/// Connect with `request_problem_info=0`, trigger a SUBACK with an error reason -/// code (subscribe to a topic matching an ACL deny pattern), and verify the -/// SUBACK contains no Reason String (`0x1F`) or User Property (`0x26`). -#[tokio::test] -async fn request_problem_info_zero_suppresses_properties() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.2-29"], + requires = ["transport.tcp"], +)] +async fn request_problem_info_zero_suppresses_properties(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("rpi-zero"); @@ -244,15 +251,14 @@ async fn request_problem_info_zero_suppresses_properties() { } } -/// `[MQTT-4.3.3-7]` Server MUST NOT apply message expiry if PUBLISH has been sent. -/// -/// Subscribe at `QoS` 2, publish with Message Expiry Interval=1s, receive the -/// PUBLISH from the broker, wait >1s, then send PUBREC. The broker must -/// still respond with PUBREL (not drop the message). -#[tokio::test] -async fn qos2_no_message_expiry_after_publish_sent() { - let broker = ConformanceBroker::start().await; - let mut sub_raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-4.3.3-7]` Server MUST NOT apply message expiry if PUBLISH has been +/// sent. +#[conformance_test( + ids = ["MQTT-4.3.3-7"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn qos2_no_message_expiry_after_publish_sent(sut: SutHandle) { + let mut sub_raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("exp-sub"); @@ -272,7 +278,7 @@ async fn qos2_no_message_expiry_after_publish_sent() { .expect("expected SUBACK"); tokio::time::sleep(Duration::from_millis(100)).await; - let mut pub_raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("exp-pub"); @@ -327,14 +333,12 @@ async fn qos2_no_message_expiry_after_publish_sent() { /// `[MQTT-4.3.3-13]` Server MUST continue the `QoS` 2 acknowledgement sequence /// even if it has applied message expiry. -/// -/// Publisher sends `QoS` 2 PUBLISH with Message Expiry Interval=1s, broker -/// responds with PUBREC, publisher waits >1s, then sends PUBREL. Broker -/// must respond with PUBCOMP. -#[tokio::test] -async fn qos2_continues_despite_expiry() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-4.3.3-13"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn qos2_continues_despite_expiry(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("exp-cont"); diff --git a/crates/mqtt5-conformance/tests/section3_ping.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_ping.rs similarity index 66% rename from crates/mqtt5-conformance/tests/section3_ping.rs rename to crates/mqtt5-conformance/src/conformance_tests/section3_ping.rs index a361741c..924d38d4 100644 --- a/crates/mqtt5-conformance/tests/section3_ping.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_ping.rs @@ -1,18 +1,24 @@ -use mqtt5_conformance::harness::{unique_client_id, ConformanceBroker}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Section 3.12 — PINGREQ/PINGRESP and Section 3.1.2-11 — keep-alive timeout. +//! +//! Proof-of-concept module for the `#[conformance_test]` proc-macro and the +//! `mqtt5-conformance-cli` runner. Exercises the raw-client path because the +//! assertions under test live at the packet level. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); -// --------------------------------------------------------------------------- -// Group 1: PINGREQ/PINGRESP Exchange — Section 3.12 -// --------------------------------------------------------------------------- - /// `[MQTT-3.12.4-1]` Server MUST send PINGRESP in response to PINGREQ. -#[tokio::test] -async fn pingresp_sent_on_pingreq() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.12.4-1"], + requires = ["transport.tcp"], +)] +async fn pingresp_sent_on_pingreq(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("ping-resp"); @@ -27,10 +33,12 @@ async fn pingresp_sent_on_pingreq() { } /// Send 3 PINGREQs in sequence, verify all 3 PINGRESPs are received. -#[tokio::test] -async fn multiple_pingreqs_all_responded() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.12.4-1"], + requires = ["transport.tcp"], +)] +async fn multiple_pingreqs_all_responded(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("ping-multi"); @@ -46,16 +54,14 @@ async fn multiple_pingreqs_all_responded() { } } -// --------------------------------------------------------------------------- -// Group 2: Keep-Alive Timeout Enforcement — Section 3.1.2-11 -// --------------------------------------------------------------------------- - /// `[MQTT-3.1.2-11]` Connect with keep-alive=2s, go silent, verify broker /// closes the connection within 1.5x the keep-alive (3s), with 1s margin. -#[tokio::test] -async fn keepalive_timeout_closes_connection() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.2-11"], + requires = ["transport.tcp"], +)] +async fn keepalive_timeout_closes_connection(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("ka-timeout"); @@ -72,10 +78,12 @@ async fn keepalive_timeout_closes_connection() { /// Connect with keep-alive=0 (disabled), go silent for 5s, verify connection /// stays open by sending a PINGREQ and getting a PINGRESP. -#[tokio::test] -async fn keepalive_zero_no_timeout() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.2-11"], + requires = ["transport.tcp"], +)] +async fn keepalive_zero_no_timeout(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("ka-zero"); @@ -95,10 +103,12 @@ async fn keepalive_zero_no_timeout() { /// Connect with keep-alive=2s, send PINGREQ every second for 5s. The /// PINGREQs reset the keep-alive timer so the connection must stay alive. -#[tokio::test] -async fn pingreq_resets_keepalive() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.1.2-11"], + requires = ["transport.tcp"], +)] +async fn pingreq_resets_keepalive(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("ka-reset"); diff --git a/crates/mqtt5-conformance/tests/section3_publish.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_publish.rs similarity index 54% rename from crates/mqtt5-conformance/tests/section3_publish.rs rename to crates/mqtt5-conformance/src/conformance_tests/section3_publish.rs index 1f2340a8..cd392561 100644 --- a/crates/mqtt5-conformance/tests/section3_publish.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_publish.rs @@ -1,24 +1,25 @@ -use mqtt5::{PublishOptions, PublishProperties, QoS, RetainHandling, SubscribeOptions}; -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, +//! Section 3.3 — PUBLISH packet conformance tests. + +use crate::assertions; +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{ + PublishOptions, PublishProperties, QoS, RetainHandling, SubscribeOptions, }; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); -// --------------------------------------------------------------------------- -// Group 1: Malformed/Invalid PUBLISH (raw client) -// --------------------------------------------------------------------------- - /// `[MQTT-3.3.1-4]` A PUBLISH packet MUST NOT have both `QoS` bits set to 1. -/// -/// Sends a PUBLISH with QoS=3 (header byte `0x36`) after CONNECT and -/// verifies the broker disconnects. -#[tokio::test] -async fn publish_qos3_is_malformed() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.3.1-4"], + requires = ["transport.tcp"], +)] +async fn publish_qos3_is_malformed(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("qos3"); @@ -31,20 +32,16 @@ async fn publish_qos3_is_malformed() { .await .unwrap(); - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.3.1-4] Server must disconnect client that sends QoS=3" - ); + assertions::expect_disconnect(&mut raw, "MQTT-3.3.1-4", TIMEOUT).await; } /// `[MQTT-3.3.1-2]` The DUP flag MUST be set to 0 for all `QoS` 0 messages. -/// -/// Sends a PUBLISH with DUP=1 and QoS=0 (header byte `0x38`) and verifies -/// the broker disconnects. -#[tokio::test] -async fn publish_dup_must_be_zero_for_qos0() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.3.1-2"], + requires = ["transport.tcp"], +)] +async fn publish_dup_must_be_zero_for_qos0(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("dupq0"); @@ -54,20 +51,17 @@ async fn publish_dup_must_be_zero_for_qos0() { .await .unwrap(); - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.3.1-2] Server must disconnect client that sends DUP=1 with QoS=0" - ); + assertions::expect_disconnect(&mut raw, "MQTT-3.3.1-2", TIMEOUT).await; } /// `[MQTT-3.3.2-2]` The Topic Name in the PUBLISH packet MUST NOT contain /// wildcard characters. -/// -/// Sends a PUBLISH with topic `test/#` and verifies the broker disconnects. -#[tokio::test] -async fn publish_topic_must_not_contain_wildcards() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.3.2-2"], + requires = ["transport.tcp"], +)] +async fn publish_topic_must_not_contain_wildcards(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("wild"); @@ -77,21 +71,17 @@ async fn publish_topic_must_not_contain_wildcards() { .await .unwrap(); - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.3.2-2] Server must disconnect client that publishes to wildcard topic" - ); + assertions::expect_disconnect(&mut raw, "MQTT-3.3.2-2", TIMEOUT).await; } /// `[MQTT-3.3.2-1]` The Topic Name MUST be present as the first field in the /// PUBLISH packet Variable Header. -/// -/// Sends a PUBLISH with an empty topic and no Topic Alias, and verifies -/// the broker disconnects. -#[tokio::test] -async fn publish_topic_must_not_be_empty_without_alias() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.3.2-1"], + requires = ["transport.tcp"], +)] +async fn publish_topic_must_not_be_empty_without_alias(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("empty"); @@ -101,20 +91,16 @@ async fn publish_topic_must_not_be_empty_without_alias() { .await .unwrap(); - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.3.2-1] Server must disconnect client that publishes with empty topic and no alias" - ); + assertions::expect_disconnect(&mut raw, "MQTT-3.3.2-1", TIMEOUT).await; } /// `[MQTT-3.3.2-7]` A Topic Alias of 0 is not permitted. -/// -/// Sends a PUBLISH with Topic Alias property set to 0 and verifies the -/// broker disconnects with a protocol error. -#[tokio::test] -async fn publish_topic_alias_zero_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.3.2-7"], + requires = ["transport.tcp"], +)] +async fn publish_topic_alias_zero_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("alias0"); @@ -127,21 +113,17 @@ async fn publish_topic_alias_zero_rejected() { .await .unwrap(); - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.3.2-7] Server must disconnect client that sends Topic Alias = 0" - ); + assertions::expect_disconnect(&mut raw, "MQTT-3.3.2-7", TIMEOUT).await; } /// `[MQTT-3.3.4-6]` A PUBLISH packet sent from a Client to a Server MUST NOT /// contain a Subscription Identifier. -/// -/// Sends a PUBLISH with a Subscription Identifier property and verifies the -/// broker disconnects. -#[tokio::test] -async fn publish_subscription_id_from_client_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.3.4-6"], + requires = ["transport.tcp"], +)] +async fn publish_subscription_id_from_client_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("subid"); @@ -155,27 +137,21 @@ async fn publish_subscription_id_from_client_rejected() { .await .unwrap(); - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.3.4-6] Server must reject PUBLISH with Subscription Identifier from client" - ); + assertions::expect_disconnect(&mut raw, "MQTT-3.3.4-6", TIMEOUT).await; } -// --------------------------------------------------------------------------- -// Group 2: Retained Messages (high-level client) -// --------------------------------------------------------------------------- - /// `[MQTT-3.3.1-5]` If the RETAIN flag is set to 1, the Server MUST store /// the Application Message and replace any existing retained message. -/// -/// Publishes a retained message, then a new subscriber connects and verifies -/// it receives the retained message. -#[tokio::test] -async fn publish_retain_stores_message() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.1-5"], + requires = ["transport.tcp", "retain_available"], +)] +async fn publish_retain_stores_message(sut: SutHandle) { let topic = format!("retain/{}", unique_client_id("store")); - let publisher = connected_client("ret-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "ret-pub") + .await + .unwrap(); let opts = PublishOptions { retain: true, ..Default::default() @@ -187,33 +163,35 @@ async fn publish_retain_stores_message() { tokio::time::sleep(Duration::from_millis(200)).await; publisher.disconnect().await.expect("disconnect failed"); - let collector = MessageCollector::new(); - let subscriber = connected_client("ret-sub", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "ret-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "[MQTT-3.3.1-5] New subscriber must receive retained message" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!(msgs[0].payload, b"retained-payload"); subscriber.disconnect().await.expect("disconnect failed"); } /// `[MQTT-3.3.1-6]` `[MQTT-3.3.1-7]` A retained message with empty payload /// removes any existing retained message for that topic and MUST NOT be stored. -/// -/// Publishes a retained message, then publishes a retained empty payload to -/// clear it. A new subscriber verifies no retained message is delivered. -#[tokio::test] -async fn publish_retain_empty_payload_clears() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.1-6", "MQTT-3.3.1-7"], + requires = ["transport.tcp", "retain_available"], +)] +async fn publish_retain_empty_payload_clears(sut: SutHandle) { let topic = format!("retain/{}", unique_client_id("clear")); - let publisher = connected_client("clr-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "clr-pub") + .await + .unwrap(); let retain_opts = PublishOptions { retain: true, ..Default::default() @@ -231,16 +209,17 @@ async fn publish_retain_empty_payload_clears() { tokio::time::sleep(Duration::from_millis(200)).await; publisher.disconnect().await.expect("disconnect failed"); - let collector = MessageCollector::new(); - let subscriber = connected_client("clr-sub", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "clr-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(500)).await; assert_eq!( - collector.count(), + subscription.count(), 0, "[MQTT-3.3.1-6/7] Retained message must be cleared by empty payload publish" ); @@ -250,16 +229,16 @@ async fn publish_retain_empty_payload_clears() { /// `[MQTT-3.3.1-8]` If the RETAIN flag is 0, the Server MUST NOT store the /// message as a retained message and MUST NOT remove or replace any existing /// retained message. -/// -/// Publishes a retained message, then publishes a non-retained message to -/// the same topic. A new subscriber verifies it receives the original retained -/// message (not the non-retained one). -#[tokio::test] -async fn publish_no_retain_does_not_store() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.1-8"], + requires = ["transport.tcp", "retain_available"], +)] +async fn publish_no_retain_does_not_store(sut: SutHandle) { let topic = format!("retain/{}", unique_client_id("noret")); - let publisher = connected_client("nr-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "nr-pub") + .await + .unwrap(); let retain_opts = PublishOptions { retain: true, ..Default::default() @@ -277,18 +256,19 @@ async fn publish_no_retain_does_not_store() { tokio::time::sleep(Duration::from_millis(200)).await; publisher.disconnect().await.expect("disconnect failed"); - let collector = MessageCollector::new(); - let subscriber = connected_client("nr-sub", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "nr-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "[MQTT-3.3.1-8] Original retained message must still be delivered" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!( msgs[0].payload, b"original-retained", "[MQTT-3.3.1-8] Non-retained publish must not replace retained message" @@ -296,21 +276,18 @@ async fn publish_no_retain_does_not_store() { subscriber.disconnect().await.expect("disconnect failed"); } -// --------------------------------------------------------------------------- -// Group 3: Retain Handling Options -// --------------------------------------------------------------------------- - /// `[MQTT-3.3.1-9]` Retain Handling 0 — the Server MUST send retained /// messages matching the Topic Filter of the subscription. -/// -/// Publishes a retained message, then subscribes with `RetainHandling::SendAtSubscribe` -/// and verifies the retained message is delivered. -#[tokio::test] -async fn publish_retain_handling_zero_sends_retained() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.1-9"], + requires = ["transport.tcp", "retain_available"], +)] +async fn publish_retain_handling_zero_sends_retained(sut: SutHandle) { let topic = format!("rh0/{}", unique_client_id("rh")); - let publisher = connected_client("rh0-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "rh0-pub") + .await + .unwrap(); let retain_opts = PublishOptions { retain: true, ..Default::default() @@ -321,19 +298,20 @@ async fn publish_retain_handling_zero_sends_retained() { .expect("publish failed"); tokio::time::sleep(Duration::from_millis(200)).await; - let collector = MessageCollector::new(); - let subscriber = connected_client("rh0-sub", &broker).await; + let subscriber = TestClient::connect_with_prefix(&sut, "rh0-sub") + .await + .unwrap(); let sub_opts = SubscribeOptions { retain_handling: RetainHandling::SendAtSubscribe, ..Default::default() }; - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let subscription = subscriber + .subscribe(&topic, sub_opts) .await .expect("subscribe failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "[MQTT-3.3.1-9] RetainHandling=0 must deliver retained message" ); publisher.disconnect().await.expect("disconnect failed"); @@ -342,15 +320,16 @@ async fn publish_retain_handling_zero_sends_retained() { /// `[MQTT-3.3.1-10]` Retain Handling 1 — send retained messages only for new /// subscriptions, not for re-subscriptions. -/// -/// Subscribes with `RetainHandling::SendIfNew`, verifies retained message -/// arrives, then re-subscribes and verifies no second retained delivery. -#[tokio::test] -async fn publish_retain_handling_one_only_new_subs() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.1-10"], + requires = ["transport.tcp", "retain_available"], +)] +async fn publish_retain_handling_one_only_new_subs(sut: SutHandle) { let topic = format!("rh1/{}", unique_client_id("rh")); - let publisher = connected_client("rh1-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "rh1-pub") + .await + .unwrap(); let retain_opts = PublishOptions { retain: true, ..Default::default() @@ -361,31 +340,32 @@ async fn publish_retain_handling_one_only_new_subs() { .expect("publish failed"); tokio::time::sleep(Duration::from_millis(200)).await; - let collector = MessageCollector::new(); let sub_opts = SubscribeOptions { retain_handling: RetainHandling::SendIfNew, ..Default::default() }; - let subscriber = connected_client("rh1-sub", &broker).await; - subscriber - .subscribe_with_options(&topic, sub_opts.clone(), collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "rh1-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, sub_opts.clone()) .await .expect("first subscribe failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "[MQTT-3.3.1-10] First subscription must receive retained message" ); - collector.clear(); + subscription.clear(); - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let subscription2 = subscriber + .subscribe(&topic, sub_opts) .await .expect("re-subscribe failed"); tokio::time::sleep(Duration::from_millis(500)).await; assert_eq!( - collector.count(), + subscription2.count(), 0, "[MQTT-3.3.1-10] Re-subscription must NOT receive retained message" ); @@ -395,15 +375,16 @@ async fn publish_retain_handling_one_only_new_subs() { /// `[MQTT-3.3.1-11]` Retain Handling 2 — the Server MUST NOT send retained /// messages. -/// -/// Publishes a retained message, then subscribes with -/// `RetainHandling::DontSend` and verifies no retained message is delivered. -#[tokio::test] -async fn publish_retain_handling_two_no_retained() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.1-11"], + requires = ["transport.tcp", "retain_available"], +)] +async fn publish_retain_handling_two_no_retained(sut: SutHandle) { let topic = format!("rh2/{}", unique_client_id("rh")); - let publisher = connected_client("rh2-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "rh2-pub") + .await + .unwrap(); let retain_opts = PublishOptions { retain: true, ..Default::default() @@ -414,20 +395,21 @@ async fn publish_retain_handling_two_no_retained() { .expect("publish failed"); tokio::time::sleep(Duration::from_millis(200)).await; - let collector = MessageCollector::new(); - let subscriber = connected_client("rh2-sub", &broker).await; + let subscriber = TestClient::connect_with_prefix(&sut, "rh2-sub") + .await + .unwrap(); let sub_opts = SubscribeOptions { retain_handling: RetainHandling::DontSend, ..Default::default() }; - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let subscription = subscriber + .subscribe(&topic, sub_opts) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(500)).await; assert_eq!( - collector.count(), + subscription.count(), 0, "[MQTT-3.3.1-11] RetainHandling=2 must NOT deliver retained messages" ); @@ -435,34 +417,31 @@ async fn publish_retain_handling_two_no_retained() { subscriber.disconnect().await.expect("disconnect failed"); } -// --------------------------------------------------------------------------- -// Group 4: Retain As Published -// --------------------------------------------------------------------------- - /// `[MQTT-3.3.1-12]` If `retain_as_published` is 0, the Server MUST set the /// RETAIN flag to 0 when forwarding. -/// -/// Publishes a retained message to a subscriber that set -/// `retain_as_published=false`. Verifies the delivered message has -/// `retain=false`. -#[tokio::test] -async fn publish_retain_as_published_zero_clears_flag() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.1-12"], + requires = ["transport.tcp", "retain_available"], +)] +async fn publish_retain_as_published_zero_clears_flag(sut: SutHandle) { let topic = format!("rap0/{}", unique_client_id("rap")); - let collector = MessageCollector::new(); - let subscriber = connected_client("rap0-sub", &broker).await; + let subscriber = TestClient::connect_with_prefix(&sut, "rap0-sub") + .await + .unwrap(); let sub_opts = SubscribeOptions { retain_as_published: false, ..Default::default() }; - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let subscription = subscriber + .subscribe(&topic, sub_opts) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("rap0-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "rap0-pub") + .await + .unwrap(); let pub_opts = PublishOptions { retain: true, ..Default::default() @@ -473,10 +452,10 @@ async fn publish_retain_as_published_zero_clears_flag() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "Subscriber must receive message" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert!( !msgs[0].retain, "[MQTT-3.3.1-12] retain_as_published=0 must clear RETAIN flag on delivery" @@ -487,28 +466,29 @@ async fn publish_retain_as_published_zero_clears_flag() { /// `[MQTT-3.3.1-13]` If `retain_as_published` is 1, the Server MUST set the /// RETAIN flag equal to the received PUBLISH packet's RETAIN flag. -/// -/// Publishes a retained message to a subscriber that set -/// `retain_as_published=true`. Verifies the delivered message has -/// `retain=true`. -#[tokio::test] -async fn publish_retain_as_published_one_preserves_flag() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.1-13"], + requires = ["transport.tcp", "retain_available"], +)] +async fn publish_retain_as_published_one_preserves_flag(sut: SutHandle) { let topic = format!("rap1/{}", unique_client_id("rap")); - let collector = MessageCollector::new(); - let subscriber = connected_client("rap1-sub", &broker).await; + let subscriber = TestClient::connect_with_prefix(&sut, "rap1-sub") + .await + .unwrap(); let sub_opts = SubscribeOptions { retain_as_published: true, ..Default::default() }; - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let subscription = subscriber + .subscribe(&topic, sub_opts) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("rap1-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "rap1-pub") + .await + .unwrap(); let pub_opts = PublishOptions { retain: true, ..Default::default() @@ -519,10 +499,10 @@ async fn publish_retain_as_published_one_preserves_flag() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "Subscriber must receive message" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert!( msgs[0].retain, "[MQTT-3.3.1-13] retain_as_published=1 must preserve RETAIN flag on delivery" @@ -531,37 +511,36 @@ async fn publish_retain_as_published_one_preserves_flag() { subscriber.disconnect().await.expect("disconnect failed"); } -// --------------------------------------------------------------------------- -// Group 5: QoS Response Flows -// --------------------------------------------------------------------------- - /// `[MQTT-3.3.4-1]` `QoS` 0 message delivered to subscriber. -/// -/// Publishes a `QoS` 0 message and verifies the subscriber receives it. -#[tokio::test] -async fn publish_qos0_delivery() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.4-1"], + requires = ["transport.tcp"], +)] +async fn publish_qos0_delivery(sut: SutHandle) { let topic = format!("qos0/{}", unique_client_id("del")); - let collector = MessageCollector::new(); - let subscriber = connected_client("q0-sub", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "q0-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("q0-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "q0-pub") + .await + .unwrap(); publisher .publish(&topic, b"qos0-msg") .await .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "[MQTT-3.3.4-1] QoS 0 message must be delivered to subscriber" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!(msgs[0].payload, b"qos0-msg"); publisher.disconnect().await.expect("disconnect failed"); subscriber.disconnect().await.expect("disconnect failed"); @@ -569,27 +548,29 @@ async fn publish_qos0_delivery() { /// `[MQTT-3.3.4-1]` `QoS` 1 → broker sends PUBACK, subscriber receives /// message. -/// -/// Publishes a `QoS` 1 message via raw client and verifies PUBACK is received. -/// Also verifies a high-level subscriber receives the message. -#[tokio::test] -async fn publish_qos1_puback_response() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.4-1"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn publish_qos1_puback_response(sut: SutHandle) { let topic = format!("qos1/{}", unique_client_id("ack")); - let collector = MessageCollector::new(); let sub_opts = SubscribeOptions { qos: QoS::AtLeastOnce, ..Default::default() }; - let subscriber = connected_client("q1-sub", &broker).await; - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "q1-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, sub_opts) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("q1-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "q1-pub") + .await + .unwrap(); let pub_opts = PublishOptions { qos: QoS::AtLeastOnce, ..Default::default() @@ -600,10 +581,10 @@ async fn publish_qos1_puback_response() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "[MQTT-3.3.4-1] QoS 1 message must be delivered to subscriber" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!(msgs[0].payload, b"qos1-msg"); publisher.disconnect().await.expect("disconnect failed"); subscriber.disconnect().await.expect("disconnect failed"); @@ -611,27 +592,29 @@ async fn publish_qos1_puback_response() { /// `[MQTT-3.3.4-1]` `QoS` 2 → full PUBREC/PUBREL/PUBCOMP exchange and /// delivery. -/// -/// Publishes a `QoS` 2 message and verifies the subscriber receives it exactly -/// once via the full 4-step handshake. -#[tokio::test] -async fn publish_qos2_full_flow() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.4-1"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn publish_qos2_full_flow(sut: SutHandle) { let topic = format!("qos2/{}", unique_client_id("flow")); - let collector = MessageCollector::new(); let sub_opts = SubscribeOptions { qos: QoS::ExactlyOnce, ..Default::default() }; - let subscriber = connected_client("q2-sub", &broker).await; - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "q2-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, sub_opts) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("q2-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "q2-pub") + .await + .unwrap(); let pub_opts = PublishOptions { qos: QoS::ExactlyOnce, ..Default::default() @@ -642,39 +625,39 @@ async fn publish_qos2_full_flow() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, Duration::from_secs(5)).await, + subscription + .wait_for_messages(1, Duration::from_secs(5)) + .await, "[MQTT-3.3.4-1] QoS 2 message must be delivered to subscriber" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!(msgs[0].payload, b"qos2-msg"); assert_eq!(msgs.len(), 1, "QoS 2 must deliver exactly once"); publisher.disconnect().await.expect("disconnect failed"); subscriber.disconnect().await.expect("disconnect failed"); } -// --------------------------------------------------------------------------- -// Group 6: Property Forwarding -// --------------------------------------------------------------------------- - /// `[MQTT-3.3.2-4]` The Server MUST send the Payload Format Indicator /// unaltered to all subscribers. -/// -/// Publishes with `payload_format_indicator=true` and verifies the subscriber -/// receives the same property value. -#[tokio::test] -async fn publish_payload_format_indicator_forwarded() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.2-4"], + requires = ["transport.tcp"], +)] +async fn publish_payload_format_indicator_forwarded(sut: SutHandle) { let topic = format!("pfi/{}", unique_client_id("fwd")); - let collector = MessageCollector::new(); - let subscriber = connected_client("pfi-sub", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "pfi-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pfi-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pfi-pub") + .await + .unwrap(); let pub_opts = PublishOptions { properties: PublishProperties { payload_format_indicator: Some(true), @@ -688,12 +671,12 @@ async fn publish_payload_format_indicator_forwarded() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "Subscriber must receive message" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!( - msgs[0].properties.payload_format_indicator, + msgs[0].payload_format_indicator, Some(true), "[MQTT-3.3.2-4] Payload Format Indicator must be forwarded unaltered" ); @@ -703,23 +686,25 @@ async fn publish_payload_format_indicator_forwarded() { /// `[MQTT-3.3.2-20]` The Server MUST send the Content Type unaltered to all /// subscribers. -/// -/// Publishes with `content_type="application/json"` and verifies the -/// subscriber receives the same property value. -#[tokio::test] -async fn publish_content_type_forwarded() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.2-20"], + requires = ["transport.tcp"], +)] +async fn publish_content_type_forwarded(sut: SutHandle) { let topic = format!("ct/{}", unique_client_id("fwd")); - let collector = MessageCollector::new(); - let subscriber = connected_client("ct-sub", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "ct-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("ct-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "ct-pub") + .await + .unwrap(); let pub_opts = PublishOptions { properties: PublishProperties { content_type: Some("application/json".to_owned()), @@ -733,12 +718,12 @@ async fn publish_content_type_forwarded() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "Subscriber must receive message" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!( - msgs[0].properties.content_type.as_deref(), + msgs[0].content_type.as_deref(), Some("application/json"), "[MQTT-3.3.2-20] Content Type must be forwarded unaltered" ); @@ -748,23 +733,25 @@ async fn publish_content_type_forwarded() { /// `[MQTT-3.3.2-15]` `[MQTT-3.3.2-16]` The Server MUST send the Response /// Topic and Correlation Data unaltered. -/// -/// Publishes with `response_topic` and `correlation_data` properties and -/// verifies both are forwarded to the subscriber. -#[tokio::test] -async fn publish_response_topic_and_correlation_data_forwarded() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.2-15", "MQTT-3.3.2-16"], + requires = ["transport.tcp"], +)] +async fn publish_response_topic_and_correlation_data_forwarded(sut: SutHandle) { let topic = format!("rt/{}", unique_client_id("fwd")); - let collector = MessageCollector::new(); - let subscriber = connected_client("rt-sub", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "rt-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("rt-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "rt-pub") + .await + .unwrap(); let pub_opts = PublishOptions { properties: PublishProperties { response_topic: Some("reply/topic".to_owned()), @@ -779,17 +766,17 @@ async fn publish_response_topic_and_correlation_data_forwarded() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "Subscriber must receive message" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!( - msgs[0].properties.response_topic.as_deref(), + msgs[0].response_topic.as_deref(), Some("reply/topic"), "[MQTT-3.3.2-15] Response Topic must be forwarded unaltered" ); assert_eq!( - msgs[0].properties.correlation_data.as_deref(), + msgs[0].correlation_data.as_deref(), Some(&[0xDE, 0xAD, 0xBE, 0xEF][..]), "[MQTT-3.3.2-16] Correlation Data must be forwarded unaltered" ); @@ -799,20 +786,18 @@ async fn publish_response_topic_and_correlation_data_forwarded() { /// `[MQTT-3.3.2-17]` `[MQTT-3.3.2-18]` The Server MUST send all User /// Properties unaltered and maintain their order. -/// -/// Publishes with multiple user properties and verifies they are delivered -/// in the same order with the same key-value pairs. Note: the broker injects -/// `x-mqtt-sender` and `x-mqtt-client-id` properties, so we filter those out -/// before comparing. -#[tokio::test] -async fn publish_user_properties_forwarded_and_ordered() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.2-17", "MQTT-3.3.2-18"], + requires = ["transport.tcp"], +)] +async fn publish_user_properties_forwarded_and_ordered(sut: SutHandle) { let topic = format!("up/{}", unique_client_id("fwd")); - let collector = MessageCollector::new(); - let subscriber = connected_client("up-sub", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "up-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; @@ -822,7 +807,9 @@ async fn publish_user_properties_forwarded_and_ordered() { ("key-b".to_owned(), "value-2".to_owned()), ("key-a".to_owned(), "value-3".to_owned()), ]; - let publisher = connected_client("up-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "up-pub") + .await + .unwrap(); let pub_opts = PublishOptions { properties: PublishProperties { user_properties: sent_props.clone(), @@ -836,60 +823,53 @@ async fn publish_user_properties_forwarded_and_ordered() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "Subscriber must receive message" ); - let msgs = collector.get_messages(); - let received: Vec<(String, String)> = msgs[0] - .properties - .user_properties - .iter() - .filter(|(k, _)| k != "x-mqtt-sender" && k != "x-mqtt-client-id") - .cloned() - .collect(); - assert_eq!( - received, sent_props, - "[MQTT-3.3.2-17/18] User properties must be forwarded unaltered and in order" + let msgs = subscription.snapshot(); + assertions::expect_user_properties_subset( + &msgs[0].user_properties, + &sent_props, + &["x-mqtt-sender", "x-mqtt-client-id"], + "MQTT-3.3.2-17/18", ); publisher.disconnect().await.expect("disconnect failed"); subscriber.disconnect().await.expect("disconnect failed"); } -// --------------------------------------------------------------------------- -// Group 7: Topic Matching -// --------------------------------------------------------------------------- - /// `[MQTT-3.3.2-3]` The Topic Name sent to subscribing Clients MUST match the /// Subscription's Topic Filter. -/// -/// Subscribes with a wildcard filter and publishes to a matching topic. Verifies -/// the subscriber receives the message with the exact published topic name. -#[tokio::test] -async fn publish_topic_matches_subscription_filter() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.2-3"], + requires = ["transport.tcp", "wildcard_subscription_available"], +)] +async fn publish_topic_matches_subscription_filter(sut: SutHandle) { let prefix = unique_client_id("match"); let filter = format!("tm/{prefix}/+"); let publish_topic = format!("tm/{prefix}/sensor"); - let collector = MessageCollector::new(); - let subscriber = connected_client("tm-sub", &broker).await; - subscriber - .subscribe(&filter, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "tm-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&filter, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("tm-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "tm-pub") + .await + .unwrap(); publisher .publish(&publish_topic, b"match-test") .await .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "[MQTT-3.3.2-3] Wildcard subscription must match published topic" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!( msgs[0].topic, publish_topic, "[MQTT-3.3.2-3] Delivered topic name must be the published topic, not the filter" diff --git a/crates/mqtt5-conformance/tests/section3_publish_advanced.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_publish_advanced.rs similarity index 71% rename from crates/mqtt5-conformance/tests/section3_publish_advanced.rs rename to crates/mqtt5-conformance/src/conformance_tests/section3_publish_advanced.rs index 8d50fcaa..c6284346 100644 --- a/crates/mqtt5-conformance/tests/section3_publish_advanced.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_publish_advanced.rs @@ -1,22 +1,31 @@ -use mqtt5::{PublishOptions, PublishProperties, QoS, SubscribeOptions}; -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Section 3.3 — Advanced PUBLISH features (overlapping subs, `no_local`, +//! message expiry, response topic). + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{PublishOptions, PublishProperties, QoS, SubscribeOptions}; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); -#[tokio::test] -async fn overlapping_subs_max_qos_delivered() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.4-2]` Overlapping subscriptions: at least one PUBLISH copy +/// must be delivered at the maximum granted `QoS` across the matching +/// subscriptions. +#[conformance_test( + ids = ["MQTT-3.3.4-2"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn overlapping_subs_max_qos_delivered(sut: SutHandle) { let tag = unique_client_id("overlap"); let topic = format!("overlap/{tag}/data"); let filter_plus = format!("overlap/{tag}/+"); let filter_hash = format!("overlap/{tag}/#"); let sub_id = unique_client_id("sub-oq"); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); sub.connect_and_establish(&sub_id, TIMEOUT).await; @@ -37,7 +46,9 @@ async fn overlapping_subs_max_qos_delivered() { .unwrap(); sub.expect_suback(TIMEOUT).await; - let publisher = connected_client("pub-overlap-qos", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub-overlap-qos") + .await + .unwrap(); let pub_opts = PublishOptions { qos: QoS::AtLeastOnce, ..Default::default() @@ -78,16 +89,21 @@ async fn overlapping_subs_max_qos_delivered() { publisher.disconnect().await.expect("disconnect failed"); } -#[tokio::test] -async fn overlapping_subs_subscription_ids_per_copy() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.4-3]` `[MQTT-3.3.4-5]` Each PUBLISH copy delivered for +/// overlapping subscriptions must carry its matching Subscription +/// Identifier. +#[conformance_test( + ids = ["MQTT-3.3.4-3", "MQTT-3.3.4-5"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn overlapping_subs_subscription_ids_per_copy(sut: SutHandle) { let tag = unique_client_id("subid"); let topic = format!("subid/{tag}/data"); let filter_a = format!("subid/{tag}/+"); let filter_b = format!("subid/{tag}/#"); let sub_id = unique_client_id("sub-oi"); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); sub.connect_and_establish(&sub_id, TIMEOUT).await; @@ -104,7 +120,9 @@ async fn overlapping_subs_subscription_ids_per_copy() { .unwrap(); sub.expect_suback(TIMEOUT).await; - let publisher = connected_client("pub-overlap-id", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub-overlap-id") + .await + .unwrap(); let pub_opts = PublishOptions { qos: QoS::AtLeastOnce, ..Default::default() @@ -153,35 +171,41 @@ async fn overlapping_subs_subscription_ids_per_copy() { publisher.disconnect().await.expect("disconnect failed"); } -#[tokio::test] -async fn overlapping_subs_no_local_prevents_echo() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.8.3-3]` A subscription with No Local set must not receive +/// messages published by the same client. +#[conformance_test( + ids = ["MQTT-3.8.3-3"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn overlapping_subs_no_local_prevents_echo(sut: SutHandle) { let tag = unique_client_id("nolocal"); let topic = format!("nolocal/{tag}/data"); let filter = format!("nolocal/{tag}/+"); - let self_collector = MessageCollector::new(); - let client = connected_client("client-nolocal", &broker).await; + let client = TestClient::connect_with_prefix(&sut, "client-nolocal") + .await + .unwrap(); let sub_opts = SubscribeOptions { qos: QoS::AtLeastOnce, no_local: true, ..Default::default() }; - client - .subscribe_with_options(&filter, sub_opts, self_collector.callback()) + let self_subscription = client + .subscribe(&filter, sub_opts) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let other_collector = MessageCollector::new(); - let other = connected_client("other-nolocal", &broker).await; + let other = TestClient::connect_with_prefix(&sut, "other-nolocal") + .await + .unwrap(); let other_sub_opts = SubscribeOptions { qos: QoS::AtLeastOnce, ..Default::default() }; - other - .subscribe_with_options(&filter, other_sub_opts, other_collector.callback()) + let other_subscription = other + .subscribe(&filter, other_sub_opts) .await .expect("other subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; @@ -192,13 +216,13 @@ async fn overlapping_subs_no_local_prevents_echo() { .expect("publish failed"); assert!( - other_collector.wait_for_messages(1, TIMEOUT).await, + other_subscription.wait_for_messages(1, TIMEOUT).await, "other subscriber should receive the message" ); tokio::time::sleep(Duration::from_millis(300)).await; assert_eq!( - self_collector.count(), + self_subscription.count(), 0, "no_local subscriber must not receive its own publish on wildcard subscription" ); @@ -207,13 +231,19 @@ async fn overlapping_subs_no_local_prevents_echo() { other.disconnect().await.expect("disconnect failed"); } -#[tokio::test] -async fn message_expiry_drops_expired_retained() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.2-5]` A retained message that has expired must not be +/// delivered to a new subscriber. +#[conformance_test( + ids = ["MQTT-3.3.2-5"], + requires = ["transport.tcp", "max_qos>=1", "retain_available"], +)] +async fn message_expiry_drops_expired_retained(sut: SutHandle) { let tag = unique_client_id("expiry"); let topic = format!("expiry/{tag}"); - let publisher = connected_client("pub-expiry", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub-expiry") + .await + .unwrap(); let pub_opts = PublishOptions { qos: QoS::AtLeastOnce, retain: true, @@ -231,17 +261,18 @@ async fn message_expiry_drops_expired_retained() { tokio::time::sleep(Duration::from_secs(2)).await; - let collector = MessageCollector::new(); - let subscriber = connected_client("sub-expiry", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "sub-expiry") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(500)).await; assert_eq!( - collector.count(), + subscription.count(), 0, "[MQTT-3.3.2-5] expired retained message must not be delivered to new subscriber" ); @@ -249,13 +280,19 @@ async fn message_expiry_drops_expired_retained() { subscriber.disconnect().await.expect("disconnect failed"); } -#[tokio::test] -async fn message_expiry_interval_decremented() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.2-6]` Message Expiry Interval must be decremented by the +/// time the message has been held by the broker. +#[conformance_test( + ids = ["MQTT-3.3.2-6"], + requires = ["transport.tcp", "max_qos>=1", "retain_available"], +)] +async fn message_expiry_interval_decremented(sut: SutHandle) { let tag = unique_client_id("decrement"); let topic = format!("decrement/{tag}"); - let publisher = connected_client("pub-decrement", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub-decrement") + .await + .unwrap(); let pub_opts = PublishOptions { qos: QoS::AtLeastOnce, retain: true, @@ -273,21 +310,21 @@ async fn message_expiry_interval_decremented() { tokio::time::sleep(Duration::from_secs(2)).await; - let collector = MessageCollector::new(); - let subscriber = connected_client("sub-decrement", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "sub-decrement") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "retained message should be delivered" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); let expiry = msgs[0] - .properties .message_expiry_interval .expect("[MQTT-3.3.2-6] delivered retained message must include message_expiry_interval"); assert!( @@ -298,11 +335,14 @@ async fn message_expiry_interval_decremented() { subscriber.disconnect().await.expect("disconnect failed"); } -#[tokio::test] -async fn response_topic_wildcard_rejected() { - let broker = ConformanceBroker::start().await; - - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.3.2-14]` The Response Topic must not contain wildcards. +/// Sending PUBLISH with a wildcard Response Topic must cause disconnect. +#[conformance_test( + ids = ["MQTT-3.3.2-14"], + requires = ["transport.tcp"], +)] +async fn response_topic_wildcard_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("rt-wc"); @@ -322,21 +362,28 @@ async fn response_topic_wildcard_rejected() { ); } -#[tokio::test] -async fn response_topic_valid_utf8_forwarded() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.2-13]` A valid UTF-8 Response Topic must be forwarded to +/// subscribers unchanged. +#[conformance_test( + ids = ["MQTT-3.3.2-13"], + requires = ["transport.tcp"], +)] +async fn response_topic_valid_utf8_forwarded(sut: SutHandle) { let tag = unique_client_id("rt-fwd"); let topic = format!("rtfwd/{tag}"); - let collector = MessageCollector::new(); - let subscriber = connected_client("sub-rt-fwd", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) + let subscriber = TestClient::connect_with_prefix(&sut, "sub-rt-fwd") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) .await .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pub-rt-fwd", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub-rt-fwd") + .await + .unwrap(); let pub_opts = PublishOptions { qos: QoS::AtMostOnce, properties: PublishProperties { @@ -351,13 +398,13 @@ async fn response_topic_valid_utf8_forwarded() { .expect("publish failed"); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "subscriber should receive message with response topic" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!( - msgs[0].properties.response_topic.as_deref(), + msgs[0].response_topic.as_deref(), Some("reply/topic"), "[MQTT-3.3.2-13] valid UTF-8 Response Topic must be forwarded to subscriber" ); diff --git a/crates/mqtt5-conformance/tests/section3_publish_alias.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_publish_alias.rs similarity index 68% rename from crates/mqtt5-conformance/tests/section3_publish_alias.rs rename to crates/mqtt5-conformance/src/conformance_tests/section3_publish_alias.rs index 8f3c4b73..b146c23b 100644 --- a/crates/mqtt5-conformance/tests/section3_publish_alias.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_publish_alias.rs @@ -1,25 +1,24 @@ -use mqtt5_conformance::harness::{unique_client_id, ConformanceBroker}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Section 3.3 — PUBLISH Topic Alias and DUP flag. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); -// --------------------------------------------------------------------------- -// Group 1: Topic Alias Lifecycle -// --------------------------------------------------------------------------- - -/// `[MQTT-3.3.2-12]` A sender can modify the Topic Alias mapping by sending -/// another PUBLISH with the same Topic Alias value and a different topic. -/// -/// Registers alias 1 → topic A via PUBLISH with both topic+alias, then reuses -/// alias 1 via PUBLISH with empty topic+alias. Subscriber receives both -/// messages with the correct topic. -#[tokio::test] -async fn topic_alias_register_and_reuse() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.2-12]` A sender can modify the Topic Alias mapping by +/// sending another PUBLISH with the same Topic Alias value and a +/// different topic. +#[conformance_test( + ids = ["MQTT-3.3.2-12"], + requires = ["transport.tcp"], +)] +async fn topic_alias_register_and_reuse(sut: SutHandle) { let topic = format!("alias/{}", unique_client_id("reg")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("asub"); @@ -29,7 +28,7 @@ async fn topic_alias_register_and_reuse() { .unwrap(); sub.expect_suback(TIMEOUT).await; - let mut pub_client = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_client = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("apub"); @@ -69,21 +68,20 @@ async fn topic_alias_register_and_reuse() { assert_eq!(recv_payload2, b"second"); } -/// `[MQTT-3.3.2-12]` A sender can modify the Topic Alias mapping by sending -/// another PUBLISH with the same Topic Alias value and a different non-zero -/// length Topic Name. -/// -/// Registers alias 5 = topic A, remaps alias 5 = topic B, then reuses alias 5. -/// The reused alias resolves to topic B. -#[tokio::test] -async fn topic_alias_update_mapping() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.2-12]` A sender can modify the Topic Alias mapping by +/// sending another PUBLISH with the same Topic Alias value and a +/// different non-zero length Topic Name. +#[conformance_test( + ids = ["MQTT-3.3.2-12"], + requires = ["transport.tcp"], +)] +async fn topic_alias_update_mapping(sut: SutHandle) { let prefix = unique_client_id("remap"); let topic_a = format!("alias/{prefix}/a"); let topic_b = format!("alias/{prefix}/b"); let filter = format!("alias/{prefix}/+"); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("rsub"); @@ -93,7 +91,7 @@ async fn topic_alias_update_mapping() { .unwrap(); sub.expect_suback(TIMEOUT).await; - let mut pub_client = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_client = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("rpub"); @@ -133,18 +131,16 @@ async fn topic_alias_update_mapping() { ); } -/// `[MQTT-3.3.2-10]` `[MQTT-3.3.2-11]` Topic Alias mappings are scoped to the -/// Network Connection. A new connection starts with no mappings. -/// -/// Registers alias 1 on connection A. Opens connection B and tries an -/// alias-only PUBLISH with alias 1 — broker disconnects because alias 1 is -/// not registered on connection B. -#[tokio::test] -async fn topic_alias_not_shared_across_connections() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.2-10]` `[MQTT-3.3.2-11]` Topic Alias mappings are scoped to +/// the Network Connection. A new connection starts with no mappings. +#[conformance_test( + ids = ["MQTT-3.3.2-10", "MQTT-3.3.2-11"], + requires = ["transport.tcp"], +)] +async fn topic_alias_not_shared_across_connections(sut: SutHandle) { let topic = format!("alias/{}", unique_client_id("scope")); - let mut conn_a = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut conn_a = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let id_a = unique_client_id("sca"); @@ -157,7 +153,7 @@ async fn topic_alias_not_shared_across_connections() { .unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; - let mut conn_b = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut conn_b = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let id_b = unique_client_id("scb"); @@ -175,17 +171,15 @@ async fn topic_alias_not_shared_across_connections() { /// `[MQTT-3.3.2-10]` `[MQTT-3.3.2-11]` Topic Alias mappings do not survive /// reconnection. -/// -/// Registers alias 3 on a connection, disconnects normally, reconnects with -/// the same client ID, then tries alias-only PUBLISH with alias 3 — broker -/// disconnects because alias mappings were cleared. -#[tokio::test] -async fn topic_alias_cleared_on_reconnect() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-3.3.2-10", "MQTT-3.3.2-11"], + requires = ["transport.tcp"], +)] +async fn topic_alias_cleared_on_reconnect(sut: SutHandle) { let topic = format!("alias/{}", unique_client_id("recon")); let client_id = unique_client_id("rcid"); - let mut conn1 = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut conn1 = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); conn1.connect_and_establish(&client_id, TIMEOUT).await; @@ -202,7 +196,7 @@ async fn topic_alias_cleared_on_reconnect() { .unwrap(); tokio::time::sleep(Duration::from_millis(200)).await; - let mut conn2 = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut conn2 = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); conn2.connect_and_establish(&client_id, TIMEOUT).await; @@ -217,16 +211,15 @@ async fn topic_alias_cleared_on_reconnect() { ); } -/// Topic Alias is stripped before delivery to subscribers. -/// -/// Publishes with a Topic Alias. The subscriber must receive the full topic -/// name (not an empty string). -#[tokio::test] -async fn topic_alias_stripped_before_delivery() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.2-8]` Topic Alias is stripped before delivery to subscribers. +#[conformance_test( + ids = ["MQTT-3.3.2-8"], + requires = ["transport.tcp"], +)] +async fn topic_alias_stripped_before_delivery(sut: SutHandle) { let topic = format!("alias/{}", unique_client_id("strip")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("ssub"); @@ -236,7 +229,7 @@ async fn topic_alias_stripped_before_delivery() { .unwrap(); sub.expect_suback(TIMEOUT).await; - let mut pub_client = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_client = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("spub"); @@ -261,22 +254,17 @@ async fn topic_alias_stripped_before_delivery() { ); } -// --------------------------------------------------------------------------- -// Group 2: DUP Flag -// --------------------------------------------------------------------------- - -/// `[MQTT-3.3.1-3]` The value of the DUP flag from an incoming PUBLISH packet -/// is not propagated when the PUBLISH packet is sent to subscribers. -/// -/// Raw publisher sends a `QoS` 1 PUBLISH with DUP=1 (header byte `0x3A`). Raw -/// subscriber receives the forwarded PUBLISH. The DUP bit (bit 3 of the first -/// byte) MUST be 0 in the forwarded copy. -#[tokio::test] -async fn dup_flag_not_propagated_to_subscribers() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-3.3.1-3]` The value of the DUP flag from an incoming PUBLISH +/// packet is not propagated when the PUBLISH packet is sent to +/// subscribers. +#[conformance_test( + ids = ["MQTT-3.3.1-3"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn dup_flag_not_propagated_to_subscribers(sut: SutHandle) { let topic = format!("dup/{}", unique_client_id("prop")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("dsub"); @@ -286,7 +274,7 @@ async fn dup_flag_not_propagated_to_subscribers() { .unwrap(); sub.expect_suback(TIMEOUT).await; - let mut pub_client = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_client = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("dpub"); diff --git a/crates/mqtt5-conformance/src/conformance_tests/section3_publish_flow.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_publish_flow.rs new file mode 100644 index 00000000..f5c9f3f6 --- /dev/null +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_publish_flow.rs @@ -0,0 +1,129 @@ +//! Section 3.3 — PUBLISH flow control (receive maximum). + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use std::time::Duration; + +/// `[MQTT-3.3.4-7]` The Server MUST NOT send more than Receive Maximum +/// `QoS` 1 and `QoS` 2 PUBLISH packets for which it has not received PUBACK, +/// PUBCOMP, or PUBREC with a Reason Code of 128 or greater from the Client. +#[conformance_test( + ids = ["MQTT-3.3.4-7"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn receive_maximum_limits_outbound_publishes(sut: SutHandle) { + let topic = format!("recv-max/{}", unique_client_id("flow")); + + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let sub_id = unique_client_id("sub-rm"); + sub.send_raw(&RawPacketBuilder::connect_with_receive_maximum(&sub_id, 2)) + .await + .unwrap(); + let connack = sub.expect_connack(Duration::from_secs(2)).await; + assert!(connack.is_some(), "Subscriber must receive CONNACK"); + let (_, reason) = connack.unwrap(); + assert_eq!(reason, 0x00, "Subscriber CONNACK must be Success"); + + sub.send_raw(&RawPacketBuilder::subscribe(&topic, 1)) + .await + .unwrap(); + let suback = sub.expect_suback(Duration::from_secs(2)).await; + assert!(suback.is_some(), "Must receive SUBACK"); + + let mut pub_raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let pub_id = unique_client_id("pub-rm"); + pub_raw + .send_raw(&RawPacketBuilder::valid_connect(&pub_id)) + .await + .unwrap(); + let pub_connack = pub_raw.expect_connack(Duration::from_secs(2)).await; + assert!(pub_connack.is_some(), "Publisher must receive CONNACK"); + + for i in 1..=4u16 { + pub_raw + .send_raw(&RawPacketBuilder::publish_qos1( + &topic, + &[u8::try_from(i).unwrap()], + i, + )) + .await + .unwrap(); + } + tokio::time::sleep(Duration::from_millis(500)).await; + + let publish_count = count_publish_packets_from_raw(&mut sub, Duration::from_secs(2)).await; + + assert_eq!( + publish_count, 2, + "[MQTT-3.3.4-7] Server must not send more than receive_maximum (2) unACKed QoS 1 publishes" + ); +} + +async fn count_publish_packets_from_raw(client: &mut RawMqttClient, timeout: Duration) -> u32 { + let mut accumulated = Vec::new(); + let deadline = tokio::time::Instant::now() + timeout; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match client + .read_packet_bytes(remaining.min(Duration::from_millis(500))) + .await + { + Some(data) => accumulated.extend_from_slice(&data), + None => break, + } + } + + count_publish_headers(&accumulated) +} + +fn count_publish_headers(data: &[u8]) -> u32 { + let mut count = 0; + let mut idx = 0; + while idx < data.len() { + let first_byte = data[idx]; + let packet_type = first_byte >> 4; + idx += 1; + + let mut remaining_len: u32 = 0; + let mut shift = 0; + loop { + if idx >= data.len() { + return count; + } + let byte = data[idx]; + idx += 1; + remaining_len |= u32::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + break; + } + shift += 7; + if shift > 21 { + return count; + } + } + + let body_end = idx + remaining_len as usize; + if body_end > data.len() { + if packet_type == 3 { + count += 1; + } + return count; + } + + if packet_type == 3 { + count += 1; + } + idx = body_end; + } + count +} diff --git a/crates/mqtt5-conformance/tests/section3_qos_ack.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_qos_ack.rs similarity index 51% rename from crates/mqtt5-conformance/tests/section3_qos_ack.rs rename to crates/mqtt5-conformance/src/conformance_tests/section3_qos_ack.rs index 40737841..e370ac10 100644 --- a/crates/mqtt5-conformance/tests/section3_qos_ack.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_qos_ack.rs @@ -1,22 +1,24 @@ -use mqtt5::{QoS, SubscribeOptions}; -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Sections 3.4–3.7 — PUBACK / PUBREC / PUBREL / PUBCOMP. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{PublishOptions, QoS, SubscribeOptions}; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); -// --------------------------------------------------------------------------- -// Group 1: PUBACK — Section 3.4 -// --------------------------------------------------------------------------- - -/// Server MUST send PUBACK in response to a `QoS` 1 PUBLISH, containing -/// the matching Packet Identifier and a valid reason code. -#[tokio::test] -async fn puback_correct_packet_id_and_reason() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.4.0-1]` `[MQTT-3.4.2-1]` Server MUST send PUBACK in response to +/// a `QoS` 1 PUBLISH, containing the matching Packet Identifier and a valid +/// reason code. +#[conformance_test( + ids = ["MQTT-3.4.0-1", "MQTT-3.4.2-1"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn puback_correct_packet_id_and_reason(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("puback-pid"); @@ -43,46 +45,50 @@ async fn puback_correct_packet_id_and_reason() { assert_eq!(reason, 0x00, "PUBACK reason code should be Success (0x00)"); } -/// `QoS` 1 PUBLISH results in message delivery to subscriber. -#[tokio::test] -async fn puback_message_delivered_on_qos1() { - let broker = ConformanceBroker::start().await; - let subscriber = connected_client("puback-sub", &broker).await; - let collector = MessageCollector::new(); - let opts = SubscribeOptions { - qos: QoS::AtLeastOnce, - ..Default::default() - }; - subscriber - .subscribe_with_options("test/qos1-deliver", opts, collector.callback()) +/// `[MQTT-3.4.0-1]` `QoS` 1 PUBLISH results in message delivery to subscriber. +#[conformance_test( + ids = ["MQTT-3.4.0-1"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn puback_message_delivered_on_qos1(sut: SutHandle) { + let subscriber = TestClient::connect_with_prefix(&sut, "puback-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe( + "test/qos1-deliver", + SubscribeOptions { + qos: QoS::AtLeastOnce, + ..SubscribeOptions::default() + }, + ) .await .unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("puback-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "puback-pub") + .await + .unwrap(); publisher - .publish("test/qos1-deliver", b"qos1-payload".to_vec()) + .publish("test/qos1-deliver", b"qos1-payload") .await .unwrap(); - assert!( - collector.wait_for_messages(1, TIMEOUT).await, - "subscriber should receive QoS 1 message" - ); - let msgs = collector.get_messages(); - assert_eq!(msgs[0].payload, b"qos1-payload"); + let msg = subscription + .expect_publish(TIMEOUT) + .await + .expect("subscriber should receive QoS 1 message"); + assert_eq!(msg.payload, b"qos1-payload"); } -// --------------------------------------------------------------------------- -// Group 2: PUBREC — Section 3.5 -// --------------------------------------------------------------------------- - -/// Server MUST send PUBREC in response to a `QoS` 2 PUBLISH, containing -/// the matching Packet Identifier and a valid reason code. -#[tokio::test] -async fn pubrec_correct_packet_id_and_reason() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.5.0-1]` `[MQTT-3.5.2-1]` Server MUST send PUBREC in response to +/// a `QoS` 2 PUBLISH, containing the matching Packet Identifier and a valid +/// reason code. +#[conformance_test( + ids = ["MQTT-3.5.0-1", "MQTT-3.5.2-1"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn pubrec_correct_packet_id_and_reason(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("pubrec-pid"); @@ -109,27 +115,28 @@ async fn pubrec_correct_packet_id_and_reason() { assert_eq!(reason, 0x00, "PUBREC reason code should be Success (0x00)"); } -/// `QoS` 2 message MUST NOT be delivered to subscribers until PUBREL is received. -/// -/// Sends `QoS` 2 PUBLISH → gets PUBREC, but does NOT send PUBREL. -/// Verifies subscriber does not receive the message. -#[tokio::test] -async fn pubrec_no_delivery_before_pubrel() { - let broker = ConformanceBroker::start().await; - - let subscriber = connected_client("pubrec-nosub", &broker).await; - let collector = MessageCollector::new(); - let opts = SubscribeOptions { - qos: QoS::ExactlyOnce, - ..Default::default() - }; - subscriber - .subscribe_with_options("test/nodelay", opts, collector.callback()) +/// `[MQTT-3.7.4-1]` `QoS` 2 message MUST NOT be delivered to subscribers +/// until PUBREL is received. +#[conformance_test( + ids = ["MQTT-3.7.4-1"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn pubrec_no_delivery_before_pubrel(sut: SutHandle) { + let subscriber = TestClient::connect_with_prefix(&sut, "pubrec-nosub") + .await + .unwrap(); + let subscription = subscriber + .subscribe( + "test/nodelay", + SubscribeOptions { + qos: QoS::ExactlyOnce, + ..SubscribeOptions::default() + }, + ) .await .unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("pubrec-raw"); @@ -150,22 +157,20 @@ async fn pubrec_no_delivery_before_pubrel() { tokio::time::sleep(Duration::from_millis(500)).await; assert_eq!( - collector.count(), + subscription.count(), 0, "message must NOT be delivered before PUBREL" ); } -// --------------------------------------------------------------------------- -// Group 3: PUBREL — Section 3.6 -// --------------------------------------------------------------------------- - -/// `[MQTT-3.6.1-1]` PUBREL fixed header flags MUST be `0x02`. The Server MUST -/// treat any other value as malformed and close the Network Connection. -#[tokio::test] -async fn pubrel_invalid_flags_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.6.1-1]` PUBREL fixed header flags MUST be `0x02`. The Server +/// MUST treat any other value as malformed and close the Network Connection. +#[conformance_test( + ids = ["MQTT-3.6.1-1"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn pubrel_invalid_flags_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("pubrel-flags"); @@ -191,12 +196,14 @@ async fn pubrel_invalid_flags_rejected() { ); } -/// PUBREL for an unknown Packet Identifier MUST result in PUBCOMP with -/// reason code `PacketIdentifierNotFound` (0x92). -#[tokio::test] -async fn pubrel_unknown_packet_id() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.6.2-1]` `[MQTT-3.7.2-1]` PUBREL for an unknown Packet Identifier +/// MUST result in PUBCOMP with reason code `PacketIdentifierNotFound` (0x92). +#[conformance_test( + ids = ["MQTT-3.6.2-1", "MQTT-3.7.2-1"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn pubrel_unknown_packet_id(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("pubrel-unk"); @@ -216,16 +223,15 @@ async fn pubrel_unknown_packet_id() { ); } -// --------------------------------------------------------------------------- -// Group 4: PUBCOMP — Section 3.7 -// --------------------------------------------------------------------------- - -/// Full inbound `QoS` 2 flow: PUBLISH → PUBREC → PUBREL → PUBCOMP. -/// Verifies PUBCOMP has matching packet ID and reason=Success. -#[tokio::test] -async fn pubcomp_correct_packet_id_and_reason() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.6.4-1]` `[MQTT-3.7.2-1]` Full inbound `QoS` 2 flow: PUBLISH → +/// PUBREC → PUBREL → PUBCOMP. Verifies PUBCOMP has matching packet ID and +/// reason=Success. +#[conformance_test( + ids = ["MQTT-3.6.4-1", "MQTT-3.7.2-1"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn pubcomp_correct_packet_id_and_reason(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("pubcomp-pid"); @@ -259,24 +265,28 @@ async fn pubcomp_correct_packet_id_and_reason() { assert_eq!(reason, 0x00, "PUBCOMP reason code should be Success (0x00)"); } -/// Full `QoS` 2 exchange delivers the message to a subscriber. -#[tokio::test] -async fn pubcomp_message_delivered_after_exchange() { - let broker = ConformanceBroker::start().await; - - let subscriber = connected_client("pubcomp-sub", &broker).await; - let collector = MessageCollector::new(); - let opts = SubscribeOptions { - qos: QoS::ExactlyOnce, - ..Default::default() - }; - subscriber - .subscribe_with_options("test/qos2-deliver", opts, collector.callback()) +/// `[MQTT-3.7.4-1]` Full `QoS` 2 exchange delivers the message to a +/// subscriber. +#[conformance_test( + ids = ["MQTT-3.7.4-1"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn pubcomp_message_delivered_after_exchange(sut: SutHandle) { + let subscriber = TestClient::connect_with_prefix(&sut, "pubcomp-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe( + "test/qos2-deliver", + SubscribeOptions { + qos: QoS::ExactlyOnce, + ..SubscribeOptions::default() + }, + ) .await .unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("pubcomp-raw"); @@ -296,25 +306,22 @@ async fn pubcomp_message_delivered_after_exchange() { let _ = raw.expect_pubcomp(TIMEOUT).await.expect("expected PUBCOMP"); - assert!( - collector.wait_for_messages(1, TIMEOUT).await, - "subscriber should receive message after full QoS 2 exchange" - ); - let msgs = collector.get_messages(); - assert_eq!(msgs[0].payload, b"qos2-payload"); + let msg = subscription + .expect_publish(TIMEOUT) + .await + .expect("subscriber should receive message after full QoS 2 exchange"); + assert_eq!(msg.payload, b"qos2-payload"); } -// --------------------------------------------------------------------------- -// Group 5: Outbound Server PUBREL -// --------------------------------------------------------------------------- - -/// When the server delivers a `QoS` 2 message to a subscriber, the PUBREL -/// it sends MUST have fixed header flags = `0x02` (byte `0x62`). -#[tokio::test] -async fn server_pubrel_correct_flags() { - let broker = ConformanceBroker::start().await; - - let mut raw_sub = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.6.1-1]` `[MQTT-3.6.2-1]` When the server delivers a `QoS` 2 +/// message to a subscriber, the PUBREL it sends MUST have fixed header +/// flags = `0x02` (byte `0x62`). +#[conformance_test( + ids = ["MQTT-3.6.1-1", "MQTT-3.6.2-1"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn server_pubrel_correct_flags(sut: SutHandle) { + let mut raw_sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("srv-pubrel-sub"); @@ -326,13 +333,15 @@ async fn server_pubrel_correct_flags() { let _ = raw_sub.read_packet_bytes(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("srv-pubrel-pub", &broker).await; - let pub_opts = mqtt5::PublishOptions { + let publisher = TestClient::connect_with_prefix(&sut, "srv-pubrel-pub") + .await + .unwrap(); + let pub_opts = PublishOptions { qos: QoS::ExactlyOnce, ..Default::default() }; publisher - .publish_with_options("test/srv-pubrel", b"outbound-qos2".to_vec(), pub_opts) + .publish_with_options("test/srv-pubrel", b"outbound-qos2", pub_opts) .await .unwrap(); diff --git a/crates/mqtt5-conformance/src/conformance_tests/section3_subscribe.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_subscribe.rs new file mode 100644 index 00000000..ca83c28f --- /dev/null +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_subscribe.rs @@ -0,0 +1,425 @@ +//! Sections 3.8–3.9 — SUBSCRIBE and SUBACK. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{PublishOptions, QoS, RetainHandling, SubscribeOptions}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(3); + +/// `[MQTT-3.8.1-1]` SUBSCRIBE fixed header flags MUST be `0x02`. +/// A raw SUBSCRIBE with flags `0x00` (byte `0x80`) must cause disconnect. +#[conformance_test( + ids = ["MQTT-3.8.1-1"], + requires = ["transport.tcp"], +)] +async fn subscribe_invalid_flags_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("sub-flags"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_invalid_flags("test/topic", 0)) + .await + .unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-3.8.1-1] server must disconnect on SUBSCRIBE with invalid flags" + ); +} + +/// `[MQTT-3.8.3-3]` SUBSCRIBE payload MUST contain at least one topic filter. +#[conformance_test( + ids = ["MQTT-3.8.3-3"], + requires = ["transport.tcp"], +)] +async fn subscribe_empty_payload_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("sub-empty"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_empty_payload(1)) + .await + .unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-3.8.3-3] server must disconnect on SUBSCRIBE with no topic filters" + ); +} + +/// `[MQTT-3.8.3-4]` `NoLocal=1` on a shared subscription is a Protocol Error. +#[conformance_test( + ids = ["MQTT-3.8.3-4"], + requires = ["transport.tcp", "shared_subscription_available"], +)] +async fn subscribe_no_local_shared_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("sub-nolocal-share"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_shared_no_local( + "workers", "tasks/+", 1, 1, + )) + .await + .unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-3.8.3-4] server must disconnect on NoLocal with shared subscription" + ); +} + +/// `[MQTT-3.9.2-1]` SUBACK packet ID must match SUBSCRIBE packet ID. +#[conformance_test( + ids = ["MQTT-3.9.2-1"], + requires = ["transport.tcp"], +)] +async fn suback_packet_id_matches(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("suback-pid"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + let packet_id: u16 = 42; + raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( + "test/suback-pid", + 0, + packet_id, + )) + .await + .unwrap(); + + let (ack_id, reason_codes) = raw + .expect_suback(TIMEOUT) + .await + .expect("expected SUBACK from broker"); + + assert_eq!( + ack_id, packet_id, + "[MQTT-3.9.2-1] SUBACK packet ID must match SUBSCRIBE packet ID" + ); + assert_eq!(reason_codes.len(), 1, "SUBACK must contain one reason code"); +} + +/// `[MQTT-3.9.3-1]` SUBACK must contain one reason code per topic filter. +#[conformance_test( + ids = ["MQTT-3.9.3-1"], + requires = ["transport.tcp"], +)] +async fn suback_reason_codes_per_filter(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("suback-multi"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + let filters = [("test/a", 0u8), ("test/b", 1), ("test/c", 2)]; + raw.send_raw(&RawPacketBuilder::subscribe_multiple(&filters, 10)) + .await + .unwrap(); + + let (ack_id, reason_codes) = raw + .expect_suback(TIMEOUT) + .await + .expect("expected SUBACK from broker"); + + assert_eq!(ack_id, 10); + assert_eq!( + reason_codes.len(), + 3, + "[MQTT-3.9.3-1] SUBACK must contain one reason code per topic filter" + ); +} + +/// `[MQTT-3.9.3-3]` SUBACK grants the exact `QoS` requested on a default +/// broker (max `QoS` 2). +#[conformance_test( + ids = ["MQTT-3.9.3-3"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn suback_grants_requested_qos(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("suback-qos"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + for qos in 0..=2u8 { + let topic = format!("test/qos{qos}"); + let packet_id = u16::from(qos) + 1; + raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( + &topic, qos, packet_id, + )) + .await + .unwrap(); + + let (ack_id, reason_codes) = raw + .expect_suback(TIMEOUT) + .await + .expect("expected SUBACK from broker"); + + assert_eq!(ack_id, packet_id); + assert_eq!( + reason_codes[0], qos, + "SUBACK should grant QoS {qos}, got 0x{:02X}", + reason_codes[0] + ); + } +} + +/// `[MQTT-3.8.4-3]` Subscribing twice to the same topic with different `QoS` +/// replaces the existing subscription. Only one copy of a published message +/// is delivered. +#[conformance_test( + ids = ["MQTT-3.8.4-3"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn subscribe_replaces_existing(sut: SutHandle) { + let mut raw_sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let sub_id = unique_client_id("sub-replace"); + raw_sub.connect_and_establish(&sub_id, TIMEOUT).await; + + raw_sub + .send_raw(&RawPacketBuilder::subscribe_with_packet_id( + "test/replace", + 0, + 1, + )) + .await + .unwrap(); + let (_, rc1) = raw_sub.expect_suback(TIMEOUT).await.expect("SUBACK 1"); + assert_eq!(rc1[0], 0x00, "first subscribe should grant QoS 0"); + + raw_sub + .send_raw(&RawPacketBuilder::subscribe_with_packet_id( + "test/replace", + 1, + 2, + )) + .await + .unwrap(); + let (_, rc2) = raw_sub.expect_suback(TIMEOUT).await.expect("SUBACK 2"); + assert_eq!(rc2[0], 0x01, "second subscribe should grant QoS 1"); + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "sub-replace-pub") + .await + .unwrap(); + publisher.publish("test/replace", b"once").await.unwrap(); + + let first = raw_sub.expect_publish(TIMEOUT).await; + assert!(first.is_some(), "subscriber should receive the message"); + + let duplicate = raw_sub.expect_publish(Duration::from_millis(500)).await; + assert!( + duplicate.is_none(), + "[MQTT-3.8.4-3] subscriber should receive only one copy (replacement, not duplicate subscription)" + ); +} + +/// `[MQTT-3.8.3-5]` Reserved bits in the subscribe options byte MUST be zero. +#[conformance_test( + ids = ["MQTT-3.8.3-5"], + requires = ["transport.tcp"], +)] +async fn subscribe_reserved_option_bits_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("sub-reserved-bits"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_with_options( + "test/reserved-bits", + 0xC0, + 1, + )) + .await + .unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-3.8.3-5] server must disconnect on SUBSCRIBE with reserved option bits set" + ); +} + +/// `[MQTT-3.8.3-1]` Topic filter in SUBSCRIBE must be valid UTF-8. +#[conformance_test( + ids = ["MQTT-3.8.3-1"], + requires = ["transport.tcp"], +)] +async fn subscribe_invalid_utf8_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("sub-bad-utf8"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_invalid_utf8(1)) + .await + .unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-3.8.3-1] server must disconnect on SUBSCRIBE with invalid UTF-8 topic filter" + ); +} + +/// `[MQTT-3.8.4-4]` When Retain Handling is 0 (`SendAtSubscribe`), retained +/// messages are sent on every subscribe, including re-subscribes. +#[conformance_test( + ids = ["MQTT-3.8.4-4"], + requires = ["transport.tcp", "retain_available"], +)] +async fn retain_handling_zero_sends_on_resubscribe(sut: SutHandle) { + let publisher = TestClient::connect_with_prefix(&sut, "rh0-pub") + .await + .unwrap(); + let pub_opts = PublishOptions { + qos: QoS::AtMostOnce, + retain: true, + ..Default::default() + }; + publisher + .publish_with_options("test/rh0/topic", b"retained-payload", pub_opts) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + + let subscriber = TestClient::connect_with_prefix(&sut, "rh0-sub") + .await + .unwrap(); + let sub_opts = SubscribeOptions { + qos: QoS::AtMostOnce, + retain_handling: RetainHandling::SendAtSubscribe, + ..Default::default() + }; + let subscription = subscriber + .subscribe("test/rh0/topic", sub_opts.clone()) + .await + .unwrap(); + + assert!( + subscription.wait_for_messages(1, TIMEOUT).await, + "first subscribe with RetainHandling=0 must deliver retained message" + ); + let msgs = subscription.snapshot(); + assert_eq!(msgs[0].payload, b"retained-payload"); + + let subscription2 = subscriber + .subscribe("test/rh0/topic", sub_opts) + .await + .unwrap(); + + assert!( + subscription2.wait_for_messages(1, TIMEOUT).await, + "[MQTT-3.8.4-4] re-subscribe with RetainHandling=0 must deliver retained message again" + ); + let msgs2 = subscription2.snapshot(); + assert_eq!(msgs2[0].payload, b"retained-payload"); +} + +/// `[MQTT-3.8.4-8]` The delivered `QoS` is the minimum of the published `QoS` +/// and the subscription's granted `QoS`. Subscribe at `QoS` 0, publish at +/// `QoS` 1 → delivered at `QoS` 0. +#[conformance_test( + ids = ["MQTT-3.8.4-8"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn delivered_qos_is_minimum_sub0_pub1(sut: SutHandle) { + let tag = unique_client_id("minqos01"); + let topic = format!("minqos/{tag}"); + + let mut raw_sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let sub_id = unique_client_id("sub-mq01"); + raw_sub.connect_and_establish(&sub_id, TIMEOUT).await; + + raw_sub + .send_raw(&RawPacketBuilder::subscribe_with_packet_id(&topic, 0, 1)) + .await + .unwrap(); + let (_, rc) = raw_sub.expect_suback(TIMEOUT).await.expect("SUBACK"); + assert_eq!(rc[0], 0x00, "granted QoS should be 0"); + tokio::time::sleep(Duration::from_millis(100)).await; + + let mut raw_publisher = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let pub_id = unique_client_id("pub-mq01"); + raw_publisher.connect_and_establish(&pub_id, TIMEOUT).await; + + raw_publisher + .send_raw(&RawPacketBuilder::publish_qos1(&topic, b"hello", 1)) + .await + .unwrap(); + + let msg = raw_sub.expect_publish(TIMEOUT).await; + assert!(msg.is_some(), "subscriber should receive the message"); + let (qos, _, payload) = msg.unwrap(); + assert_eq!(payload, b"hello"); + assert_eq!( + qos, 0, + "[MQTT-3.8.4-8] delivered QoS must be min(pub=1, sub=0) = 0" + ); +} + +/// `[MQTT-3.8.4-8]` Subscribe at `QoS` 1, publish at `QoS` 2 → delivered at +/// `QoS` 1. +#[conformance_test( + ids = ["MQTT-3.8.4-8"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn delivered_qos_is_minimum_sub1_pub2(sut: SutHandle) { + let tag = unique_client_id("minqos12"); + let topic = format!("minqos/{tag}"); + + let mut raw_sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let sub_id = unique_client_id("sub-mq12"); + raw_sub.connect_and_establish(&sub_id, TIMEOUT).await; + + raw_sub + .send_raw(&RawPacketBuilder::subscribe_with_packet_id(&topic, 1, 1)) + .await + .unwrap(); + let (_, rc) = raw_sub.expect_suback(TIMEOUT).await.expect("SUBACK"); + assert_eq!(rc[0], 0x01, "granted QoS should be 1"); + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "pub-mq12") + .await + .unwrap(); + let pub_opts = PublishOptions { + qos: QoS::ExactlyOnce, + ..Default::default() + }; + publisher + .publish_with_options(&topic, b"world", pub_opts) + .await + .unwrap(); + + let msg = raw_sub.expect_publish(TIMEOUT).await; + assert!(msg.is_some(), "subscriber should receive the message"); + let (qos, _, payload) = msg.unwrap(); + assert_eq!(payload, b"world"); + assert_eq!( + qos, 1, + "[MQTT-3.8.4-8] delivered QoS must be min(pub=2, sub=1) = 1" + ); +} diff --git a/crates/mqtt5-conformance/tests/section3_unsubscribe.rs b/crates/mqtt5-conformance/src/conformance_tests/section3_unsubscribe.rs similarity index 68% rename from crates/mqtt5-conformance/tests/section3_unsubscribe.rs rename to crates/mqtt5-conformance/src/conformance_tests/section3_unsubscribe.rs index b5d712ec..208afc1a 100644 --- a/crates/mqtt5-conformance/tests/section3_unsubscribe.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section3_unsubscribe.rs @@ -1,22 +1,23 @@ -use mqtt5::{QoS, SubscribeOptions}; -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Sections 3.10–3.11 — UNSUBSCRIBE and UNSUBACK. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{QoS, SubscribeOptions}; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); -// --------------------------------------------------------------------------- -// Group 1: UNSUBSCRIBE Structure — Section 3.10 -// --------------------------------------------------------------------------- - /// `[MQTT-3.10.1-1]` UNSUBSCRIBE fixed header flags MUST be `0x02`. /// A raw UNSUBSCRIBE with flags `0x00` (byte `0xA0`) must cause disconnect. -#[tokio::test] -async fn unsubscribe_invalid_flags_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.10.1-1"], + requires = ["transport.tcp"], +)] +async fn unsubscribe_invalid_flags_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsub-flags"); @@ -35,12 +36,14 @@ async fn unsubscribe_invalid_flags_rejected() { ); } -/// `[MQTT-3.10.3-2]` UNSUBSCRIBE payload MUST contain at least one topic filter. -/// An empty-payload UNSUBSCRIBE must cause disconnect. -#[tokio::test] -async fn unsubscribe_empty_payload_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.10.3-2]` UNSUBSCRIBE payload MUST contain at least one topic +/// filter. +#[conformance_test( + ids = ["MQTT-3.10.3-2"], + requires = ["transport.tcp"], +)] +async fn unsubscribe_empty_payload_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsub-empty"); @@ -56,15 +59,13 @@ async fn unsubscribe_empty_payload_rejected() { ); } -// --------------------------------------------------------------------------- -// Group 2: UNSUBACK Response — Section 3.11 -// --------------------------------------------------------------------------- - /// `[MQTT-3.11.2-1]` UNSUBACK packet ID must match UNSUBSCRIBE packet ID. -#[tokio::test] -async fn unsuback_packet_id_matches() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.11.2-1"], + requires = ["transport.tcp"], +)] +async fn unsuback_packet_id_matches(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsuback-pid"); @@ -104,10 +105,12 @@ async fn unsuback_packet_id_matches() { } /// `[MQTT-3.11.3-1]` UNSUBACK must contain one reason code per topic filter. -#[tokio::test] -async fn unsuback_reason_codes_per_filter() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.11.3-1"], + requires = ["transport.tcp"], +)] +async fn unsuback_reason_codes_per_filter(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsuback-multi"); @@ -131,11 +134,14 @@ async fn unsuback_reason_codes_per_filter() { ); } -/// Subscribe then unsubscribe — reason code should be Success (0x00). -#[tokio::test] -async fn unsuback_success_for_existing() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.11.3-2]` Subscribe then unsubscribe — reason code should be +/// Success (0x00). +#[conformance_test( + ids = ["MQTT-3.11.3-2"], + requires = ["transport.tcp"], +)] +async fn unsuback_success_for_existing(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsuback-ok"); @@ -166,12 +172,14 @@ async fn unsuback_success_for_existing() { ); } -/// Unsubscribe from a topic never subscribed — reason code should be -/// `NoSubscriptionExisted` (0x11). -#[tokio::test] -async fn unsuback_no_subscription_existed() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.11.3-2]` Unsubscribe from a topic never subscribed — reason code +/// should be `NoSubscriptionExisted` (0x11). +#[conformance_test( + ids = ["MQTT-3.11.3-2"], + requires = ["transport.tcp"], +)] +async fn unsuback_no_subscription_existed(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsuback-noexist"); @@ -193,35 +201,33 @@ async fn unsuback_no_subscription_existed() { ); } -// --------------------------------------------------------------------------- -// Group 3: Subscription Removal Verification -// --------------------------------------------------------------------------- - /// `[MQTT-3.10.4-1]` After unsubscribing, the broker must stop sending /// messages for that topic filter. -#[tokio::test] -async fn unsubscribe_stops_delivery() { - let broker = ConformanceBroker::start().await; - let subscriber = connected_client("unsub-stop", &broker).await; - let collector = MessageCollector::new(); +#[conformance_test( + ids = ["MQTT-3.10.4-1"], + requires = ["transport.tcp"], +)] +async fn unsubscribe_stops_delivery(sut: SutHandle) { + let subscriber = TestClient::connect_with_prefix(&sut, "unsub-stop") + .await + .unwrap(); let opts = SubscribeOptions { qos: QoS::AtMostOnce, ..Default::default() }; - subscriber - .subscribe_with_options("test/unsub-stop", opts, collector.callback()) - .await - .unwrap(); + let subscription = subscriber.subscribe("test/unsub-stop", opts).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("unsub-stop-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "unsub-stop-pub") + .await + .unwrap(); publisher - .publish("test/unsub-stop", b"before".to_vec()) + .publish("test/unsub-stop", b"before") .await .unwrap(); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "subscriber should receive message before unsubscribe" ); @@ -229,11 +235,11 @@ async fn unsubscribe_stops_delivery() { tokio::time::sleep(Duration::from_millis(100)).await; publisher - .publish("test/unsub-stop", b"after".to_vec()) + .publish("test/unsub-stop", b"after") .await .unwrap(); - let got_more = collector + let got_more = subscription .wait_for_messages(2, Duration::from_millis(500)) .await; assert!( @@ -242,13 +248,16 @@ async fn unsubscribe_stops_delivery() { ); } -/// Multi-filter UNSUBSCRIBE: one existing, one non-existing. Verify -/// reason codes (Success + `NoSubscriptionExisted`) and that messages -/// stop for unsubscribed topic but continue for remaining. -#[tokio::test] -async fn unsubscribe_partial_multi() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.10.4-1]` `[MQTT-3.11.3-2]` Multi-filter UNSUBSCRIBE: one +/// existing, one non-existing. Verify reason codes (Success + +/// `NoSubscriptionExisted`) and that messages stop for unsubscribed topic +/// but continue for remaining. +#[conformance_test( + ids = ["MQTT-3.10.4-1", "MQTT-3.11.3-2"], + requires = ["transport.tcp"], +)] +async fn unsubscribe_partial_multi(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsub-partial"); @@ -285,11 +294,10 @@ async fn unsubscribe_partial_multi() { reason_codes[1] ); - let publisher = connected_client("unsub-partial-pub", &broker).await; - publisher - .publish("test/keep", b"still-here".to_vec()) + let publisher = TestClient::connect_with_prefix(&sut, "unsub-partial-pub") .await .unwrap(); + publisher.publish("test/keep", b"still-here").await.unwrap(); let msg = raw.expect_publish(TIMEOUT).await; assert!( @@ -297,10 +305,7 @@ async fn unsubscribe_partial_multi() { "messages on test/keep should still be delivered" ); - publisher - .publish("test/remove", b"gone".to_vec()) - .await - .unwrap(); + publisher.publish("test/remove", b"gone").await.unwrap(); let stale = raw.expect_publish(Duration::from_millis(500)).await; assert!( @@ -309,12 +314,14 @@ async fn unsubscribe_partial_multi() { ); } -/// Unsubscribe twice from the same topic. First UNSUBACK=Success, -/// second UNSUBACK=`NoSubscriptionExisted`. -#[tokio::test] -async fn unsubscribe_idempotent() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.11.3-2]` Unsubscribe twice from the same topic. First +/// UNSUBACK=Success, second UNSUBACK=`NoSubscriptionExisted`. +#[conformance_test( + ids = ["MQTT-3.11.3-2"], + requires = ["transport.tcp"], +)] +async fn unsubscribe_idempotent(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsub-idempotent"); @@ -356,17 +363,15 @@ async fn unsubscribe_idempotent() { ); } -// --------------------------------------------------------------------------- -// Group 4: UNSUBACK Reason Code Validation — Section 3.11.3 -// --------------------------------------------------------------------------- - /// `[MQTT-3.11.3-2]` UNSUBACK reason codes must be spec-defined values. /// Success (0x00) and `NoSubscriptionExisted` (0x11) are the two valid /// outcomes for a well-formed UNSUBSCRIBE. Verify both are in range. -#[tokio::test] -async fn unsuback_reason_codes_are_valid_spec_values() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.11.3-2"], + requires = ["transport.tcp"], +)] +async fn unsuback_reason_codes_are_valid_spec_values(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsuback-valid"); @@ -410,16 +415,14 @@ async fn unsuback_reason_codes_are_valid_spec_values() { ); } -// --------------------------------------------------------------------------- -// Group 5: Invalid UTF-8 — Section 3.10.3 -// --------------------------------------------------------------------------- - /// `[MQTT-3.10.3-1]` Topic filter in UNSUBSCRIBE must be valid UTF-8. /// Sending invalid UTF-8 bytes must cause disconnect. -#[tokio::test] -async fn unsubscribe_invalid_utf8_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-3.10.3-1"], + requires = ["transport.tcp"], +)] +async fn unsubscribe_invalid_utf8_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("unsub-bad-utf8"); diff --git a/crates/mqtt5-conformance/src/conformance_tests/section4_enhanced_auth.rs b/crates/mqtt5-conformance/src/conformance_tests/section4_enhanced_auth.rs new file mode 100644 index 00000000..ad5e1742 --- /dev/null +++ b/crates/mqtt5-conformance/src/conformance_tests/section4_enhanced_auth.rs @@ -0,0 +1,104 @@ +//! Section 4.12 — Enhanced Authentication (tests that work against the +//! default `AllowAllAuth` provider). +//! +//! Tests that require a pre-configured `CHALLENGE-RESPONSE` provider remain +//! in `tests/section4_enhanced_auth.rs` because they need to inject an +//! `mqtt5::broker::auth::AuthProvider` implementation at SUT construction +//! time, which is outside the scope of the vendor-neutral `SutHandle`. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use std::time::Duration; + +/// `[MQTT-4.12.0-1]` If the Server does not support the Authentication +/// Method supplied by the Client, it MAY send a CONNACK with a Reason +/// Code of 0x8C (Bad Authentication Method). +#[conformance_test( + ids = ["MQTT-4.12.0-1"], + requires = ["transport.tcp"], +)] +async fn unsupported_auth_method_closes(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + + let client_id = unique_client_id("unsup-auth"); + let connect = RawPacketBuilder::connect_with_auth_method(&client_id, "UNKNOWN-METHOD"); + raw.send_raw(&connect).await.unwrap(); + + let connack = raw.expect_connack(Duration::from_secs(3)).await; + assert!( + connack.is_some(), + "[MQTT-4.12.0-1] Server must send CONNACK for unsupported auth method" + ); + let (_, reason_code) = connack.unwrap(); + assert_eq!( + reason_code, 0x8C, + "[MQTT-4.12.0-1] CONNACK reason code must be 0x8C (Bad Authentication Method)" + ); + + assert!( + raw.expect_disconnect(Duration::from_secs(2)).await, + "[MQTT-4.12.0-1] Server must close connection after rejecting auth method" + ); +} + +/// `[MQTT-4.12.0-6]` If the Client does not include an Authentication +/// Method in the CONNECT, the Server MUST NOT send an AUTH packet. +#[conformance_test( + ids = ["MQTT-4.12.0-6"], + requires = ["transport.tcp"], +)] +async fn no_auth_method_no_server_auth(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + + let client_id = unique_client_id("no-auth"); + let connect = RawPacketBuilder::valid_connect(&client_id); + raw.send_raw(&connect).await.unwrap(); + + let connack = raw.expect_connack(Duration::from_secs(3)).await; + assert!( + connack.is_some(), + "[MQTT-4.12.0-6] Server must send CONNACK for plain connect" + ); + let (_, reason_code) = connack.unwrap(); + assert_eq!( + reason_code, 0x00, + "[MQTT-4.12.0-6] CONNACK must be success for plain connect" + ); + + raw.send_raw(&RawPacketBuilder::pingreq()).await.unwrap(); + assert!( + raw.expect_pingresp(Duration::from_secs(3)).await, + "[MQTT-4.12.0-6] Connection must remain operational with no AUTH packets sent" + ); +} + +/// `[MQTT-4.12.0-7]` If the Client does not include an Authentication +/// Method in the CONNECT, the Client MUST NOT send an AUTH packet. The +/// Server treats this as a Protocol Error. +#[conformance_test( + ids = ["MQTT-4.12.0-7"], + requires = ["transport.tcp"], +)] +async fn unsolicited_auth_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + + let client_id = unique_client_id("unsol-auth"); + raw.connect_and_establish(&client_id, Duration::from_secs(3)) + .await; + + let auth = RawPacketBuilder::auth_with_method(0x19, "SOME-METHOD"); + raw.send_raw(&auth).await.unwrap(); + + assert!( + raw.expect_disconnect(Duration::from_secs(3)).await, + "[MQTT-4.12.0-7] Server must disconnect client that sends AUTH without prior auth method" + ); +} diff --git a/crates/mqtt5-conformance/tests/section4_error_handling.rs b/crates/mqtt5-conformance/src/conformance_tests/section4_error_handling.rs similarity index 69% rename from crates/mqtt5-conformance/tests/section4_error_handling.rs rename to crates/mqtt5-conformance/src/conformance_tests/section4_error_handling.rs index 72ddb8e9..e4379bd5 100644 --- a/crates/mqtt5-conformance/tests/section4_error_handling.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section4_error_handling.rs @@ -1,5 +1,9 @@ -use mqtt5_conformance::harness::{unique_client_id, ConformanceBroker}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Section 4.13 — Error Handling. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); @@ -7,13 +11,12 @@ const TIMEOUT: Duration = Duration::from_secs(3); /// [MQTT-4.13.1-1] When a Server detects a Malformed Packet or Protocol Error, /// and a Reason Code is given in the specification, it MUST close the Network /// Connection. -/// -/// Sends a packet with first byte 0x00 (packet type 0, which is reserved -/// and has no valid interpretation). The server must close the connection. -#[tokio::test] -async fn malformed_packet_closes_connection() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-4.13.1-1"], + requires = ["transport.tcp"], +)] +async fn malformed_packet_closes_connection(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("malformed"); @@ -31,13 +34,12 @@ async fn malformed_packet_closes_connection() { /// [MQTT-4.13.2-1] The Server MUST perform one of the following if it detects /// a Protocol Error: send a DISCONNECT with an appropriate Reason Code, and /// then close the Network Connection. -/// -/// Triggers a protocol error by sending a second CONNECT packet on the same -/// connection. The server should send DISCONNECT and close the connection. -#[tokio::test] -async fn protocol_error_disconnect_closes_connection() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +#[conformance_test( + ids = ["MQTT-4.13.2-1"], + requires = ["transport.tcp"], +)] +async fn protocol_error_disconnect_closes_connection(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("proto-err"); diff --git a/crates/mqtt5-conformance/tests/section4_flow_control.rs b/crates/mqtt5-conformance/src/conformance_tests/section4_flow_control.rs similarity index 80% rename from crates/mqtt5-conformance/tests/section4_flow_control.rs rename to crates/mqtt5-conformance/src/conformance_tests/section4_flow_control.rs index 1845b55a..6b7a8edf 100644 --- a/crates/mqtt5-conformance/tests/section4_flow_control.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section4_flow_control.rs @@ -1,16 +1,26 @@ -use mqtt5::{PublishOptions, QoS}; -use mqtt5_conformance::harness::{connected_client, unique_client_id, ConformanceBroker}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Section 4.9 — Flow Control (receive maximum quota enforcement). + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{PublishOptions, QoS}; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(3); -#[tokio::test] -async fn flow_control_quota_enforced() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-4.9.0-1]` / `[MQTT-4.9.0-2]` Server must not send more unACKed +/// `QoS` 1/2 PUBLISH packets than the client's Receive Maximum. Further +/// queued messages are delivered only after PUBACKs free the quota. +#[conformance_test( + ids = ["MQTT-4.9.0-1", "MQTT-4.9.0-2"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn flow_control_quota_enforced(sut: SutHandle) { let topic = format!("fc-quota/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub-fc"); @@ -27,7 +37,7 @@ async fn flow_control_quota_enforced() { .unwrap(); let _ = sub.expect_suback(TIMEOUT).await; - let mut pub_raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("pub-fc"); @@ -74,13 +84,18 @@ async fn flow_control_quota_enforced() { } } -#[tokio::test] -async fn flow_control_other_packets_at_zero_quota() { - let broker = ConformanceBroker::start().await; +/// `[MQTT-4.9.0-3]` PINGREQ, SUBSCRIBE and other non-PUBLISH control +/// packets must still be processed even when the PUBLISH quota is fully +/// consumed. +#[conformance_test( + ids = ["MQTT-4.9.0-3"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn flow_control_other_packets_at_zero_quota(sut: SutHandle) { let topic = format!("fc-zero/{}", unique_client_id("t")); let topic2 = format!("fc-zero2/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub-fc0"); @@ -97,7 +112,9 @@ async fn flow_control_other_packets_at_zero_quota() { .unwrap(); let _ = sub.expect_suback(TIMEOUT).await; - let publisher = connected_client("pub-fc0", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub-fc0") + .await + .unwrap(); let pub_opts = PublishOptions { qos: QoS::AtLeastOnce, ..Default::default() @@ -149,10 +166,14 @@ async fn flow_control_other_packets_at_zero_quota() { ); } -#[tokio::test] -async fn auth_invalid_flags_malformed() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) +/// `[MQTT-3.15.1-1]` AUTH packet with non-zero reserved flags is a +/// Malformed Packet and MUST cause disconnection. +#[conformance_test( + ids = ["MQTT-3.15.1-1"], + requires = ["transport.tcp"], +)] +async fn auth_invalid_flags_malformed(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("auth-flags"); diff --git a/crates/mqtt5-conformance/tests/section4_qos.rs b/crates/mqtt5-conformance/src/conformance_tests/section4_qos.rs similarity index 71% rename from crates/mqtt5-conformance/tests/section4_qos.rs rename to crates/mqtt5-conformance/src/conformance_tests/section4_qos.rs index 886ca28c..e41eede9 100644 --- a/crates/mqtt5-conformance/tests/section4_qos.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section4_qos.rs @@ -1,8 +1,11 @@ -use mqtt5::{PublishOptions, QoS, SubscribeOptions}; -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +//! Section 4.3 — `QoS` delivery protocols and Section 4.4 — message redelivery. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{PublishOptions, QoS, SubscribeOptions}; use std::collections::HashSet; use std::time::Duration; @@ -10,15 +13,14 @@ const TIMEOUT: Duration = Duration::from_secs(3); /// `[MQTT-4.3.1-1]` In the `QoS` 0 delivery protocol, the Server MUST send a /// PUBLISH packet with DUP=0. -/// -/// Subscribes a raw client at `QoS` 0, publishes from another client, and -/// verifies the received PUBLISH has DUP=0 (first byte `0x30` for non-retained). -#[tokio::test] -async fn qos0_server_outbound_publish_has_dup_zero() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.3.1-1"], + requires = ["transport.tcp"], +)] +async fn qos0_server_outbound_publish_has_dup_zero(sut: SutHandle) { let topic = format!("qos0-dup/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub"); @@ -29,11 +31,8 @@ async fn qos0_server_outbound_publish_has_dup_zero() { let _ = sub.expect_suback(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pub", &broker).await; - publisher - .publish(&topic, b"qos0-data".to_vec()) - .await - .unwrap(); + let publisher = TestClient::connect_with_prefix(&sut, "pub").await.unwrap(); + publisher.publish(&topic, b"qos0-data").await.unwrap(); let (first_byte, _packet_id, qos, recv_topic, payload) = sub .expect_publish_with_id(TIMEOUT) @@ -46,7 +45,7 @@ async fn qos0_server_outbound_publish_has_dup_zero() { let dup = (first_byte >> 3) & 0x01; assert_eq!( dup, 0, - "[MQTT-4.3.1-1] Server outbound QoS 0 PUBLISH must have DUP=0" + "[MQTT-4.3.1-1] Server outbound `QoS` 0 PUBLISH must have DUP=0" ); } @@ -54,15 +53,14 @@ async fn qos0_server_outbound_publish_has_dup_zero() { /// Server MUST assign a non-zero Packet Identifier each time it has a new /// Application Message to deliver, and the PUBLISH MUST have DUP=0 on first /// delivery. -/// -/// Subscribes at `QoS` 1, publishes messages one at a time, acknowledges each, -/// and verifies each received PUBLISH has a unique non-zero packet ID and DUP=0. -#[tokio::test] -async fn qos1_server_outbound_unique_nonzero_id_and_dup_zero() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.3.2-1", "MQTT-4.3.2-2"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn qos1_server_outbound_unique_nonzero_id_and_dup_zero(sut: SutHandle) { let topic = format!("qos1-id/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub"); @@ -73,7 +71,7 @@ async fn qos1_server_outbound_unique_nonzero_id_and_dup_zero() { let _ = sub.expect_suback(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let mut pub_raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("pub"); @@ -122,15 +120,14 @@ async fn qos1_server_outbound_unique_nonzero_id_and_dup_zero() { /// `[MQTT-4.3.2-5]` After the Server has sent a PUBACK, the Packet Identifier /// is available for reuse. -/// -/// Subscribes at `QoS` 1, receives a message, sends PUBACK, receives next -/// message — verifies the second message can reuse the same packet ID. -#[tokio::test] -async fn qos1_packet_id_reusable_after_puback() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.3.2-5"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn qos1_packet_id_reusable_after_puback(sut: SutHandle) { let topic = format!("qos1-reuse/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub"); @@ -141,14 +138,14 @@ async fn qos1_packet_id_reusable_after_puback() { let _ = sub.expect_suback(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub").await.unwrap(); let pub_opts = PublishOptions { qos: QoS::AtLeastOnce, ..Default::default() }; publisher - .publish_with_options(&topic, b"msg-1".to_vec(), pub_opts.clone()) + .publish_with_options(&topic, b"msg-1", pub_opts.clone()) .await .unwrap(); @@ -164,7 +161,7 @@ async fn qos1_packet_id_reusable_after_puback() { tokio::time::sleep(Duration::from_millis(50)).await; publisher - .publish_with_options(&topic, b"msg-2".to_vec(), pub_opts) + .publish_with_options(&topic, b"msg-2", pub_opts) .await .unwrap(); @@ -180,17 +177,16 @@ async fn qos1_packet_id_reusable_after_puback() { } /// `[MQTT-2.2.1-4]` The Server assigns non-zero Packet Identifiers to outbound -/// `QoS`>0 packets. -/// -/// Subscribes at `QoS` 1 and `QoS` 2 on separate topics, publishes to both, -/// and verifies all received packet IDs are non-zero. -#[tokio::test] -async fn server_assigns_nonzero_packet_ids() { - let broker = ConformanceBroker::start().await; +/// QoS>0 packets. +#[conformance_test( + ids = ["MQTT-2.2.1-4"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn server_assigns_nonzero_packet_ids(sut: SutHandle) { let topic_q1 = format!("pid-nz-q1/{}", unique_client_id("t")); let topic_q2 = format!("pid-nz-q2/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub"); @@ -205,14 +201,14 @@ async fn server_assigns_nonzero_packet_ids() { let _ = sub.expect_suback(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub").await.unwrap(); let q1_opts = PublishOptions { qos: QoS::AtLeastOnce, ..Default::default() }; publisher - .publish_with_options(&topic_q1, b"q1-data".to_vec(), q1_opts) + .publish_with_options(&topic_q1, b"q1-data", q1_opts) .await .unwrap(); @@ -232,7 +228,7 @@ async fn server_assigns_nonzero_packet_ids() { ..Default::default() }; publisher - .publish_with_options(&topic_q2, b"q2-data".to_vec(), q2_opts) + .publish_with_options(&topic_q2, b"q2-data", q2_opts) .await .unwrap(); @@ -255,12 +251,14 @@ async fn server_assigns_nonzero_packet_ids() { /// `[MQTT-4.3.3-1]` `[MQTT-4.3.3-2]` In the `QoS` 2 delivery protocol, the /// Server MUST assign a non-zero Packet Identifier and the outbound PUBLISH /// MUST have DUP=0 on first delivery. -#[tokio::test] -async fn qos2_server_outbound_unique_nonzero_id_and_dup_zero() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.3.3-1", "MQTT-4.3.3-2"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn qos2_server_outbound_unique_nonzero_id_and_dup_zero(sut: SutHandle) { let topic = format!("qos2-id/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub"); @@ -271,13 +269,13 @@ async fn qos2_server_outbound_unique_nonzero_id_and_dup_zero() { let _ = sub.expect_suback(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub").await.unwrap(); let pub_opts = PublishOptions { qos: QoS::ExactlyOnce, ..Default::default() }; publisher - .publish_with_options(&topic, b"qos2-data".to_vec(), pub_opts) + .publish_with_options(&topic, b"qos2-data", pub_opts) .await .unwrap(); @@ -312,16 +310,14 @@ async fn qos2_server_outbound_unique_nonzero_id_and_dup_zero() { /// `[MQTT-4.3.3-3]` The `QoS` 2 message is considered "unacknowledged" until /// the corresponding PUBREC has been received. The Server MUST hold the message /// state. -/// -/// Subscribes at `QoS` 2, receives PUBLISH, does NOT send PUBREC. Verifies the -/// server does not discard the message state by checking that no further packets -/// arrive spontaneously. -#[tokio::test] -async fn qos2_unacknowledged_until_pubrec() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.3.3-3"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn qos2_unacknowledged_until_pubrec(sut: SutHandle) { let topic = format!("qos2-hold/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub"); @@ -332,13 +328,13 @@ async fn qos2_unacknowledged_until_pubrec() { let _ = sub.expect_suback(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub").await.unwrap(); let pub_opts = PublishOptions { qos: QoS::ExactlyOnce, ..Default::default() }; publisher - .publish_with_options(&topic, b"held".to_vec(), pub_opts) + .publish_with_options(&topic, b"held", pub_opts) .await .unwrap(); @@ -359,15 +355,14 @@ async fn qos2_unacknowledged_until_pubrec() { /// `[MQTT-4.3.3-5]` The Server MUST send a PUBREL after receiving PUBREC, and /// MUST hold the Packet Identifier state until the corresponding PUBCOMP is /// received. -/// -/// Full outbound `QoS` 2 flow from server perspective: subscriber receives -/// PUBLISH, sends PUBREC, receives PUBREL, sends PUBCOMP. -#[tokio::test] -async fn qos2_server_sends_pubrel_after_pubrec_and_holds_until_pubcomp() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.3.3-5"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn qos2_server_sends_pubrel_after_pubrec_and_holds_until_pubcomp(sut: SutHandle) { let topic = format!("qos2-flow/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub"); @@ -378,13 +373,13 @@ async fn qos2_server_sends_pubrel_after_pubrec_and_holds_until_pubcomp() { let _ = sub.expect_suback(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub").await.unwrap(); let pub_opts = PublishOptions { qos: QoS::ExactlyOnce, ..Default::default() }; publisher - .publish_with_options(&topic, b"flow-data".to_vec(), pub_opts) + .publish_with_options(&topic, b"flow-data", pub_opts) .await .unwrap(); @@ -417,37 +412,25 @@ async fn qos2_server_sends_pubrel_after_pubrec_and_holds_until_pubcomp() { /// `[MQTT-4.3.3-9]` A PUBREC with a Reason Code of 0x80 or greater indicates /// the Server MUST discard the message and treat the Packet Identifier as /// available for reuse. -/// -/// Client publishes `QoS` 2, receives PUBREC, sends PUBREL, receives PUBCOMP -/// from the server (inbound flow). We test the *inbound* direction: client -/// sends `QoS` 2 PUBLISH, server sends PUBREC; client then sends PUBREC with -/// error for an *outbound* server publish. Since we cannot easily force the -/// server to send us a `QoS` 2 PUBLISH and then us respond with PUBREC error, -/// we test the equivalent: client sends `QoS` 2 PUBLISH, server sends PUBREC -/// with success, client sends PUBREL, server sends PUBCOMP. Then we verify a -/// new PUBLISH with the same packet ID is treated as new. -/// -/// Actually, this statement applies to the *receiver* side. For the inbound -/// direction, the server IS the receiver. We send a `QoS` 2 PUBLISH, get -/// PUBREC with error code, and verify the server treated the ID as released. -#[tokio::test] -async fn qos2_pubrec_error_allows_packet_id_reuse() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.3.3-9"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn qos2_pubrec_error_allows_packet_id_reuse(sut: SutHandle) { let topic = format!("qos2-pubrec-err/{}", unique_client_id("t")); - let collector = MessageCollector::new(); - let subscriber = connected_client("sub", &broker).await; + let subscriber = TestClient::connect_with_prefix(&sut, "sub").await.unwrap(); let sub_opts = SubscribeOptions { qos: QoS::ExactlyOnce, ..Default::default() }; - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let _subscription = subscriber + .subscribe(&topic, sub_opts) .await - .unwrap(); + .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let mut pub_raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("pub"); @@ -502,28 +485,25 @@ async fn qos2_pubrec_error_allows_packet_id_reuse() { /// `[MQTT-4.3.3-10]` A `QoS` 2 PUBLISH with DUP=1 (retransmission) MUST NOT /// cause the message to be duplicated to subscribers. -/// -/// Sends a `QoS` 2 PUBLISH, gets PUBREC. Then sends the same PUBLISH again -/// with DUP=1 and same packet ID. The subscriber should receive the message -/// only once. -#[tokio::test] -async fn qos2_duplicate_publish_no_double_delivery() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.3.3-10"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn qos2_duplicate_publish_no_double_delivery(sut: SutHandle) { let topic = format!("qos2-dup/{}", unique_client_id("t")); - let collector = MessageCollector::new(); - let subscriber = connected_client("sub", &broker).await; + let subscriber = TestClient::connect_with_prefix(&sut, "sub").await.unwrap(); let sub_opts = SubscribeOptions { qos: QoS::ExactlyOnce, ..Default::default() }; - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let subscription = subscriber + .subscribe(&topic, sub_opts) .await - .unwrap(); + .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let mut pub_raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("pub"); @@ -567,7 +547,7 @@ async fn qos2_duplicate_publish_no_double_delivery() { let _ = pub_raw.expect_pubcomp(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(500)).await; - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!( msgs.len(), 1, @@ -579,27 +559,25 @@ async fn qos2_duplicate_publish_no_double_delivery() { /// `[MQTT-4.3.3-12]` After PUBCOMP, the same Packet Identifier is treated as /// a new publication. -/// -/// Completes a full `QoS` 2 inbound flow, then sends a new PUBLISH with the -/// same packet ID and verifies the server treats it as a new message. -#[tokio::test] -async fn qos2_after_pubcomp_same_id_is_new_message() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.3.3-12"], + requires = ["transport.tcp", "max_qos>=2"], +)] +async fn qos2_after_pubcomp_same_id_is_new_message(sut: SutHandle) { let topic = format!("qos2-new/{}", unique_client_id("t")); - let collector = MessageCollector::new(); - let subscriber = connected_client("sub", &broker).await; + let subscriber = TestClient::connect_with_prefix(&sut, "sub").await.unwrap(); let sub_opts = SubscribeOptions { qos: QoS::ExactlyOnce, ..Default::default() }; - subscriber - .subscribe_with_options(&topic, sub_opts, collector.callback()) + let subscription = subscriber + .subscribe(&topic, sub_opts) .await - .unwrap(); + .expect("subscribe failed"); tokio::time::sleep(Duration::from_millis(100)).await; - let mut pub_raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut pub_raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let pub_id = unique_client_id("pub"); @@ -640,25 +618,24 @@ async fn qos2_after_pubcomp_same_id_is_new_message() { let _ = pub_raw.expect_pubcomp(TIMEOUT).await.expect("PUBCOMP #2"); assert!( - collector.wait_for_messages(2, TIMEOUT).await, + subscription.wait_for_messages(2, TIMEOUT).await, "[MQTT-4.3.3-12] Both messages must be delivered" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!(msgs[0].payload, b"first"); assert_eq!(msgs[1].payload, b"second"); } /// `[MQTT-4.4.0-1]` The Server MUST NOT resend a PUBLISH during the same /// Network Connection (no spontaneous retransmission on active connections). -/// -/// Subscribes at `QoS` 1, receives a PUBLISH, does NOT send PUBACK for 2 -/// seconds, and verifies the server does not spontaneously retransmit. -#[tokio::test] -async fn no_spontaneous_retransmission_on_active_connection() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.4.0-1"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn no_spontaneous_retransmission_on_active_connection(sut: SutHandle) { let topic = format!("no-resend/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub"); @@ -669,13 +646,13 @@ async fn no_spontaneous_retransmission_on_active_connection() { let _ = sub.expect_suback(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub").await.unwrap(); let pub_opts = PublishOptions { qos: QoS::AtLeastOnce, ..Default::default() }; publisher - .publish_with_options(&topic, b"no-retry".to_vec(), pub_opts) + .publish_with_options(&topic, b"no-retry", pub_opts) .await .unwrap(); @@ -699,15 +676,14 @@ async fn no_spontaneous_retransmission_on_active_connection() { /// `[MQTT-4.4.0-2]` When a Client sends a PUBACK with a Reason Code of 0x80 /// or greater, the Server MUST treat the PUBLISH as acknowledged and MUST NOT /// attempt to retransmit. -/// -/// Subscribes at `QoS` 1, receives PUBLISH, sends PUBACK with error reason -/// code `0x80`, waits, and verifies no retransmission occurs. -#[tokio::test] -async fn puback_error_stops_retransmission() { - let broker = ConformanceBroker::start().await; +#[conformance_test( + ids = ["MQTT-4.4.0-2"], + requires = ["transport.tcp", "max_qos>=1"], +)] +async fn puback_error_stops_retransmission(sut: SutHandle) { let topic = format!("puback-err/{}", unique_client_id("t")); - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let sub_id = unique_client_id("sub"); @@ -718,13 +694,13 @@ async fn puback_error_stops_retransmission() { let _ = sub.expect_suback(TIMEOUT).await; tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "pub").await.unwrap(); let pub_opts = PublishOptions { qos: QoS::AtLeastOnce, ..Default::default() }; publisher - .publish_with_options(&topic, b"ack-err".to_vec(), pub_opts) + .publish_with_options(&topic, b"ack-err", pub_opts) .await .unwrap(); diff --git a/crates/mqtt5-conformance/src/conformance_tests/section4_shared_sub.rs b/crates/mqtt5-conformance/src/conformance_tests/section4_shared_sub.rs new file mode 100644 index 00000000..4203a328 --- /dev/null +++ b/crates/mqtt5-conformance/src/conformance_tests/section4_shared_sub.rs @@ -0,0 +1,489 @@ +//! Section 4.8 — Shared Subscriptions. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::{PublishOptions, QoS, SubscribeOptions}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(3); + +/// `[MQTT-4.8.2-1]` A valid shared subscription filter +/// (`$share//`) must be accepted. +#[conformance_test( + ids = ["MQTT-4.8.2-1"], + requires = ["transport.tcp", "shared_subscription_available"], +)] +async fn shared_sub_valid_format_accepted(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("shared-valid"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( + "$share/mygroup/sensor/+", + 0, + 1, + )) + .await + .unwrap(); + + let (_, reason_codes) = raw + .expect_suback(TIMEOUT) + .await + .expect("[MQTT-4.8.2-1] must receive SUBACK"); + assert_eq!(reason_codes.len(), 1); + assert_eq!( + reason_codes[0], 0x00, + "[MQTT-4.8.2-1] valid shared subscription must be granted QoS 0" + ); +} + +/// `[MQTT-4.8.2-2]` The `ShareName` MUST NOT contain `#` or `+`. +#[conformance_test( + ids = ["MQTT-4.8.2-2"], + requires = ["transport.tcp", "shared_subscription_available"], +)] +async fn shared_sub_share_name_with_wildcard_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("shared-badname"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_multiple( + &[("$share/gr+oup/topic", 0), ("$share/gr#oup/topic", 0)], + 1, + )) + .await + .unwrap(); + + let (_, reason_codes) = raw + .expect_suback(TIMEOUT) + .await + .expect("[MQTT-4.8.2-2] must receive SUBACK"); + assert_eq!(reason_codes.len(), 2); + assert_eq!( + reason_codes[0], 0x8F, + "[MQTT-4.8.2-2] ShareName with + must return TopicFilterInvalid (0x8F)" + ); + assert_eq!( + reason_codes[1], 0x8F, + "[MQTT-4.8.2-2] ShareName with # must return TopicFilterInvalid (0x8F)" + ); +} + +/// `[MQTT-4.8.2-1]` Incomplete `$share/` without a topic filter after +/// the second `/` must be rejected with `TopicFilterInvalid`. +#[conformance_test( + ids = ["MQTT-4.8.2-1"], + requires = ["transport.tcp", "shared_subscription_available"], +)] +async fn shared_sub_incomplete_format_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("shared-incomplete"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( + "$share/grouponly", + 0, + 1, + )) + .await + .unwrap(); + + let (_, reason_codes) = raw + .expect_suback(TIMEOUT) + .await + .expect("[MQTT-4.8.2-1] must receive SUBACK"); + assert_eq!(reason_codes.len(), 1); + assert_eq!( + reason_codes[0], 0x8F, + "[MQTT-4.8.2-1] incomplete $share/grouponly (no second /) must return TopicFilterInvalid" + ); +} + +/// `[MQTT-4.8.2-4]` Messages published to a shared subscription are +/// distributed across the members of the shared group. +#[conformance_test( + ids = ["MQTT-4.8.2-4"], + requires = ["transport.tcp", "shared_subscription_available"], +)] +async fn shared_sub_round_robin_delivery(sut: SutHandle) { + let topic = format!("tasks/{}", unique_client_id("rr")); + let shared_filter = format!("$share/workers/{topic}"); + + let worker1 = TestClient::connect_with_prefix(&sut, "shared-rr-w1") + .await + .unwrap(); + let sub1 = worker1 + .subscribe(&shared_filter, SubscribeOptions::default()) + .await + .unwrap(); + + let worker2 = TestClient::connect_with_prefix(&sut, "shared-rr-w2") + .await + .unwrap(); + let sub2 = worker2 + .subscribe(&shared_filter, SubscribeOptions::default()) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "shared-rr-pub") + .await + .unwrap(); + for i in 0..6 { + let payload = format!("msg-{i}"); + publisher.publish(&topic, payload.as_bytes()).await.unwrap(); + } + + tokio::time::sleep(Duration::from_millis(500)).await; + + let count1 = sub1.count(); + let count2 = sub2.count(); + assert_eq!( + count1 + count2, + 6, + "all 6 messages must be delivered across shared group" + ); + assert!( + count1 >= 1 && count2 >= 1, + "both shared subscribers must receive at least one message: w1={count1}, w2={count2}" + ); +} + +/// `[MQTT-4.8.2-4]` A shared subscription coexists with regular +/// subscriptions on the same topic; both must receive the message. +#[conformance_test( + ids = ["MQTT-4.8.2-4"], + requires = ["transport.tcp", "shared_subscription_available"], +)] +async fn shared_sub_mixed_with_regular(sut: SutHandle) { + let topic = format!("tasks/{}", unique_client_id("mix")); + let shared_filter = format!("$share/workers/{topic}"); + + let shared = TestClient::connect_with_prefix(&sut, "shared-mix-s") + .await + .unwrap(); + let sub_shared = shared + .subscribe(&shared_filter, SubscribeOptions::default()) + .await + .unwrap(); + + let regular = TestClient::connect_with_prefix(&sut, "shared-mix-r") + .await + .unwrap(); + let sub_regular = regular + .subscribe(&topic, SubscribeOptions::default()) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "shared-mix-pub") + .await + .unwrap(); + for i in 0..4 { + let payload = format!("msg-{i}"); + publisher.publish(&topic, payload.as_bytes()).await.unwrap(); + } + + sub_regular.wait_for_messages(4, TIMEOUT).await; + sub_shared.wait_for_messages(4, TIMEOUT).await; + + tokio::time::sleep(Duration::from_millis(300)).await; + + assert_eq!( + sub_regular.count(), + 4, + "regular subscriber must receive all 4 messages" + ); + assert_eq!( + sub_shared.count(), + 4, + "sole shared group member must receive all 4 messages" + ); +} + +/// `[MQTT-4.8.2-5]` Shared subscriptions MUST NOT receive retained messages +/// on subscribe. +#[conformance_test( + ids = ["MQTT-4.8.2-5"], + requires = ["transport.tcp", "shared_subscription_available", "retain_available"], +)] +async fn shared_sub_no_retained_on_subscribe(sut: SutHandle) { + let topic = format!("sensor/temp/{}", unique_client_id("ret")); + let shared_filter = format!("$share/readers/{topic}"); + + let publisher = TestClient::connect_with_prefix(&sut, "shared-ret-pub") + .await + .unwrap(); + publisher + .publish_with_options( + &topic, + b"25.5", + PublishOptions { + retain: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(200)).await; + + let shared_sub = TestClient::connect_with_prefix(&sut, "shared-ret-s") + .await + .unwrap(); + let sub_shared = shared_sub + .subscribe(&shared_filter, SubscribeOptions::default()) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(300)).await; + + assert_eq!( + sub_shared.count(), + 0, + "shared subscription must not receive retained messages on subscribe" + ); + + let regular_sub = TestClient::connect_with_prefix(&sut, "shared-ret-r") + .await + .unwrap(); + let sub_regular = regular_sub + .subscribe(&topic, SubscribeOptions::default()) + .await + .unwrap(); + + sub_regular.wait_for_messages(1, TIMEOUT).await; + let msgs = sub_regular.snapshot(); + assert_eq!( + msgs.len(), + 1, + "regular subscription must receive retained message" + ); + assert_eq!(msgs[0].payload, b"25.5"); +} + +/// `[MQTT-4.8.2-4]` After unsubscribing from a shared subscription, the +/// client must no longer receive messages distributed to the share group. +#[conformance_test( + ids = ["MQTT-4.8.2-4"], + requires = ["transport.tcp", "shared_subscription_available"], +)] +async fn shared_sub_unsubscribe_stops_delivery(sut: SutHandle) { + let topic = format!("tasks/{}", unique_client_id("unsub")); + let shared_filter = format!("$share/workers/{topic}"); + + let subscriber = TestClient::connect_with_prefix(&sut, "shared-unsub-s") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&shared_filter, SubscribeOptions::default()) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "shared-unsub-pub") + .await + .unwrap(); + publisher.publish(&topic, b"before").await.unwrap(); + + subscription.wait_for_messages(1, TIMEOUT).await; + assert_eq!( + subscription.count(), + 1, + "must receive message before unsubscribe" + ); + + subscriber.unsubscribe(&shared_filter).await.unwrap(); + + tokio::time::sleep(Duration::from_millis(100)).await; + + publisher.publish(&topic, b"after").await.unwrap(); + + tokio::time::sleep(Duration::from_millis(300)).await; + + assert_eq!( + subscription.count(), + 1, + "must not receive messages after unsubscribe from shared subscription" + ); +} + +/// `[MQTT-4.8.2-4]` Multiple shared groups with different `ShareNames` on the +/// same topic are independent; each group receives the message. +#[conformance_test( + ids = ["MQTT-4.8.2-4"], + requires = ["transport.tcp", "shared_subscription_available"], +)] +async fn shared_sub_multiple_groups_independent(sut: SutHandle) { + let topic = format!("topic/{}", unique_client_id("grp")); + let filter_a = format!("$share/groupA/{topic}"); + let filter_b = format!("$share/groupB/{topic}"); + + let client_a = TestClient::connect_with_prefix(&sut, "shared-grpA") + .await + .unwrap(); + let sub_a = client_a + .subscribe(&filter_a, SubscribeOptions::default()) + .await + .unwrap(); + + let client_b = TestClient::connect_with_prefix(&sut, "shared-grpB") + .await + .unwrap(); + let sub_b = client_b + .subscribe(&filter_b, SubscribeOptions::default()) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "shared-grp-pub") + .await + .unwrap(); + publisher.publish(&topic, b"payload").await.unwrap(); + + sub_a.wait_for_messages(1, TIMEOUT).await; + sub_b.wait_for_messages(1, TIMEOUT).await; + + assert_eq!( + sub_a.count(), + 1, + "groupA must receive the message independently" + ); + assert_eq!( + sub_b.count(), + 1, + "groupB must receive the message independently" + ); +} + +/// `[MQTT-4.8.2-3]` Messages delivered to a shared subscription are +/// delivered at the minimum of the publish `QoS` and the granted `QoS`. +#[conformance_test( + ids = ["MQTT-4.8.2-3"], + requires = ["transport.tcp", "shared_subscription_available", "max_qos>=1"], +)] +async fn shared_sub_respects_granted_qos(sut: SutHandle) { + let topic = format!("shared-qos/{}", unique_client_id("t")); + let shared_filter = format!("$share/qgrp/{topic}"); + + let mut sub = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let sub_id = unique_client_id("sub-sqos"); + sub.connect_and_establish(&sub_id, TIMEOUT).await; + + sub.send_raw(&RawPacketBuilder::subscribe_with_packet_id( + &shared_filter, + 0, + 1, + )) + .await + .unwrap(); + let (_, reason_codes) = sub + .expect_suback(TIMEOUT) + .await + .expect("must receive SUBACK"); + assert_eq!( + reason_codes[0], 0x00, + "shared subscription must be granted at QoS 0" + ); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let mut pub_raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let pub_id = unique_client_id("pub-sqos"); + pub_raw.connect_and_establish(&pub_id, TIMEOUT).await; + + pub_raw + .send_raw(&RawPacketBuilder::publish_qos1(&topic, b"qos1-data", 1)) + .await + .unwrap(); + let _ = pub_raw.expect_puback(TIMEOUT).await; + + let (qos, recv_topic, payload) = sub + .expect_publish(TIMEOUT) + .await + .expect("[MQTT-4.8.2-3] subscriber must receive message"); + + assert_eq!(recv_topic, topic); + assert_eq!(payload, b"qos1-data"); + assert_eq!( + qos, 0, + "[MQTT-4.8.2-3] message published at QoS 1 must be downgraded to granted QoS 0" + ); +} + +/// `[MQTT-4.8.2-6]` A message rejected by a shared subscriber with a +/// PUBACK error reason code MUST NOT be redistributed to other members of +/// the share group. +#[conformance_test( + ids = ["MQTT-4.8.2-6"], + requires = ["transport.tcp", "shared_subscription_available", "max_qos>=1"], +)] +async fn shared_sub_puback_error_discards(sut: SutHandle) { + let topic = format!("shared-err/{}", unique_client_id("t")); + let shared_filter = format!("$share/errgrp/{topic}"); + + let mut worker = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let worker_id = unique_client_id("shared-err-w"); + worker.connect_and_establish(&worker_id, TIMEOUT).await; + worker + .send_raw(&RawPacketBuilder::subscribe_with_packet_id( + &shared_filter, + 1, + 1, + )) + .await + .unwrap(); + let _ = worker.expect_suback(TIMEOUT).await; + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "shared-err-pub") + .await + .unwrap(); + let pub_opts = PublishOptions { + qos: QoS::AtLeastOnce, + ..Default::default() + }; + publisher + .publish_with_options(&topic, b"reject-me", pub_opts) + .await + .unwrap(); + + let received = worker + .expect_publish_with_id(TIMEOUT) + .await + .expect("sole shared subscriber must receive the message"); + + let (_, packet_id, _, _, _) = received; + worker + .send_raw(&RawPacketBuilder::puback_with_reason(packet_id, 0x80)) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let redistributed = worker.read_packet_bytes(Duration::from_secs(1)).await; + assert!( + redistributed.is_none(), + "[MQTT-4.8.2-6] message rejected with PUBACK error must not be redistributed" + ); +} diff --git a/crates/mqtt5-conformance/src/conformance_tests/section4_topic.rs b/crates/mqtt5-conformance/src/conformance_tests/section4_topic.rs new file mode 100644 index 00000000..97025c1a --- /dev/null +++ b/crates/mqtt5-conformance/src/conformance_tests/section4_topic.rs @@ -0,0 +1,578 @@ +//! Section 4.7 — Topic Names and Topic Filters. + +use crate::conformance_test; +use crate::harness::unique_client_id; +use crate::raw_client::{RawMqttClient, RawPacketBuilder}; +use crate::sut::SutHandle; +use crate::test_client::TestClient; +use mqtt5_protocol::types::SubscribeOptions; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(3); + +/// `[MQTT-4.7.1-1]` The multi-level wildcard `#` MUST be the last character +/// in a topic filter. +#[conformance_test( + ids = ["MQTT-4.7.1-1"], + requires = ["transport.tcp"], +)] +async fn multi_level_wildcard_must_be_last(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("mlwild-last"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( + "sport/tennis/#/ranking", + 0, + 1, + )) + .await + .unwrap(); + + let response = raw.expect_suback(TIMEOUT).await; + match response { + Some((_, reason_codes)) => { + assert_eq!(reason_codes.len(), 1); + assert_eq!( + reason_codes[0], 0x8F, + "[MQTT-4.7.1-1] # not last must return TopicFilterInvalid (0x8F)" + ); + } + None => { + assert!( + raw.expect_disconnect(Duration::from_millis(100)).await, + "[MQTT-4.7.1-1] # not last must cause SUBACK 0x8F or disconnect" + ); + } + } +} + +/// `[MQTT-4.7.1-1]` The multi-level wildcard `#` MUST occupy a complete +/// topic level on its own. +#[conformance_test( + ids = ["MQTT-4.7.1-1"], + requires = ["transport.tcp"], +)] +async fn multi_level_wildcard_must_be_full_level(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("mlwild-full"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( + "sport/tennis#", + 0, + 1, + )) + .await + .unwrap(); + + let response = raw.expect_suback(TIMEOUT).await; + match response { + Some((_, reason_codes)) => { + assert_eq!(reason_codes.len(), 1); + assert_eq!( + reason_codes[0], 0x8F, + "[MQTT-4.7.1-1] tennis# (not full level) must return TopicFilterInvalid" + ); + } + None => { + assert!( + raw.expect_disconnect(Duration::from_millis(100)).await, + "[MQTT-4.7.1-1] tennis# must cause SUBACK 0x8F or disconnect" + ); + } + } +} + +/// `[MQTT-4.7.1-2]` The single-level wildcard `+` MUST occupy a complete +/// topic level on its own. +#[conformance_test( + ids = ["MQTT-4.7.1-2"], + requires = ["transport.tcp"], +)] +async fn single_level_wildcard_must_be_full_level(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("slwild-full"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_multiple( + &[("sport+", 0), ("sport/+tennis", 0)], + 1, + )) + .await + .unwrap(); + + let response = raw.expect_suback(TIMEOUT).await; + match response { + Some((_, reason_codes)) => { + assert_eq!(reason_codes.len(), 2); + assert_eq!( + reason_codes[0], 0x8F, + "[MQTT-4.7.1-2] sport+ must return TopicFilterInvalid" + ); + assert_eq!( + reason_codes[1], 0x8F, + "[MQTT-4.7.1-2] sport/+tennis must return TopicFilterInvalid" + ); + } + None => { + assert!( + raw.expect_disconnect(Duration::from_millis(100)).await, + "[MQTT-4.7.1-2] invalid + usage must cause SUBACK 0x8F or disconnect" + ); + } + } +} + +/// `[MQTT-4.7.1-1]` `[MQTT-4.7.1-2]` Valid wildcard filters must be +/// accepted with reason code Success (0x00). +#[conformance_test( + ids = ["MQTT-4.7.1-1", "MQTT-4.7.1-2"], + requires = ["transport.tcp"], +)] +async fn valid_wildcard_filters_accepted(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("wild-ok"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_multiple( + &[ + ("sport/+", 0), + ("sport/#", 0), + ("+/tennis/#", 0), + ("#", 0), + ("+", 0), + ], + 1, + )) + .await + .unwrap(); + + let (_, reason_codes) = raw + .expect_suback(TIMEOUT) + .await + .expect("must receive SUBACK for valid wildcards"); + assert_eq!(reason_codes.len(), 5); + for (i, rc) in reason_codes.iter().enumerate() { + assert_eq!(*rc, 0x00, "filter {i} must be granted QoS 0, got {rc:#04x}"); + } +} + +/// `[MQTT-4.7.2-1]` Topic Names beginning with `$` MUST NOT be matched by +/// a Topic Filter starting with a wildcard character (`#` or `+`). +#[conformance_test( + ids = ["MQTT-4.7.2-1"], + requires = ["transport.tcp"], +)] +async fn dollar_topics_not_matched_by_root_wildcards(sut: SutHandle) { + let sub_hash = TestClient::connect_with_prefix(&sut, "dollar-hash") + .await + .unwrap(); + let subscription_hash = sub_hash + .subscribe("#", SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + let sub_plus = TestClient::connect_with_prefix(&sut, "dollar-plus") + .await + .unwrap(); + let subscription_plus = sub_plus + .subscribe("+/info", SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + tokio::time::sleep(Duration::from_millis(200)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "dollar-pub") + .await + .unwrap(); + publisher + .publish("$SYS/test", b"sys-payload") + .await + .unwrap(); + publisher.publish("$SYS/info", b"sys-info").await.unwrap(); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let dollar_on_hash: Vec<_> = subscription_hash + .snapshot() + .into_iter() + .filter(|m| m.topic.starts_with('$')) + .collect(); + assert!( + dollar_on_hash.is_empty(), + "[MQTT-4.7.2-1] # must not match $-prefixed topics, got: {:?}", + dollar_on_hash.iter().map(|m| &m.topic).collect::>() + ); + + let dollar_on_plus: Vec<_> = subscription_plus + .snapshot() + .into_iter() + .filter(|m| m.topic.starts_with('$')) + .collect(); + assert!( + dollar_on_plus.is_empty(), + "[MQTT-4.7.2-1] +/info must not match $-prefixed topics, got: {:?}", + dollar_on_plus.iter().map(|m| &m.topic).collect::>() + ); +} + +/// `[MQTT-4.7.2-1]` Topic Filters that explicitly include `$` (e.g. +/// `$SYS/#`) MAY match topics beginning with `$`. +#[conformance_test( + ids = ["MQTT-4.7.2-1"], + requires = ["transport.tcp"], +)] +async fn dollar_topics_matched_by_explicit_prefix(sut: SutHandle) { + let subscriber = TestClient::connect_with_prefix(&sut, "dollar-explicit") + .await + .unwrap(); + let subscription = subscriber + .subscribe("$SYS/#", SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "dollar-explpub") + .await + .unwrap(); + publisher.publish("$SYS/test", b"payload").await.unwrap(); + + subscription.wait_for_messages(1, TIMEOUT).await; + + tokio::time::sleep(Duration::from_millis(300)).await; + + let msgs = subscription.snapshot(); + let found = msgs + .iter() + .find(|m| m.topic == "$SYS/test") + .expect("$SYS/# must match $SYS/test"); + assert_eq!(found.payload, b"payload"); +} + +/// `[MQTT-4.7.3-1]` Topic Filters MUST be at least one character long. +/// An empty filter must be rejected with `TopicFilterInvalid` or disconnect. +#[conformance_test( + ids = ["MQTT-4.7.3-1"], + requires = ["transport.tcp"], +)] +async fn topic_filter_must_not_be_empty(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("empty-filter"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id("", 0, 1)) + .await + .unwrap(); + + let response = raw.expect_suback(TIMEOUT).await; + match response { + Some((_, reason_codes)) => { + assert_eq!(reason_codes.len(), 1); + assert_eq!( + reason_codes[0], 0x8F, + "[MQTT-4.7.3-1] empty filter must return TopicFilterInvalid" + ); + } + None => { + assert!( + raw.expect_disconnect(Duration::from_millis(100)).await, + "[MQTT-4.7.3-1] empty filter must cause SUBACK 0x8F or disconnect" + ); + } + } +} + +/// `[MQTT-4.7.3-2]` Topic Names and Topic Filters MUST NOT include the +/// null character (U+0000). PUBLISH with a null in the topic name must +/// cause disconnect. +#[conformance_test( + ids = ["MQTT-4.7.3-2"], + requires = ["transport.tcp"], +)] +async fn null_char_in_topic_name_rejected(sut: SutHandle) { + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) + .await + .unwrap(); + let client_id = unique_client_id("null-topic"); + raw.connect_and_establish(&client_id, TIMEOUT).await; + + raw.send_raw(&RawPacketBuilder::publish_qos0("test\0/topic", b"payload")) + .await + .unwrap(); + + assert!( + raw.expect_disconnect(TIMEOUT).await, + "[MQTT-4.7.3-2] null char in topic name must cause disconnect" + ); +} + +/// `[MQTT-4.7.1-2]` The single-level wildcard `+` matches exactly one +/// topic level — not zero, not multiple. +#[conformance_test( + ids = ["MQTT-4.7.1-2"], + requires = ["transport.tcp"], +)] +async fn single_level_wildcard_matches_one_level(sut: SutHandle) { + let subscriber = TestClient::connect_with_prefix(&sut, "sl-match") + .await + .unwrap(); + let subscription = subscriber + .subscribe("sport/+/player", SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "sl-pub") + .await + .unwrap(); + publisher + .publish("sport/tennis/player", b"match") + .await + .unwrap(); + publisher + .publish("sport/tennis/doubles/player", b"no-match") + .await + .unwrap(); + + assert!( + subscription.wait_for_messages(1, TIMEOUT).await, + "sport/+/player must match sport/tennis/player" + ); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let msgs = subscription.snapshot(); + assert_eq!( + msgs.len(), + 1, + "sport/+/player must not match sport/tennis/doubles/player" + ); + assert_eq!(msgs[0].topic, "sport/tennis/player"); +} + +/// `[MQTT-4.7.1-1]` The multi-level wildcard `#` matches the parent topic +/// and any descendants. +#[conformance_test( + ids = ["MQTT-4.7.1-1"], + requires = ["transport.tcp"], +)] +async fn multi_level_wildcard_matches_all_descendants(sut: SutHandle) { + let subscriber = TestClient::connect_with_prefix(&sut, "ml-match") + .await + .unwrap(); + let subscription = subscriber + .subscribe("sport/#", SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "ml-pub") + .await + .unwrap(); + publisher.publish("sport", b"zero").await.unwrap(); + publisher.publish("sport/tennis", b"one").await.unwrap(); + publisher + .publish("sport/tennis/player", b"two") + .await + .unwrap(); + + assert!( + subscription.wait_for_messages(3, TIMEOUT).await, + "sport/# must match sport, sport/tennis, and sport/tennis/player" + ); + + let msgs = subscription.snapshot(); + let topics: Vec<&str> = msgs.iter().map(|m| m.topic.as_str()).collect(); + assert!(topics.contains(&"sport")); + assert!(topics.contains(&"sport/tennis")); + assert!(topics.contains(&"sport/tennis/player")); +} + +/// `[MQTT-4.5.0-1]` The server MUST deliver published messages to clients +/// that have matching subscriptions, and MUST NOT deliver to non-matching +/// subscribers. +#[conformance_test( + ids = ["MQTT-4.5.0-1"], + requires = ["transport.tcp"], +)] +async fn server_delivers_to_matching_subscribers(sut: SutHandle) { + let tag = unique_client_id("deliver"); + let topic = format!("deliver/{tag}"); + + let sub_exact = TestClient::connect_with_prefix(&sut, "sub-exact") + .await + .unwrap(); + let subscription_exact = sub_exact + .subscribe(&topic, SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + let filter_wild = format!("deliver/{tag}/+"); + let sub_wild = TestClient::connect_with_prefix(&sut, "sub-wild") + .await + .unwrap(); + let subscription_wild = sub_wild + .subscribe(&filter_wild, SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + let sub_non = TestClient::connect_with_prefix(&sut, "sub-non") + .await + .unwrap(); + let subscription_non = sub_non + .subscribe("other/topic", SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "pub-deliver") + .await + .unwrap(); + publisher.publish(&topic, b"match-payload").await.unwrap(); + + assert!( + subscription_exact.wait_for_messages(1, TIMEOUT).await, + "[MQTT-4.5.0-1] exact-match subscriber must receive the message" + ); + let msgs = subscription_exact.snapshot(); + assert_eq!(msgs[0].payload, b"match-payload"); + + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!( + subscription_wild.count(), + 0, + "wildcard subscriber for deliver/tag/+ must not match deliver/tag" + ); + assert_eq!( + subscription_non.count(), + 0, + "non-matching subscriber must not receive the message" + ); +} + +/// `[MQTT-4.6.0-5]` Message ordering MUST be preserved per topic for the +/// same `QoS` level. +#[conformance_test( + ids = ["MQTT-4.6.0-5"], + requires = ["transport.tcp"], +)] +async fn message_ordering_preserved_same_qos(sut: SutHandle) { + let tag = unique_client_id("order"); + let topic = format!("order/{tag}"); + + let subscriber = TestClient::connect_with_prefix(&sut, "sub-order") + .await + .unwrap(); + let subscription = subscriber + .subscribe(&topic, SubscribeOptions::default()) + .await + .expect("subscribe failed"); + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "pub-order") + .await + .unwrap(); + for i in 0u32..5 { + let payload = format!("msg-{i}"); + publisher.publish(&topic, payload.as_bytes()).await.unwrap(); + } + + assert!( + subscription.wait_for_messages(5, TIMEOUT).await, + "subscriber should receive all 5 messages" + ); + + let msgs = subscription.snapshot(); + for (i, msg) in msgs.iter().enumerate() { + let expected = format!("msg-{i}"); + assert_eq!( + msg.payload, + expected.as_bytes(), + "[MQTT-4.6.0-5] message {i} must be in order, expected {expected}, got {}", + String::from_utf8_lossy(&msg.payload) + ); + } +} + +/// `[MQTT-4.7.3-4]` Topic matching MUST NOT apply Unicode normalization. +/// U+00C5 (A-ring precomposed) and U+0041 U+030A (A + combining ring) +/// must be treated as different topics. +#[conformance_test( + ids = ["MQTT-4.7.3-4"], + requires = ["transport.tcp"], +)] +async fn topic_matching_no_unicode_normalization(sut: SutHandle) { + let tag = unique_client_id("unicode"); + + let precomposed = format!("uni/{tag}/\u{00C5}"); + let decomposed = format!("uni/{tag}/A\u{030A}"); + + let sub_pre = TestClient::connect_with_prefix(&sut, "sub-pre") + .await + .unwrap(); + let subscription_pre = sub_pre + .subscribe(&precomposed, SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + let sub_dec = TestClient::connect_with_prefix(&sut, "sub-dec") + .await + .unwrap(); + let subscription_dec = sub_dec + .subscribe(&decomposed, SubscribeOptions::default()) + .await + .expect("subscribe failed"); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let publisher = TestClient::connect_with_prefix(&sut, "pub-unicode") + .await + .unwrap(); + publisher + .publish(&precomposed, b"precomposed") + .await + .unwrap(); + + assert!( + subscription_pre.wait_for_messages(1, TIMEOUT).await, + "precomposed subscriber must receive message on precomposed topic" + ); + + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!( + subscription_dec.count(), + 0, + "[MQTT-4.7.3-4] decomposed subscriber must NOT receive message on precomposed topic \ + (no Unicode normalization)" + ); + + publisher.publish(&decomposed, b"decomposed").await.unwrap(); + + assert!( + subscription_dec.wait_for_messages(1, TIMEOUT).await, + "decomposed subscriber must receive message on decomposed topic" + ); + + tokio::time::sleep(Duration::from_millis(300)).await; + let pre_msgs = subscription_pre.snapshot(); + assert_eq!( + pre_msgs.len(), + 1, + "[MQTT-4.7.3-4] precomposed subscriber must NOT receive message on decomposed topic" + ); +} diff --git a/crates/mqtt5-conformance/tests/section6_websocket.rs b/crates/mqtt5-conformance/src/conformance_tests/section6_websocket.rs similarity index 78% rename from crates/mqtt5-conformance/tests/section6_websocket.rs rename to crates/mqtt5-conformance/src/conformance_tests/section6_websocket.rs index 93773540..83a3f50a 100644 --- a/crates/mqtt5-conformance/tests/section6_websocket.rs +++ b/crates/mqtt5-conformance/src/conformance_tests/section6_websocket.rs @@ -1,17 +1,25 @@ +//! Section 6 — MQTT over WebSocket. + +use crate::conformance_test; +use crate::raw_client::RawPacketBuilder; +use crate::sut::SutHandle; use futures_util::{SinkExt, StreamExt}; -use mqtt5_conformance::harness::ConformanceBroker; -use mqtt5_conformance::raw_client::RawPacketBuilder; +use std::net::SocketAddr; use std::time::Duration; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::Message; const TIMEOUT: Duration = Duration::from_secs(3); +fn ws_addr(sut: &SutHandle) -> SocketAddr { + sut.expect_ws_addr() +} + async fn ws_connect_raw( - broker: &ConformanceBroker, + sut: &SutHandle, ) -> tokio_tungstenite::WebSocketStream> { - let ws_port = broker.ws_port().expect("WebSocket not enabled"); - let url = format!("ws://127.0.0.1:{ws_port}/mqtt"); + let addr = ws_addr(sut); + let url = format!("ws://{addr}/mqtt"); let mut request = url.into_client_request().expect("valid request"); request.headers_mut().insert( "Sec-WebSocket-Protocol", @@ -24,13 +32,13 @@ async fn ws_connect_raw( } async fn ws_connect_raw_with_response( - broker: &ConformanceBroker, + sut: &SutHandle, ) -> ( tokio_tungstenite::WebSocketStream>, http::Response>>, ) { - let ws_port = broker.ws_port().expect("WebSocket not enabled"); - let url = format!("ws://127.0.0.1:{ws_port}/mqtt"); + let addr = ws_addr(sut); + let url = format!("ws://{addr}/mqtt"); let mut request = url.into_client_request().expect("valid request"); request.headers_mut().insert( "Sec-WebSocket-Protocol", @@ -43,10 +51,12 @@ async fn ws_connect_raw_with_response( /// `[MQTT-6.0.0-1]` Server MUST close the connection if a non-binary /// data frame is received. -#[tokio::test] -async fn websocket_text_frame_closes() { - let broker = ConformanceBroker::start_with_websocket().await; - let mut ws = ws_connect_raw(&broker).await; +#[conformance_test( + ids = ["MQTT-6.0.0-1"], + requires = ["transport.websocket"], +)] +async fn websocket_text_frame_closes(sut: SutHandle) { + let mut ws = ws_connect_raw(&sut).await; let connect_bytes = RawPacketBuilder::valid_connect("ws-text-test"); ws.send(Message::Binary(connect_bytes.into())) @@ -74,10 +84,12 @@ async fn websocket_text_frame_closes() { /// `[MQTT-6.0.0-2]` Server MUST NOT assume MQTT packets are aligned on /// WebSocket frame boundaries; partial packets across frames must work. -#[tokio::test] -async fn websocket_packet_across_frames() { - let broker = ConformanceBroker::start_with_websocket().await; - let mut ws = ws_connect_raw(&broker).await; +#[conformance_test( + ids = ["MQTT-6.0.0-2"], + requires = ["transport.websocket"], +)] +async fn websocket_packet_across_frames(sut: SutHandle) { + let mut ws = ws_connect_raw(&sut).await; let connect_bytes = RawPacketBuilder::valid_connect("ws-split-test"); let mid = connect_bytes.len() / 2; @@ -113,10 +125,12 @@ async fn websocket_packet_across_frames() { } /// `[MQTT-6.0.0-4]` Server MUST select "mqtt" as the WebSocket subprotocol. -#[tokio::test] -async fn websocket_subprotocol_is_mqtt() { - let broker = ConformanceBroker::start_with_websocket().await; - let (_ws, response) = ws_connect_raw_with_response(&broker).await; +#[conformance_test( + ids = ["MQTT-6.0.0-4"], + requires = ["transport.websocket"], +)] +async fn websocket_subprotocol_is_mqtt(sut: SutHandle) { + let (_ws, response) = ws_connect_raw_with_response(&sut).await; let subprotocol = response .headers() diff --git a/crates/mqtt5-conformance/src/lib.rs b/crates/mqtt5-conformance/src/lib.rs index c40119b7..27726825 100644 --- a/crates/mqtt5-conformance/src/lib.rs +++ b/crates/mqtt5-conformance/src/lib.rs @@ -10,7 +10,22 @@ #![warn(clippy::pedantic)] +extern crate self as mqtt5_conformance; + +pub mod assertions; +pub mod capabilities; +#[cfg(feature = "inprocess-fixture")] +pub mod conformance_tests; +#[cfg(feature = "inprocess-fixture")] pub mod harness; pub mod manifest; +pub mod packet_parser; pub mod raw_client; +pub mod registry; pub mod report; +pub mod skip; +pub mod sut; +pub mod test_client; +pub mod transport; + +pub use mqtt5_conformance_macros::conformance_test; diff --git a/crates/mqtt5-conformance/src/packet_parser.rs b/crates/mqtt5-conformance/src/packet_parser.rs new file mode 100644 index 00000000..5863bcb6 --- /dev/null +++ b/crates/mqtt5-conformance/src/packet_parser.rs @@ -0,0 +1,332 @@ +//! Typed views over raw MQTT v5.0 packet bytes. +//! +//! [`RawMqttClient`](crate::raw_client::RawMqttClient) hands out raw `Vec` +//! responses from the broker. Tests historically duplicated property-walking +//! logic inline; this module consolidates the variable-length integer decoding, +//! property-table walking, and per-packet field extraction into typed views +//! that borrow from the underlying byte buffer. +//! +//! All parsers are tolerant: a malformed or short buffer returns `None` rather +//! than panicking, so tests can express expectations as `expect("…")` with a +//! conformance-statement message attached. + +#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] + +const PROP_PAYLOAD_FORMAT_INDICATOR: u8 = 0x01; +const PROP_MESSAGE_EXPIRY_INTERVAL: u8 = 0x02; +const PROP_CONTENT_TYPE: u8 = 0x03; +const PROP_RESPONSE_TOPIC: u8 = 0x08; +const PROP_CORRELATION_DATA: u8 = 0x09; +const PROP_SUBSCRIPTION_IDENTIFIER: u8 = 0x0B; +const PROP_TOPIC_ALIAS: u8 = 0x23; +const PROP_USER_PROPERTY: u8 = 0x26; + +/// Decodes an MQTT variable-length integer starting at `start` in `data`. +/// +/// Returns the decoded value and the index of the byte immediately following +/// the encoded integer, or `None` if the buffer is short or the integer is +/// malformed (more than 4 continuation bytes). +#[must_use] +pub fn decode_variable_int(data: &[u8], start: usize) -> Option<(u32, usize)> { + let mut value: u32 = 0; + let mut shift = 0; + let mut idx = start; + loop { + if idx >= data.len() { + return None; + } + let byte = data[idx]; + idx += 1; + value |= u32::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Some((value, idx)); + } + shift += 7; + if shift > 21 { + return None; + } + } +} + +/// Decodes a length-prefixed UTF-8 string starting at `idx`. +/// +/// Returns the decoded string and the index of the byte immediately following +/// the string, or `None` on truncation or invalid UTF-8. +#[must_use] +pub fn decode_string(data: &[u8], idx: usize) -> Option<(String, usize)> { + if idx + 2 > data.len() { + return None; + } + let len = u16::from_be_bytes([data[idx], data[idx + 1]]) as usize; + let start = idx + 2; + let end = start + len; + if end > data.len() { + return None; + } + let s = String::from_utf8(data[start..end].to_vec()).ok()?; + Some((s, end)) +} + +/// A typed view over the property table of any MQTT v5.0 packet. +/// +/// Walks the variable-length property block once on construction and exposes +/// accessors for the most common fields. Unknown property IDs are tolerated +/// (the walker advances by the correct length for every defined property and +/// returns `None` for unrecognized IDs encountered during eager extraction). +#[derive(Debug, Clone, Default)] +pub struct ParsedProperties { + pub user_properties: Vec<(String, String)>, + pub subscription_identifiers: Vec, + pub topic_alias: Option, + pub content_type: Option, + pub response_topic: Option, + pub correlation_data: Option>, + pub payload_format_indicator: Option, + pub message_expiry_interval: Option, +} + +impl ParsedProperties { + /// Parses the property block at `[start..start+len]`. + /// + /// Returns `None` if any property's body is truncated or unknown. + #[must_use] + pub fn parse(data: &[u8], start: usize, len: usize) -> Option { + let end = start + len; + if end > data.len() { + return None; + } + let mut props = Self::default(); + let mut idx = start; + while idx < end { + let prop_id = data[idx]; + idx += 1; + match prop_id { + PROP_PAYLOAD_FORMAT_INDICATOR => { + if idx >= end { + return None; + } + props.payload_format_indicator = Some(data[idx]); + idx += 1; + } + PROP_MESSAGE_EXPIRY_INTERVAL => { + if idx + 4 > end { + return None; + } + props.message_expiry_interval = Some(u32::from_be_bytes([ + data[idx], + data[idx + 1], + data[idx + 2], + data[idx + 3], + ])); + idx += 4; + } + PROP_CONTENT_TYPE => { + let (s, next) = decode_string(data, idx)?; + props.content_type = Some(s); + idx = next; + } + PROP_RESPONSE_TOPIC => { + let (s, next) = decode_string(data, idx)?; + props.response_topic = Some(s); + idx = next; + } + PROP_CORRELATION_DATA => { + if idx + 2 > end { + return None; + } + let len = u16::from_be_bytes([data[idx], data[idx + 1]]) as usize; + let body_start = idx + 2; + let body_end = body_start + len; + if body_end > end { + return None; + } + props.correlation_data = Some(data[body_start..body_end].to_vec()); + idx = body_end; + } + PROP_SUBSCRIPTION_IDENTIFIER => { + let (val, next) = decode_variable_int(data, idx)?; + props.subscription_identifiers.push(val); + idx = next; + } + PROP_TOPIC_ALIAS => { + if idx + 2 > end { + return None; + } + props.topic_alias = Some(u16::from_be_bytes([data[idx], data[idx + 1]])); + idx += 2; + } + PROP_USER_PROPERTY => { + let (key, after_key) = decode_string(data, idx)?; + let (value, after_value) = decode_string(data, after_key)?; + props.user_properties.push((key, value)); + idx = after_value; + } + _ => return None, + } + } + Some(props) + } +} + +/// A typed view over a PUBLISH packet's bytes. +#[derive(Debug, Clone)] +pub struct ParsedPublish { + pub dup: bool, + pub qos: u8, + pub retain: bool, + pub topic: String, + pub packet_id: Option, + pub properties: ParsedProperties, + pub payload: Vec, +} + +impl ParsedPublish { + /// Parses a PUBLISH packet from raw bytes. + /// + /// Returns `None` if the first byte is not a PUBLISH (`0x3X`) or any field + /// is truncated. + #[must_use] + pub fn parse(data: &[u8]) -> Option { + if data.is_empty() || (data[0] & 0xF0) != 0x30 { + return None; + } + let first_byte = data[0]; + let dup = (first_byte & 0x08) != 0; + let qos = (first_byte >> 1) & 0x03; + let retain = (first_byte & 0x01) != 0; + let (remaining_len, body_start) = decode_variable_int(data, 1)?; + let body_end = body_start + remaining_len as usize; + if body_end > data.len() { + return None; + } + let mut idx = body_start; + let (topic, after_topic) = decode_string(data, idx)?; + idx = after_topic; + let packet_id = if qos > 0 { + if idx + 2 > body_end { + return None; + } + let pid = u16::from_be_bytes([data[idx], data[idx + 1]]); + idx += 2; + Some(pid) + } else { + None + }; + let (props_len, props_start) = decode_variable_int(data, idx)?; + let properties = ParsedProperties::parse(data, props_start, props_len as usize)?; + let payload_start = props_start + props_len as usize; + if payload_start > body_end { + return None; + } + let payload = data[payload_start..body_end].to_vec(); + Some(Self { + dup, + qos, + retain, + topic, + packet_id, + properties, + payload, + }) + } +} + +/// A typed view over a CONNACK packet's bytes. +#[derive(Debug, Clone)] +pub struct ParsedConnAck { + pub session_present: bool, + pub reason_code: u8, + pub properties: ParsedProperties, +} + +impl ParsedConnAck { + /// Parses a CONNACK packet from raw bytes. + /// + /// Returns `None` if the first byte is not `0x20` or any field is truncated. + #[must_use] + pub fn parse(data: &[u8]) -> Option { + if data.is_empty() || data[0] != 0x20 { + return None; + } + let (remaining_len, body_start) = decode_variable_int(data, 1)?; + let body_end = body_start + remaining_len as usize; + if body_end > data.len() || remaining_len < 2 { + return None; + } + let session_present = (data[body_start] & 0x01) != 0; + let reason_code = data[body_start + 1]; + let after_fixed = body_start + 2; + let properties = if after_fixed < body_end { + let (props_len, props_start) = decode_variable_int(data, after_fixed)?; + ParsedProperties::parse(data, props_start, props_len as usize)? + } else { + ParsedProperties::default() + }; + Some(Self { + session_present, + reason_code, + properties, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variable_int_decodes_single_byte() { + let data = [0x05]; + assert_eq!(decode_variable_int(&data, 0), Some((5, 1))); + } + + #[test] + fn variable_int_decodes_multi_byte() { + let data = [0x80, 0x01]; + assert_eq!(decode_variable_int(&data, 0), Some((128, 2))); + } + + #[test] + fn variable_int_rejects_overlong() { + let data = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + assert_eq!(decode_variable_int(&data, 0), None); + } + + #[test] + fn properties_parse_user_properties() { + let data = [ + 0x26, 0x00, 0x01, b'k', 0x00, 0x01, b'v', 0x26, 0x00, 0x01, b'a', 0x00, 0x01, b'b', + ]; + let props = ParsedProperties::parse(&data, 0, data.len()).unwrap(); + assert_eq!( + props.user_properties, + vec![ + ("k".to_owned(), "v".to_owned()), + ("a".to_owned(), "b".to_owned()), + ] + ); + } + + #[test] + fn properties_parse_multiple_subscription_identifiers() { + let data = [0x0B, 0x05, 0x0B, 0x07]; + let props = ParsedProperties::parse(&data, 0, data.len()).unwrap(); + assert_eq!(props.subscription_identifiers, vec![5, 7]); + } + + #[test] + fn parsed_publish_extracts_qos1_with_properties() { + let data = [ + 0x32, 0x10, 0x00, 0x04, b't', b'/', b'a', b'b', 0x00, 0x01, 0x02, 0x01, 0x01, b'h', + b'e', b'l', b'l', b'o', + ]; + let pkt = ParsedPublish::parse(&data).unwrap(); + assert!(!pkt.dup); + assert_eq!(pkt.qos, 1); + assert!(!pkt.retain); + assert_eq!(pkt.topic, "t/ab"); + assert_eq!(pkt.packet_id, Some(1)); + assert_eq!(pkt.properties.payload_format_indicator, Some(1)); + assert_eq!(pkt.payload, b"hello"); + } +} diff --git a/crates/mqtt5-conformance/src/raw_client.rs b/crates/mqtt5-conformance/src/raw_client.rs index 6e522015..99359190 100644 --- a/crates/mqtt5-conformance/src/raw_client.rs +++ b/crates/mqtt5-conformance/src/raw_client.rs @@ -1,13 +1,14 @@ -//! Raw TCP client for sending hand-crafted MQTT packets. +//! Raw transport-level client for sending hand-crafted MQTT packets. //! //! The normal [`MqttClient`](mqtt5::MqttClient) API enforces well-formed packets, //! making it impossible to test how the broker handles malformed input. -//! [`RawMqttClient`] operates at the TCP byte level, and [`RawPacketBuilder`] -//! constructs both valid and deliberately invalid MQTT v5.0 packets for -//! conformance edge-case testing. +//! [`RawMqttClient`] operates at the byte level over any [`Transport`], and +//! [`RawPacketBuilder`] constructs both valid and deliberately invalid MQTT +//! v5.0 packets for conformance edge-case testing. #![allow(clippy::cast_possible_truncation, clippy::missing_errors_doc)] +use crate::transport::{TcpTransport, Transport}; use bytes::{BufMut, BytesMut}; use mqtt5_protocol::packet::connack::ConnAckPacket; use mqtt5_protocol::packet::MqttPacket; @@ -15,26 +16,41 @@ use mqtt5_protocol::FixedHeader; use std::net::SocketAddr; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -/// A raw TCP client for sending arbitrary bytes to an MQTT broker. +/// A raw transport-level client for sending arbitrary bytes to an MQTT broker. /// /// Unlike [`MqttClient`](mqtt5::MqttClient), this client performs no packet -/// validation or encoding — it writes raw bytes directly to the TCP stream. -/// Use this for testing broker behavior with malformed, truncated, or +/// validation or encoding — it writes raw bytes directly to the underlying +/// transport. Use it for testing broker behavior with malformed, truncated, or /// protocol-violating packets. -pub struct RawMqttClient { - stream: TcpStream, +/// +/// The default transport is [`TcpTransport`]; future phases add TLS and +/// WebSocket variants so the same test code can drive a broker over every +/// protocol it speaks. +pub struct RawMqttClient { + stream: T, } -impl RawMqttClient { +impl RawMqttClient { /// Opens a raw TCP connection to the broker. pub async fn connect_tcp(addr: SocketAddr) -> std::io::Result { - let stream = TcpStream::connect(addr).await?; + let stream = TcpTransport::connect(addr).await?; Ok(Self { stream }) } +} + +impl RawMqttClient { + /// Wraps an already-established transport. + pub fn from_transport(stream: T) -> Self { + Self { stream } + } + + /// Consumes the client and returns the underlying transport. + pub fn into_transport(self) -> T { + self.stream + } - /// Sends raw bytes over the TCP connection. + /// Sends raw bytes over the transport. pub async fn send_raw(&mut self, data: &[u8]) -> std::io::Result<()> { self.stream.write_all(data).await } diff --git a/crates/mqtt5-conformance/src/registry.rs b/crates/mqtt5-conformance/src/registry.rs new file mode 100644 index 00000000..bcc09fed --- /dev/null +++ b/crates/mqtt5-conformance/src/registry.rs @@ -0,0 +1,56 @@ +//! Compile-time registry of conformance tests. +//! +//! Tests annotated with `#[mqtt5_conformance_macros::conformance_test]` +//! register themselves into the [`CONFORMANCE_TESTS`] distributed slice at +//! compile time. The CLI runner walks this slice to build a libtest-mimic +//! `Trial` for each test, evaluates the SUT capability requirements, and +//! either runs or marks the trial as skipped. +//! +//! The slice survives across crates: every crate that depends on +//! `mqtt5-conformance` and uses `#[conformance_test]` contributes entries. +//! The runner observes the union without any explicit registration step. + +use crate::capabilities::Requirement; +use crate::sut::SutHandle; +use std::future::Future; +use std::pin::Pin; + +pub use linkme; + +/// A future returned by a conformance-test runner closure. +/// +/// `'static` so the test can be queued onto a tokio runtime by the CLI +/// runner without lifetime gymnastics. +pub type TestFuture = Pin + Send + 'static>>; + +/// Type-erased entry-point a registered test exposes to the runner. +/// +/// The runner provides the constructed [`SutHandle`] (constructed once per +/// process for the in-process fixture, or shared for an external SUT) and +/// receives a future that drives the test body to completion. The future +/// panics on assertion failure, matching libtest-mimic's expectations. +pub type TestRunner = fn(SutHandle) -> TestFuture; + +/// A single conformance test registered at compile time. +/// +/// Constructed by the `#[conformance_test]` proc-macro and stored in the +/// [`CONFORMANCE_TESTS`] distributed slice. The runner reads these records +/// to build the test plan; it never instantiates them by hand. +#[derive(Debug, Clone, Copy)] +pub struct ConformanceTest { + pub name: &'static str, + pub module_path: &'static str, + pub file: &'static str, + pub line: u32, + pub ids: &'static [&'static str], + pub requires: &'static [Requirement], + pub runner: TestRunner, +} + +/// Distributed slice collecting every `#[conformance_test]` in the build. +/// +/// `linkme` arranges for the linker to concatenate per-test entries into +/// this slot at link time, with no startup-time registration cost. The CLI +/// runner iterates this slice to build the test plan. +#[linkme::distributed_slice] +pub static CONFORMANCE_TESTS: [ConformanceTest] = [..]; diff --git a/crates/mqtt5-conformance/src/skip.rs b/crates/mqtt5-conformance/src/skip.rs new file mode 100644 index 00000000..e2a89239 --- /dev/null +++ b/crates/mqtt5-conformance/src/skip.rs @@ -0,0 +1,126 @@ +//! Runtime capability gating. +//! +//! The conformance suite runs against every kind of broker, but no single +//! broker supports every optional feature in the MQTT v5 specification. Tests +//! that require optional features declare their preconditions as a list of +//! [`crate::capabilities::Requirement`] values; this module checks those +//! preconditions against a live [`crate::sut::SutHandle`] and reports the +//! first unmet requirement as a [`SkipReason`]. +//! +//! Use [`skip_if_missing`] at the top of a test: +//! +//! ```ignore +//! use mqtt5_conformance::capabilities::Requirement; +//! use mqtt5_conformance::skip::skip_if_missing; +//! +//! if let Err(reason) = skip_if_missing(sut, &[Requirement::SharedSubscriptionAvailable]) { +//! println!("skipping: {reason}"); +//! return; +//! } +//! ``` + +use crate::capabilities::Requirement; +use crate::sut::SutHandle; + +/// A description of why a test was skipped. +/// +/// Carries the unmet [`Requirement`] so reports can group skipped tests by +/// which capability is missing on the SUT. +#[derive(Debug, Clone)] +pub struct SkipReason { + pub requirement: Requirement, + pub message: String, +} + +impl std::fmt::Display for SkipReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for SkipReason {} + +/// Returns `Ok(())` if `sut` meets every requirement in `requirements`, +/// otherwise returns a [`SkipReason`] naming the first unmet requirement. +/// +/// # Errors +/// Returns a [`SkipReason`] describing the first unmet requirement. +pub fn skip_if_missing(sut: &SutHandle, requirements: &[Requirement]) -> Result<(), SkipReason> { + let capabilities = sut.capabilities(); + for &requirement in requirements { + if !requirement.matches(capabilities) { + return Err(SkipReason { + message: format!( + "SUT does not satisfy required capability `{}`", + requirement.label() + ), + requirement, + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::capabilities::Capabilities; + use crate::sut::{SutDescriptor, SutHandle}; + + fn external_sut(caps: Capabilities) -> SutHandle { + SutHandle::External(Box::new(SutDescriptor { + capabilities: caps, + ..SutDescriptor::default() + })) + } + + #[test] + fn skip_returns_ok_when_all_requirements_met() { + let caps = Capabilities::default(); + let sut = external_sut(caps); + assert!(skip_if_missing(&sut, &[Requirement::MinQos(1)]).is_ok()); + } + + #[test] + fn skip_returns_reason_when_requirement_missing() { + let caps = Capabilities { + max_qos: 0, + ..Capabilities::default() + }; + let sut = external_sut(caps); + let err = skip_if_missing(&sut, &[Requirement::MinQos(1)]).unwrap_err(); + assert_eq!(err.requirement, Requirement::MinQos(1)); + assert!(err.message.contains("max_qos >= 1")); + } + + #[test] + fn skip_returns_first_unmet_requirement() { + let caps = Capabilities { + shared_subscription_available: false, + ..Capabilities::default() + }; + let sut = external_sut(caps); + let err = skip_if_missing( + &sut, + &[ + Requirement::TransportTcp, + Requirement::TransportTls, + Requirement::SharedSubscriptionAvailable, + ], + ) + .unwrap_err(); + assert_eq!(err.requirement, Requirement::TransportTls); + } + + #[test] + fn skip_reason_is_display() { + let reason = SkipReason { + requirement: Requirement::Acl, + message: "SUT does not satisfy required capability `acl`".to_owned(), + }; + assert_eq!( + reason.to_string(), + "SUT does not satisfy required capability `acl`" + ); + } +} diff --git a/crates/mqtt5-conformance/src/sut.rs b/crates/mqtt5-conformance/src/sut.rs new file mode 100644 index 00000000..60f0ae4e --- /dev/null +++ b/crates/mqtt5-conformance/src/sut.rs @@ -0,0 +1,549 @@ +//! System Under Test abstraction. +//! +//! A System Under Test (SUT) is any MQTT v5 broker the conformance suite is +//! configured to validate. Operators describe their broker with a +//! [`SutDescriptor`] loaded from a `sut.toml` file; tests interact with it +//! through a [`SutHandle`] that is agnostic to whether the broker is running +//! in-process (for mqtt-lib's self-tests) or as an external process (for +//! third-party brokers). +//! +//! In-process operation is gated behind the `inprocess-fixture` feature. +//! When the feature is absent, the crate depends on nothing from `mqtt5` and +//! can be extracted into a standalone repository. + +use crate::capabilities::Capabilities; +use serde::Deserialize; +use std::net::SocketAddr; +use std::path::Path; + +/// Errors produced while loading or resolving a [`SutDescriptor`]. +#[derive(Debug)] +pub enum SutDescriptorError { + /// Failed to read the descriptor file from disk. + Io(std::io::Error), + /// Failed to parse the descriptor as TOML. + Parse(toml::de::Error), + /// Failed to parse a listed address as a `SocketAddr`. + InvalidAddress { + field: &'static str, + value: String, + source: std::net::AddrParseError, + }, +} + +impl std::fmt::Display for SutDescriptorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "failed to read SUT descriptor: {e}"), + Self::Parse(e) => write!(f, "failed to parse SUT descriptor: {e}"), + Self::InvalidAddress { + field, + value, + source, + } => { + write!(f, "invalid address in `{field}` = {value:?}: {source}") + } + } + } +} + +impl std::error::Error for SutDescriptorError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::Parse(e) => Some(e), + Self::InvalidAddress { source, .. } => Some(source), + } + } +} + +/// Harness-invocable hooks exposed by the operator. +/// +/// All fields are optional. An absent command means the hook is unsupported +/// and tests that require it will self-skip. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct SutHooks { + pub restart_command: String, + pub cleanup_command: String, +} + +/// A fully-parsed `sut.toml` descriptor. +/// +/// Describes how to reach the broker (addresses), what it claims to support +/// (capabilities), and how to manage its lifecycle (hooks). Produced from a +/// TOML file by [`SutDescriptor::from_file`] or [`SutDescriptor::from_str`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct SutDescriptor { + pub name: String, + pub address: String, + pub tls_address: String, + pub ws_address: String, + pub capabilities: Capabilities, + pub hooks: SutHooks, +} + +impl Default for SutDescriptor { + fn default() -> Self { + Self { + name: "unnamed-sut".to_owned(), + address: String::new(), + tls_address: String::new(), + ws_address: String::new(), + capabilities: Capabilities::default(), + hooks: SutHooks::default(), + } + } +} + +impl SutDescriptor { + /// Loads and parses a descriptor from a TOML file on disk. + /// + /// # Errors + /// Returns an error if the file cannot be read or the TOML is invalid. + pub fn from_file(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(SutDescriptorError::Io)?; + Self::from_str(&text) + } + + /// Parses a descriptor from a TOML string. + /// + /// # Errors + /// Returns an error if the TOML is invalid. + #[allow(clippy::should_implement_trait)] + pub fn from_str(text: &str) -> Result { + let mut descriptor: Self = toml::from_str(text).map_err(SutDescriptorError::Parse)?; + if !descriptor.hooks.restart_command.is_empty() { + descriptor.capabilities.hooks.restart = true; + } + if !descriptor.hooks.cleanup_command.is_empty() { + descriptor.capabilities.hooks.cleanup = true; + } + Ok(descriptor) + } + + /// Returns the plain-TCP `SocketAddr` if the descriptor declares one. + /// + /// Accepts both `mqtt://host:port` and `host:port` forms; the scheme is + /// stripped before parsing. + /// + /// # Errors + /// Returns an error if the address field is set but not a valid + /// `SocketAddr`. + pub fn tcp_socket_addr(&self) -> Result, SutDescriptorError> { + parse_optional_addr("address", &self.address) + } + + /// Returns the TLS `SocketAddr` if the descriptor declares one. + /// + /// # Errors + /// Returns an error if the field is set but not a valid `SocketAddr`. + pub fn tls_socket_addr(&self) -> Result, SutDescriptorError> { + parse_optional_addr("tls_address", &self.tls_address) + } + + /// Returns the WebSocket `SocketAddr` if the descriptor declares one. + /// + /// The WebSocket path is implied by the conformance test (`/mqtt`); only + /// the host:port portion is extracted here. + /// + /// # Errors + /// Returns an error if the field is set but not a valid `SocketAddr`. + pub fn ws_socket_addr(&self) -> Result, SutDescriptorError> { + parse_optional_addr("ws_address", &self.ws_address) + } +} + +fn parse_optional_addr( + field: &'static str, + value: &str, +) -> Result, SutDescriptorError> { + if value.is_empty() { + return Ok(None); + } + let stripped = strip_scheme(value); + let stripped = strip_ws_path(stripped); + stripped + .parse() + .map(Some) + .map_err(|source| SutDescriptorError::InvalidAddress { + field, + value: value.to_owned(), + source, + }) +} + +fn strip_scheme(addr: &str) -> &str { + for scheme in ["mqtts://", "mqtt://", "wss://", "ws://"] { + if let Some(rest) = addr.strip_prefix(scheme) { + return rest; + } + } + addr +} + +fn strip_ws_path(addr: &str) -> &str { + addr.split('/').next().unwrap_or(addr) +} + +/// A live handle to a running System Under Test. +/// +/// Exposes only what tests need: the capability matrix and per-transport +/// addresses. Tests never access `mqtt5::*` types through this handle, which +/// is what allows the crate to be extracted from its host repository. +/// +/// Both variants carry boxed payloads so the enum stays small regardless of +/// whether the `inprocess-fixture` feature is enabled. +pub enum SutHandle { + External(Box), + #[cfg(feature = "inprocess-fixture")] + InProcess(Box), +} + +impl SutHandle { + /// Returns the capability matrix of the underlying SUT. + #[must_use] + pub fn capabilities(&self) -> &Capabilities { + match self { + Self::External(descriptor) => &descriptor.capabilities, + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(fixture) => fixture.capabilities(), + } + } + + /// Returns the SUT's primary TCP `SocketAddr`, panicking with a clear + /// message if the SUT does not declare one or if the declared address is + /// malformed. Intended for test bodies that require a working TCP + /// endpoint; production code should use [`Self::tcp_socket_addr`]. + /// + /// # Panics + /// Panics if the SUT has no TCP address or if the declared address + /// cannot be parsed as a `SocketAddr`. + #[must_use] + pub fn expect_tcp_addr(&self) -> SocketAddr { + self.tcp_socket_addr() + .expect("sut tcp address must parse") + .expect("sut must declare a tcp address") + } + + /// Returns the SUT's primary TCP address, if any. + /// + /// # Errors + /// Returns an error if the external descriptor's TCP address is + /// malformed. + pub fn tcp_socket_addr(&self) -> Result, SutDescriptorError> { + match self { + Self::External(descriptor) => descriptor.tcp_socket_addr(), + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(fixture) => Ok(Some(fixture.tcp_socket_addr())), + } + } + + /// Returns the SUT's WebSocket address, if any. + /// + /// # Errors + /// Returns an error if the external descriptor's WebSocket address is + /// malformed. + pub fn ws_socket_addr(&self) -> Result, SutDescriptorError> { + match self { + Self::External(descriptor) => descriptor.ws_socket_addr(), + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(fixture) => Ok(fixture.ws_socket_addr()), + } + } + + /// Returns the SUT's WebSocket `SocketAddr`, panicking with a clear + /// message if the SUT does not declare one or if the declared address is + /// malformed. + /// + /// # Panics + /// Panics if the SUT has no WebSocket address or if the declared address + /// cannot be parsed as a `SocketAddr`. + #[must_use] + pub fn expect_ws_addr(&self) -> SocketAddr { + self.ws_socket_addr() + .expect("sut ws address must parse") + .expect("sut must declare a ws address") + } +} + +/// An in-process SUT fixture that owns a running broker. +/// +/// Wraps the existing [`crate::harness::ConformanceBroker`] so the two code +/// paths (in-process and external) converge behind the same [`SutHandle`] +/// enum. Only available when the `inprocess-fixture` feature is enabled. +#[cfg(feature = "inprocess-fixture")] +pub struct InProcessFixture { + broker: crate::harness::ConformanceBroker, + capabilities: Capabilities, +} + +#[cfg(feature = "inprocess-fixture")] +impl InProcessFixture { + /// Starts the default in-process broker and returns a fixture wrapping it. + pub async fn start() -> Self { + let broker = crate::harness::ConformanceBroker::start().await; + Self { + broker, + capabilities: inprocess_capabilities(false), + } + } + + /// Starts the in-process broker with WebSocket enabled. + pub async fn start_with_websocket() -> Self { + let broker = crate::harness::ConformanceBroker::start_with_websocket().await; + Self { + broker, + capabilities: inprocess_capabilities(true), + } + } + + /// Starts the in-process broker with a caller-supplied configuration. + /// + /// Intended for tests that need to exercise non-default broker settings + /// (for example, a reduced `server_receive_maximum`). Only available with + /// the `inprocess-fixture` feature; external SUTs express the same + /// constraints through `sut.toml` capabilities. + pub async fn start_with_config(config: mqtt5::broker::config::BrokerConfig) -> Self { + let broker = crate::harness::ConformanceBroker::start_with_config(config).await; + Self { + broker, + capabilities: inprocess_capabilities(false), + } + } + + /// Starts the in-process broker with a custom authentication provider. + /// + /// Used by enhanced-auth conformance tests that need to inject a + /// challenge-response provider. Only available with the + /// `inprocess-fixture` feature; external SUTs declare enhanced-auth + /// support through `sut.toml` capabilities. + pub async fn start_with_auth_provider( + provider: std::sync::Arc, + ) -> Self { + let broker = crate::harness::ConformanceBroker::start_with_auth_provider(provider).await; + Self { + broker, + capabilities: inprocess_capabilities(false), + } + } + + /// Returns the underlying [`crate::harness::ConformanceBroker`]. + /// + /// Phase F will strip direct uses of this accessor as tests migrate to + /// the [`SutHandle`]-driven API. + #[must_use] + pub fn broker(&self) -> &crate::harness::ConformanceBroker { + &self.broker + } + + /// Returns the in-process TCP socket address. + #[must_use] + pub fn tcp_socket_addr(&self) -> SocketAddr { + self.broker.socket_addr() + } + + /// Returns the in-process WebSocket socket address, if enabled. + #[must_use] + pub fn ws_socket_addr(&self) -> Option { + self.broker + .ws_port() + .map(|port| SocketAddr::from(([127, 0, 0, 1], port))) + } + + /// Returns this fixture's capability matrix. + #[must_use] + pub fn capabilities(&self) -> &Capabilities { + &self.capabilities + } +} + +/// Starts the default in-process broker and returns it wrapped in a +/// [`SutHandle::InProcess`] ready for use in conformance tests. +/// +/// This is the canonical one-liner used by every in-process integration +/// test: it eliminates per-file boilerplate that otherwise re-defines the +/// same `Box::new(InProcessFixture::start().await)` dance. +#[cfg(feature = "inprocess-fixture")] +pub async fn inprocess_sut() -> SutHandle { + SutHandle::InProcess(Box::new(InProcessFixture::start().await)) +} + +/// Starts an in-process broker with WebSocket enabled and returns it +/// wrapped in a [`SutHandle::InProcess`]. +#[cfg(feature = "inprocess-fixture")] +pub async fn inprocess_sut_with_websocket() -> SutHandle { + SutHandle::InProcess(Box::new(InProcessFixture::start_with_websocket().await)) +} + +/// Starts an in-process broker with a caller-supplied configuration and +/// returns it wrapped in a [`SutHandle::InProcess`]. +/// +/// Used by conformance tests that need to override default broker settings +/// (for example, to validate behaviour at a reduced receive-maximum). +#[cfg(feature = "inprocess-fixture")] +pub async fn inprocess_sut_with_config(config: mqtt5::broker::config::BrokerConfig) -> SutHandle { + SutHandle::InProcess(Box::new(InProcessFixture::start_with_config(config).await)) +} + +/// Starts an in-process broker with a caller-supplied auth provider and +/// returns it wrapped in a [`SutHandle::InProcess`]. +/// +/// Used by enhanced-auth conformance tests to inject a challenge-response +/// provider. Only available when the `inprocess-fixture` feature is enabled. +#[cfg(feature = "inprocess-fixture")] +pub async fn inprocess_sut_with_auth_provider( + provider: std::sync::Arc, +) -> SutHandle { + SutHandle::InProcess(Box::new( + InProcessFixture::start_with_auth_provider(provider).await, + )) +} + +#[cfg(feature = "inprocess-fixture")] +fn inprocess_capabilities(websocket: bool) -> Capabilities { + Capabilities { + transports: crate::capabilities::TransportSupport { + tcp: true, + tls: false, + websocket, + quic: false, + }, + injected_user_properties: vec!["x-mqtt-sender".to_owned(), "x-mqtt-client-id".to_owned()], + ..Capabilities::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_minimal_descriptor() { + let toml_str = r#" + name = "mosquitto-2.x" + address = "mqtt://127.0.0.1:1883" + "#; + let sut = SutDescriptor::from_str(toml_str).unwrap(); + assert_eq!(sut.name, "mosquitto-2.x"); + assert_eq!(sut.address, "mqtt://127.0.0.1:1883"); + assert_eq!( + sut.tcp_socket_addr().unwrap().unwrap().to_string(), + "127.0.0.1:1883" + ); + } + + #[test] + fn parse_descriptor_with_capabilities() { + let toml_str = r#" + name = "restricted" + address = "127.0.0.1:1883" + + [capabilities] + max_qos = 1 + retain_available = false + injected_user_properties = ["x-mqtt-sender"] + "#; + let sut = SutDescriptor::from_str(toml_str).unwrap(); + assert_eq!(sut.capabilities.max_qos, 1); + assert!(!sut.capabilities.retain_available); + assert_eq!( + sut.capabilities.injected_user_properties, + vec!["x-mqtt-sender".to_owned()] + ); + } + + #[test] + fn absent_addresses_return_none() { + let sut = SutDescriptor::default(); + assert_eq!(sut.tcp_socket_addr().unwrap(), None); + assert_eq!(sut.tls_socket_addr().unwrap(), None); + assert_eq!(sut.ws_socket_addr().unwrap(), None); + } + + #[test] + fn malformed_address_reports_field() { + let sut = SutDescriptor { + address: "not-an-address".to_owned(), + ..SutDescriptor::default() + }; + let err = sut.tcp_socket_addr().unwrap_err(); + match err { + SutDescriptorError::InvalidAddress { field, .. } => assert_eq!(field, "address"), + other => panic!("expected InvalidAddress, got {other:?}"), + } + } + + #[test] + fn strip_ws_path_removes_suffix() { + assert_eq!(strip_ws_path("127.0.0.1:8083/mqtt"), "127.0.0.1:8083"); + assert_eq!(strip_ws_path("127.0.0.1:8083"), "127.0.0.1:8083"); + } + + #[test] + fn fixture_inprocess_parses() { + let text = include_str!("../tests/fixtures/inprocess.toml"); + let descriptor = SutDescriptor::from_str(text).expect("inprocess fixture must parse"); + assert_eq!(descriptor.name, "mqtt5-inprocess"); + assert!(descriptor.capabilities.transports.tcp); + assert!(!descriptor.capabilities.transports.tls); + } + + #[test] + fn fixture_external_mqtt5_parses() { + let text = include_str!("../tests/fixtures/external-mqtt5.toml"); + let descriptor = SutDescriptor::from_str(text).expect("external-mqtt5 fixture must parse"); + assert_eq!(descriptor.name, "mqtt5-external"); + assert!(descriptor.capabilities.transports.tcp); + assert!(!descriptor.capabilities.transports.tls); + assert_eq!( + descriptor.tcp_socket_addr().unwrap().unwrap().to_string(), + "127.0.0.1:1883" + ); + } + + #[test] + fn fixture_mosquitto_parses() { + let text = include_str!("../tests/fixtures/mosquitto-2.x.toml"); + let descriptor = SutDescriptor::from_str(text).expect("mosquitto fixture must parse"); + assert_eq!(descriptor.name, "mosquitto-2.x"); + assert!(descriptor.capabilities.shared_subscription_available); + } + + #[test] + fn fixture_emqx_parses() { + let text = include_str!("../tests/fixtures/emqx.toml"); + let descriptor = SutDescriptor::from_str(text).expect("emqx fixture must parse"); + assert_eq!(descriptor.name, "emqx-5.x"); + assert!(descriptor.capabilities.transports.quic); + assert!(descriptor.capabilities.acl); + assert!(descriptor + .capabilities + .enhanced_auth + .methods + .iter() + .any(|m| m == "SCRAM-SHA-256")); + } + + #[test] + fn fixture_hivemq_ce_parses() { + let text = include_str!("../tests/fixtures/hivemq-ce.toml"); + let descriptor = SutDescriptor::from_str(text).expect("hivemq-ce fixture must parse"); + assert_eq!(descriptor.name, "hivemq-ce"); + assert!(descriptor.capabilities.transports.websocket); + assert!(!descriptor.capabilities.acl); + } + + #[test] + fn ws_address_parses_with_path() { + let sut = SutDescriptor { + ws_address: "ws://127.0.0.1:8083/mqtt".to_owned(), + ..SutDescriptor::default() + }; + assert_eq!( + sut.ws_socket_addr().unwrap().unwrap().to_string(), + "127.0.0.1:8083" + ); + } +} diff --git a/crates/mqtt5-conformance/src/test_client/inprocess.rs b/crates/mqtt5-conformance/src/test_client/inprocess.rs new file mode 100644 index 00000000..503eba21 --- /dev/null +++ b/crates/mqtt5-conformance/src/test_client/inprocess.rs @@ -0,0 +1,133 @@ +//! In-process backend for [`crate::test_client::TestClient`]. +//! +//! Wraps an [`mqtt5::MqttClient`] running against an in-process broker +//! fixture. Only compiled when the `inprocess-fixture` feature is enabled. + +use super::{MessageQueue, ReceivedMessage, Subscription, TestClientError}; +use crate::sut::SutHandle; +use mqtt5::MqttClient; +use mqtt5_protocol::types::{ConnectOptions, PublishOptions, SubscribeOptions}; +use std::sync::{Arc, Mutex}; + +/// In-process backing for [`crate::test_client::TestClient`]. +pub struct InProcessTestClient { + client: MqttClient, + client_id: String, + session_present: bool, +} + +impl InProcessTestClient { + /// Connects an in-process client against `sut` using the given + /// protocol-level connect options. + /// + /// # Errors + /// Returns an error if the SUT has no TCP address or if the underlying + /// `MqttClient` fails to connect. + pub async fn connect( + sut: &SutHandle, + options: ConnectOptions, + ) -> Result { + let addr = sut + .tcp_socket_addr()? + .ok_or(TestClientError::NoTcpAddress)?; + let address = format!("mqtt://{addr}"); + let client_id = options.client_id.clone(); + + let wrapper = mqtt5::ConnectOptions { + protocol_options: options.clone(), + ..mqtt5::ConnectOptions::default() + }; + let client = MqttClient::with_options(wrapper.clone()); + let result = Box::pin(client.connect_with_options(&address, wrapper)).await?; + Ok(Self { + client, + client_id, + session_present: result.session_present, + }) + } + + /// Returns the client id used to connect. + #[must_use] + pub fn client_id(&self) -> &str { + &self.client_id + } + + /// Returns the `SessionPresent` flag from the server's CONNACK. + #[must_use] + pub fn session_present(&self) -> bool { + self.session_present + } + + /// Publishes with the given [`PublishOptions`]. + /// + /// # Errors + /// Returns an error if the broker rejects the publish or the client + /// is disconnected. + pub async fn publish_with_options( + &self, + topic: &str, + payload: &[u8], + options: PublishOptions, + ) -> Result<(), TestClientError> { + self.client + .publish_with_options(topic, payload.to_vec(), options) + .await?; + Ok(()) + } + + /// Subscribes to `filter` and returns a [`Subscription`] handle. + /// + /// # Errors + /// Returns an error if the broker rejects the subscription. + /// + /// # Panics + /// Panics from the delivery callback if the internal mutex has been + /// poisoned. + pub async fn subscribe( + &self, + filter: &str, + options: SubscribeOptions, + ) -> Result { + let messages: MessageQueue = Arc::new(Mutex::new(Vec::new())); + let messages_cb = Arc::clone(&messages); + let (packet_id, granted_qos) = self + .client + .subscribe_with_options(filter, options, move |msg| { + messages_cb + .lock() + .unwrap() + .push(ReceivedMessage::from_message(msg)); + }) + .await?; + Ok(Subscription::new(messages, packet_id, granted_qos)) + } + + /// Unsubscribes from `filter`. + /// + /// # Errors + /// Returns an error if the broker rejects the unsubscribe. + pub async fn unsubscribe(&self, filter: &str) -> Result<(), TestClientError> { + self.client.unsubscribe(filter).await?; + Ok(()) + } + + /// Performs a normal DISCONNECT handshake. + /// + /// # Errors + /// Returns an error if the underlying client reports one while + /// disconnecting. + pub async fn disconnect(&self) -> Result<(), TestClientError> { + self.client.disconnect().await?; + Ok(()) + } + + /// Drops the TCP connection without sending a DISCONNECT packet. + /// + /// # Errors + /// Returns an error if the underlying client reports one while tearing + /// down the transport. + pub async fn disconnect_abnormally(&self) -> Result<(), TestClientError> { + self.client.disconnect_abnormally().await?; + Ok(()) + } +} diff --git a/crates/mqtt5-conformance/src/test_client/mod.rs b/crates/mqtt5-conformance/src/test_client/mod.rs new file mode 100644 index 00000000..788522e9 --- /dev/null +++ b/crates/mqtt5-conformance/src/test_client/mod.rs @@ -0,0 +1,599 @@ +//! High-level test client for conformance tests. +//! +//! [`TestClient`] hides the backing implementation behind a vendor-neutral +//! API. Tests construct a `TestClient` from a [`crate::sut::SutHandle`] and +//! the same test body runs against either the in-process fixture or an +//! external broker reached over raw TCP. +//! +//! The two backends are: +//! +//! * [`inprocess`] — wraps [`mqtt5::MqttClient`]; only compiled when the +//! `inprocess-fixture` feature is enabled. This is the path mqtt-lib's own +//! CI takes. +//! * [`raw`] — speaks MQTT v5 directly over a [`crate::transport::TcpTransport`] +//! with its own packet loop and `QoS` state machines. This is the path the +//! standalone conformance runner will take when validating third-party +//! brokers via a [`crate::sut::SutDescriptor`]. +//! +//! Both backends converge on the same [`ReceivedMessage`] / [`Subscription`] +//! types so tests remain agnostic of which implementation they run against. + +#[cfg(feature = "inprocess-fixture")] +pub mod inprocess; +pub mod raw; + +use crate::sut::{SutDescriptorError, SutHandle}; +use mqtt5_protocol::types::{ConnectOptions, Message, PublishOptions, QoS, SubscribeOptions}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use ulid::Ulid; + +/// Errors produced by [`TestClient`] operations. +#[derive(Debug)] +pub enum TestClientError { + /// The SUT has no TCP address and cannot be connected to. + NoTcpAddress, + /// Resolving the SUT's TCP address failed. + SutAddress(SutDescriptorError), + /// Raw transport I/O failure. + Io(std::io::Error), + /// Failure encoding or decoding an MQTT packet, or an error reported by + /// the underlying [`mqtt5::MqttClient`] (which re-exports the same + /// `MqttError` type from `mqtt5_protocol`). + Protocol(mqtt5_protocol::error::MqttError), + /// The broker returned an unexpected packet or failure code. + Unexpected(String), + /// An operation exceeded its timeout. + Timeout(&'static str), + /// The background reader task exited; the connection is gone. + Disconnected, +} + +impl std::fmt::Display for TestClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoTcpAddress => write!(f, "SUT has no TCP address configured"), + Self::SutAddress(e) => write!(f, "failed to resolve SUT address: {e}"), + Self::Io(e) => write!(f, "raw transport I/O error: {e}"), + Self::Protocol(e) => write!(f, "MQTT protocol error: {e}"), + Self::Unexpected(msg) => write!(f, "unexpected broker response: {msg}"), + Self::Timeout(op) => write!(f, "operation timed out: {op}"), + Self::Disconnected => write!(f, "connection closed by broker"), + } + } +} + +impl std::error::Error for TestClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::NoTcpAddress | Self::Unexpected(_) | Self::Timeout(_) | Self::Disconnected => { + None + } + Self::SutAddress(e) => Some(e), + Self::Io(e) => Some(e), + Self::Protocol(e) => Some(e), + } + } +} + +impl From for TestClientError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +impl From for TestClientError { + fn from(e: mqtt5_protocol::error::MqttError) -> Self { + Self::Protocol(e) + } +} + +impl From for TestClientError { + fn from(e: SutDescriptorError) -> Self { + Self::SutAddress(e) + } +} + +/// A message received by a [`Subscription`]. +/// +/// Includes the `dup` flag and `subscription_identifiers` list from +/// `MessageProperties`. Overlapping subscriptions can deliver multiple +/// identifiers per PUBLISH, so the field is a `Vec` rather than +/// `Option`. +#[derive(Debug, Clone)] +pub struct ReceivedMessage { + pub topic: String, + pub payload: Vec, + pub qos: QoS, + pub retain: bool, + pub dup: bool, + pub subscription_identifiers: Vec, + pub user_properties: Vec<(String, String)>, + pub content_type: Option, + pub response_topic: Option, + pub correlation_data: Option>, + pub message_expiry_interval: Option, + pub payload_format_indicator: Option, +} + +impl ReceivedMessage { + pub(crate) fn from_message(msg: Message) -> Self { + Self { + topic: msg.topic, + payload: msg.payload, + qos: msg.qos, + retain: msg.retain, + dup: false, + subscription_identifiers: msg.properties.subscription_identifiers, + user_properties: msg.properties.user_properties, + content_type: msg.properties.content_type, + response_topic: msg.properties.response_topic, + correlation_data: msg.properties.correlation_data, + message_expiry_interval: msg.properties.message_expiry_interval, + payload_format_indicator: msg.properties.payload_format_indicator, + } + } + + pub(crate) fn from_publish(packet: &mqtt5_protocol::packet::publish::PublishPacket) -> Self { + let msg: Message = packet.clone().into(); + let mut out = Self::from_message(msg); + out.dup = packet.dup; + out + } +} + +pub(crate) type MessageQueue = Arc>>; + +/// Handle to an active subscription with its own message queue. +/// +/// Each call to [`TestClient::subscribe`] returns a fresh `Subscription`; +/// tests pull delivered messages off it with +/// [`expect_publish`](Self::expect_publish) or inspect the snapshot via +/// [`snapshot`](Self::snapshot). +#[derive(Clone)] +pub struct Subscription { + messages: MessageQueue, + packet_id: u16, + granted_qos: QoS, +} + +impl Subscription { + pub(crate) fn new(messages: MessageQueue, packet_id: u16, granted_qos: QoS) -> Self { + Self { + messages, + packet_id, + granted_qos, + } + } + + /// Returns the SUBACK Packet Identifier echoed by the broker. + #[must_use] + pub fn packet_id(&self) -> u16 { + self.packet_id + } + + /// Returns the granted `QoS` from the broker's SUBACK. + #[must_use] + pub fn granted_qos(&self) -> QoS { + self.granted_qos + } + + /// Waits for the next received message, up to `timeout`. + /// + /// Returns `None` if the timeout elapses with no message delivered. The + /// returned message is removed from the subscription's queue so + /// subsequent calls see later messages. + /// + /// # Panics + /// Panics if the internal mutex has been poisoned by another thread + /// panicking while holding the lock. + pub async fn expect_publish(&self, timeout: Duration) -> Option { + let deadline = tokio::time::Instant::now() + timeout; + loop { + { + let mut queue = self.messages.lock().unwrap(); + if !queue.is_empty() { + return Some(queue.remove(0)); + } + } + if tokio::time::Instant::now() >= deadline { + return None; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + /// Waits until at least `count` messages have accumulated, returning + /// `true` if reached before `timeout`. + /// + /// # Panics + /// Panics if the internal mutex has been poisoned. + pub async fn wait_for_messages(&self, count: usize, timeout: Duration) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + loop { + if self.messages.lock().unwrap().len() >= count { + return true; + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + /// Returns a snapshot of all messages currently queued without + /// consuming them. + /// + /// # Panics + /// Panics if the internal mutex has been poisoned. + #[must_use] + pub fn snapshot(&self) -> Vec { + self.messages.lock().unwrap().clone() + } + + /// Returns the number of queued messages. + /// + /// # Panics + /// Panics if the internal mutex has been poisoned. + #[must_use] + pub fn count(&self) -> usize { + self.messages.lock().unwrap().len() + } + + /// Drops every queued message. + /// + /// # Panics + /// Panics if the internal mutex has been poisoned. + pub fn clear(&self) { + self.messages.lock().unwrap().clear(); + } +} + +/// High-level conformance test client. +/// +/// Dispatches to whichever backend is active for the [`SutHandle`] the +/// client was constructed from. The public methods mirror the vocabulary +/// used by conformance tests: `connect`, `publish`, `subscribe`, +/// `expect_publish`, `disconnect`. +pub enum TestClient { + #[cfg(feature = "inprocess-fixture")] + InProcess(inprocess::InProcessTestClient), + Raw(raw::RawTestClient), +} + +impl TestClient { + /// Connects a new client to `sut` using the given `client_id` and + /// default options. + /// + /// # Errors + /// Returns an error if the SUT has no TCP address or if the underlying + /// client fails to connect. + pub async fn connect(sut: &SutHandle, client_id: &str) -> Result { + let options = ConnectOptions::new(client_id); + Self::connect_with_options(sut, options).await + } + + /// Connects a new client with a random ULID-suffixed client id, using + /// `prefix` as the leading segment. + /// + /// # Errors + /// Returns an error if the SUT has no TCP address or if the underlying + /// client fails to connect. + pub async fn connect_with_prefix( + sut: &SutHandle, + prefix: &str, + ) -> Result { + let client_id = format!("conf-{prefix}-{}", Ulid::new()); + Self::connect(sut, &client_id).await + } + + /// Connects a new client to `sut` with the given [`ConnectOptions`]. + /// + /// # Errors + /// Returns an error if the SUT has no TCP address or if the underlying + /// client fails to connect. + pub async fn connect_with_options( + sut: &SutHandle, + options: ConnectOptions, + ) -> Result { + match sut { + #[cfg(feature = "inprocess-fixture")] + SutHandle::InProcess(_) => { + let client = inprocess::InProcessTestClient::connect(sut, options).await?; + Ok(Self::InProcess(client)) + } + SutHandle::External(_) => { + let addr = sut + .tcp_socket_addr()? + .ok_or(TestClientError::NoTcpAddress)?; + let client = raw::RawTestClient::connect(addr, options).await?; + Ok(Self::Raw(client)) + } + } + } + + /// Returns the client id used to connect. + #[must_use] + pub fn client_id(&self) -> &str { + match self { + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(c) => c.client_id(), + Self::Raw(c) => c.client_id(), + } + } + + /// Returns the `SessionPresent` flag from the server's CONNACK. + #[must_use] + pub fn session_present(&self) -> bool { + match self { + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(c) => c.session_present(), + Self::Raw(c) => c.session_present(), + } + } + + /// Publishes `payload` to `topic` with default options (`QoS` 0, no + /// retain). + /// + /// # Errors + /// Returns an error if the broker rejects the publish or the client + /// is disconnected. + pub async fn publish(&self, topic: &str, payload: &[u8]) -> Result<(), TestClientError> { + self.publish_with_options(topic, payload, PublishOptions::default()) + .await + } + + /// Publishes with the given [`PublishOptions`]. + /// + /// # Errors + /// Returns an error if the broker rejects the publish or the client + /// is disconnected. + pub async fn publish_with_options( + &self, + topic: &str, + payload: &[u8], + options: PublishOptions, + ) -> Result<(), TestClientError> { + match self { + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(c) => c.publish_with_options(topic, payload, options).await, + Self::Raw(c) => c.publish_with_options(topic, payload, options).await, + } + } + + /// Subscribes to `filter` and returns a [`Subscription`] handle. + /// + /// # Errors + /// Returns an error if the broker rejects the subscription. + pub async fn subscribe( + &self, + filter: &str, + options: SubscribeOptions, + ) -> Result { + match self { + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(c) => c.subscribe(filter, options).await, + Self::Raw(c) => c.subscribe(filter, options).await, + } + } + + /// Unsubscribes from `filter`. + /// + /// # Errors + /// Returns an error if the broker rejects the unsubscribe. + pub async fn unsubscribe(&self, filter: &str) -> Result<(), TestClientError> { + match self { + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(c) => c.unsubscribe(filter).await, + Self::Raw(c) => c.unsubscribe(filter).await, + } + } + + /// Performs a normal DISCONNECT handshake. + /// + /// # Errors + /// Returns an error if the underlying client reports one while + /// disconnecting. + pub async fn disconnect(&self) -> Result<(), TestClientError> { + match self { + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(c) => c.disconnect().await, + Self::Raw(c) => c.disconnect().await, + } + } + + /// Drops the TCP connection without sending a DISCONNECT packet. + /// + /// # Errors + /// Returns an error if the underlying client reports one while tearing + /// down the transport. + pub async fn disconnect_abnormally(&self) -> Result<(), TestClientError> { + match self { + #[cfg(feature = "inprocess-fixture")] + Self::InProcess(c) => c.disconnect_abnormally().await, + Self::Raw(c) => c.disconnect_abnormally().await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sut::{InProcessFixture, SutHandle}; + + #[tokio::test] + async fn inprocess_roundtrip() { + let fixture = InProcessFixture::start().await; + let sut = SutHandle::InProcess(Box::new(fixture)); + + let subscriber = TestClient::connect_with_prefix(&sut, "rt-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe( + "test/roundtrip", + SubscribeOptions { + qos: QoS::AtLeastOnce, + ..SubscribeOptions::default() + }, + ) + .await + .unwrap(); + + let publisher = TestClient::connect_with_prefix(&sut, "rt-pub") + .await + .unwrap(); + publisher.publish("test/roundtrip", b"hello").await.unwrap(); + + let msg = subscription + .expect_publish(Duration::from_secs(3)) + .await + .expect("message should arrive"); + assert_eq!(msg.topic, "test/roundtrip"); + assert_eq!(msg.payload, b"hello"); + + subscriber.disconnect().await.unwrap(); + publisher.disconnect().await.unwrap(); + } + + #[tokio::test] + async fn inprocess_expect_publish_times_out() { + let fixture = InProcessFixture::start().await; + let sut = SutHandle::InProcess(Box::new(fixture)); + + let subscriber = TestClient::connect_with_prefix(&sut, "timeout-sub") + .await + .unwrap(); + let subscription = subscriber + .subscribe("test/empty", SubscribeOptions::default()) + .await + .unwrap(); + + assert!(subscription + .expect_publish(Duration::from_millis(100)) + .await + .is_none()); + } + + #[tokio::test] + async fn raw_backend_roundtrip_against_inprocess_fixture() { + let fixture = InProcessFixture::start().await; + let addr = fixture.tcp_socket_addr(); + + let sub_opts = ConnectOptions::new(format!("conf-raw-sub-{}", Ulid::new())); + let subscriber = raw::RawTestClient::connect(addr, sub_opts).await.unwrap(); + let subscription = subscriber + .subscribe( + "raw/test/roundtrip", + SubscribeOptions { + qos: QoS::AtLeastOnce, + ..SubscribeOptions::default() + }, + ) + .await + .unwrap(); + + let pub_opts = ConnectOptions::new(format!("conf-raw-pub-{}", Ulid::new())); + let publisher = raw::RawTestClient::connect(addr, pub_opts).await.unwrap(); + publisher + .publish_with_options( + "raw/test/roundtrip", + b"hello-raw", + PublishOptions { + qos: QoS::AtLeastOnce, + ..PublishOptions::default() + }, + ) + .await + .unwrap(); + + let msg = subscription + .expect_publish(Duration::from_secs(3)) + .await + .expect("raw backend should receive published message"); + assert_eq!(msg.topic, "raw/test/roundtrip"); + assert_eq!(msg.payload, b"hello-raw"); + + subscriber.disconnect().await.unwrap(); + publisher.disconnect().await.unwrap(); + } + + #[tokio::test] + async fn raw_backend_qos2_roundtrip() { + let fixture = InProcessFixture::start().await; + let addr = fixture.tcp_socket_addr(); + + let sub_opts = ConnectOptions::new(format!("conf-raw-q2sub-{}", Ulid::new())); + let subscriber = raw::RawTestClient::connect(addr, sub_opts).await.unwrap(); + let subscription = subscriber + .subscribe( + "raw/test/qos2", + SubscribeOptions { + qos: QoS::ExactlyOnce, + ..SubscribeOptions::default() + }, + ) + .await + .unwrap(); + + let pub_opts = ConnectOptions::new(format!("conf-raw-q2pub-{}", Ulid::new())); + let publisher = raw::RawTestClient::connect(addr, pub_opts).await.unwrap(); + publisher + .publish_with_options( + "raw/test/qos2", + b"hello-qos2", + PublishOptions { + qos: QoS::ExactlyOnce, + ..PublishOptions::default() + }, + ) + .await + .unwrap(); + + let msg = subscription + .expect_publish(Duration::from_secs(3)) + .await + .expect("raw backend should receive QoS2 message"); + assert_eq!(msg.topic, "raw/test/qos2"); + assert_eq!(msg.payload, b"hello-qos2"); + + subscriber.disconnect().await.unwrap(); + publisher.disconnect().await.unwrap(); + } + + #[tokio::test] + async fn raw_backend_unsubscribe_stops_delivery() { + let fixture = InProcessFixture::start().await; + let addr = fixture.tcp_socket_addr(); + + let sub_opts = ConnectOptions::new(format!("conf-raw-unsub-{}", Ulid::new())); + let subscriber = raw::RawTestClient::connect(addr, sub_opts).await.unwrap(); + let subscription = subscriber + .subscribe("raw/test/unsub", SubscribeOptions::default()) + .await + .unwrap(); + + let pub_opts = ConnectOptions::new(format!("conf-raw-unsub-pub-{}", Ulid::new())); + let publisher = raw::RawTestClient::connect(addr, pub_opts).await.unwrap(); + publisher + .publish("raw/test/unsub", b"before") + .await + .unwrap(); + + assert!(subscription + .expect_publish(Duration::from_secs(3)) + .await + .is_some()); + + subscriber.unsubscribe("raw/test/unsub").await.unwrap(); + publisher.publish("raw/test/unsub", b"after").await.unwrap(); + + assert!(subscription + .expect_publish(Duration::from_millis(300)) + .await + .is_none()); + + subscriber.disconnect().await.unwrap(); + publisher.disconnect().await.unwrap(); + } +} diff --git a/crates/mqtt5-conformance/src/test_client/raw.rs b/crates/mqtt5-conformance/src/test_client/raw.rs new file mode 100644 index 00000000..c821582a --- /dev/null +++ b/crates/mqtt5-conformance/src/test_client/raw.rs @@ -0,0 +1,695 @@ +//! Raw-TCP backend for [`crate::test_client::TestClient`]. +//! +//! Speaks MQTT v5 directly over a [`crate::transport::TcpTransport`] with +//! its own packet reader task and `QoS` state machines. This is the path the +//! standalone conformance runner takes when validating third-party brokers +//! described by a [`crate::sut::SutDescriptor`]. +//! +//! Both the in-process backend and the raw backend converge on the same +//! [`crate::test_client::ReceivedMessage`] / [`crate::test_client::Subscription`] +//! types, so test bodies remain agnostic of which one they execute against. + +use super::{MessageQueue, ReceivedMessage, Subscription, TestClientError}; +use crate::transport::TcpTransport; +use bytes::BytesMut; +use mqtt5_protocol::packet::connect::ConnectPacket; +use mqtt5_protocol::packet::disconnect::DisconnectPacket; +use mqtt5_protocol::packet::publish::PublishPacket; +use mqtt5_protocol::packet::pubrel::PubRelPacket; +use mqtt5_protocol::packet::subscribe::{SubscribePacket, TopicFilter}; +use mqtt5_protocol::packet::subscribe_options::{ + RetainHandling as PacketRetainHandling, SubscriptionOptions, +}; +use mqtt5_protocol::packet::unsubscribe::UnsubscribePacket; +use mqtt5_protocol::packet::{ + puback::PubAckPacket, pubcomp::PubCompPacket, pubrec::PubRecPacket, FixedHeader, MqttPacket, + Packet, +}; +use mqtt5_protocol::types::{ + ConnectOptions, PublishOptions, QoS, RetainHandling, SubscribeOptions, +}; +use mqtt5_protocol::validation::{strip_shared_subscription_prefix, topic_matches_filter}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::sync::{oneshot, Mutex as AsyncMutex}; +use tokio::task::JoinHandle; + +const READ_BUF_INITIAL: usize = 8 * 1024; +const ACK_TIMEOUT: Duration = Duration::from_secs(10); + +type AckMap = Arc>>>; +type SubscriptionList = Arc>>; +type ConnAckSlot = Arc>>; + +#[derive(Debug, Clone, Copy)] +struct ConnAckInfo { + session_present: bool, +} + +#[derive(Debug)] +enum AckOutcome { + SubAck(Vec), + UnsubAck(Vec), + PubAck(mqtt5_protocol::types::ReasonCode), + PubRec(mqtt5_protocol::types::ReasonCode), + PubComp(mqtt5_protocol::types::ReasonCode), +} + +struct SubscriptionEntry { + filter: String, + queue: MessageQueue, +} + +struct SharedState { + writer: AsyncMutex>, + pending_acks: AckMap, + subscriptions: SubscriptionList, + connack: ConnAckSlot, +} + +/// Raw-TCP backing for [`crate::test_client::TestClient`]. +pub struct RawTestClient { + client_id: String, + next_packet_id: AtomicU16, + shared: Arc, + reader_task: StdMutex>>, + session_present: bool, +} + +impl RawTestClient { + /// Connects a raw client to `addr` using the given protocol-level + /// connect options. + /// + /// # Errors + /// Returns an error if the TCP connection fails, the CONNECT handshake + /// is rejected, or the broker returns a malformed CONNACK. + /// + /// # Panics + /// Panics if the internal reader-handle mutex is poisoned, which can + /// only happen if a prior holder panicked while holding the lock. + pub async fn connect( + addr: SocketAddr, + options: ConnectOptions, + ) -> Result { + let client_id = options.client_id.clone(); + let stream = TcpTransport::connect(addr).await?.into_inner(); + let (read_half, mut write_half) = stream.into_split(); + + let connect = ConnectPacket::new(options); + let mut buf = BytesMut::with_capacity(64); + connect.encode(&mut buf)?; + write_half.write_all(&buf).await?; + write_half.flush().await?; + + let pending_acks: AckMap = Arc::new(StdMutex::new(HashMap::new())); + let subscriptions: SubscriptionList = Arc::new(StdMutex::new(Vec::new())); + let connack: ConnAckSlot = Arc::new(StdMutex::new(None)); + let shared = Arc::new(SharedState { + writer: AsyncMutex::new(Some(write_half)), + pending_acks: Arc::clone(&pending_acks), + subscriptions: Arc::clone(&subscriptions), + connack: Arc::clone(&connack), + }); + + let reader_shared = Arc::clone(&shared); + let reader_task = tokio::spawn(async move { + let _ = reader_loop(read_half, reader_shared).await; + }); + + let reader_handle_for_wait = StdMutex::new(Some(reader_task)); + let session_present = Self::await_connack_static(&shared, &reader_handle_for_wait).await?; + let reader_task = reader_handle_for_wait.lock().unwrap().take(); + + Ok(Self { + client_id, + next_packet_id: AtomicU16::new(1), + shared, + reader_task: StdMutex::new(reader_task), + session_present, + }) + } + + async fn await_connack_static( + shared: &Arc, + reader_task: &StdMutex>>, + ) -> Result { + let deadline = tokio::time::Instant::now() + ACK_TIMEOUT; + loop { + { + let guard = shared.connack.lock().unwrap(); + if let Some(info) = *guard { + return Ok(info.session_present); + } + } + if tokio::time::Instant::now() >= deadline { + return Err(TestClientError::Timeout("connack")); + } + if reader_task + .lock() + .unwrap() + .as_ref() + .is_some_and(JoinHandle::is_finished) + { + return Err(TestClientError::Disconnected); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + + /// Returns the client id used to connect. + #[must_use] + pub fn client_id(&self) -> &str { + &self.client_id + } + + /// Returns the `SessionPresent` flag from the server's CONNACK. + #[must_use] + pub fn session_present(&self) -> bool { + self.session_present + } + + fn allocate_packet_id(&self) -> u16 { + loop { + let id = self.next_packet_id.fetch_add(1, Ordering::Relaxed); + if id != 0 { + return id; + } + } + } + + async fn write_packet(&self, packet: &P) -> Result<(), TestClientError> { + let mut buf = BytesMut::with_capacity(64); + packet.encode(&mut buf)?; + let mut writer = self.shared.writer.lock().await; + let stream = writer.as_mut().ok_or(TestClientError::Disconnected)?; + stream.write_all(&buf).await?; + stream.flush().await?; + Ok(()) + } + + async fn await_ack( + &self, + packet_id: u16, + op: &'static str, + ) -> Result { + let (tx, rx) = oneshot::channel(); + { + let mut acks = self.shared.pending_acks.lock().unwrap(); + acks.insert(packet_id, tx); + } + match tokio::time::timeout(ACK_TIMEOUT, rx).await { + Ok(Ok(outcome)) => Ok(outcome), + Ok(Err(_)) => Err(TestClientError::Disconnected), + Err(_) => { + let mut acks = self.shared.pending_acks.lock().unwrap(); + acks.remove(&packet_id); + Err(TestClientError::Timeout(op)) + } + } + } + + /// Publishes `payload` to `topic` with default options (`QoS` 0, no retain). + /// + /// # Errors + /// Returns an error if the broker rejects the publish or the connection + /// is closed. + pub async fn publish(&self, topic: &str, payload: &[u8]) -> Result<(), TestClientError> { + self.publish_with_options(topic, payload, PublishOptions::default()) + .await + } + + /// Publishes with the given [`PublishOptions`]. + /// + /// # Errors + /// Returns an error if the broker rejects the publish, the connection + /// is closed, or the `QoS` handshake times out. + pub async fn publish_with_options( + &self, + topic: &str, + payload: &[u8], + options: PublishOptions, + ) -> Result<(), TestClientError> { + let qos = options.qos; + let packet_id = match qos { + QoS::AtMostOnce => 0, + QoS::AtLeastOnce | QoS::ExactlyOnce => self.allocate_packet_id(), + }; + + let mut publish = PublishPacket::new(topic, payload.to_vec(), qos); + if qos != QoS::AtMostOnce { + publish = publish.with_packet_id(packet_id); + } + if options.retain { + publish = publish.with_retain(true); + } + if let Some(expiry) = options.properties.message_expiry_interval { + publish.properties.set_message_expiry_interval(expiry); + } + if let Some(format) = options.properties.payload_format_indicator { + publish.properties.set_payload_format_indicator(format); + } + if let Some(content_type) = options.properties.content_type { + publish.properties.set_content_type(content_type); + } + if let Some(response_topic) = options.properties.response_topic { + publish.properties.set_response_topic(response_topic); + } + if let Some(correlation) = options.properties.correlation_data { + publish.properties.set_correlation_data(correlation.into()); + } + for (key, value) in options.properties.user_properties { + publish.properties.add_user_property(key, value); + } + if let Some(topic_alias) = options.properties.topic_alias { + publish.properties.set_topic_alias(topic_alias); + } + for sub_id in options.properties.subscription_identifiers { + publish.properties.set_subscription_identifier(sub_id); + } + + self.write_packet(&publish).await?; + + match qos { + QoS::AtMostOnce => Ok(()), + QoS::AtLeastOnce => match self.await_ack(packet_id, "puback").await? { + AckOutcome::PubAck(rc) => { + if rc.is_success() { + Ok(()) + } else { + Err(TestClientError::Unexpected(format!( + "PUBACK reason code {rc:?}" + ))) + } + } + other => Err(TestClientError::Unexpected(format!( + "expected PUBACK, got {other:?}" + ))), + }, + QoS::ExactlyOnce => { + match self.await_ack(packet_id, "pubrec").await? { + AckOutcome::PubRec(rc) if rc.is_success() => {} + AckOutcome::PubRec(rc) => { + return Err(TestClientError::Unexpected(format!( + "PUBREC reason code {rc:?}" + ))); + } + other => { + return Err(TestClientError::Unexpected(format!( + "expected PUBREC, got {other:?}" + ))); + } + } + let pubrel = PubRelPacket::new(packet_id); + self.write_packet(&pubrel).await?; + match self.await_ack(packet_id, "pubcomp").await? { + AckOutcome::PubComp(rc) if rc.is_success() => Ok(()), + AckOutcome::PubComp(rc) => Err(TestClientError::Unexpected(format!( + "PUBCOMP reason code {rc:?}" + ))), + other => Err(TestClientError::Unexpected(format!( + "expected PUBCOMP, got {other:?}" + ))), + } + } + } + } + + /// Subscribes to `filter` and returns a [`Subscription`] handle. + /// + /// # Errors + /// Returns an error if the broker rejects the subscription or returns + /// a non-success SUBACK reason code. + /// + /// # Panics + /// Panics if the internal subscription mutex has been poisoned. + pub async fn subscribe( + &self, + filter: &str, + options: SubscribeOptions, + ) -> Result { + let messages: MessageQueue = Arc::new(StdMutex::new(Vec::new())); + let matching_filter = strip_shared_subscription_prefix(filter); + { + let mut subs = self.shared.subscriptions.lock().unwrap(); + subs.push(SubscriptionEntry { + filter: matching_filter.to_string(), + queue: Arc::clone(&messages), + }); + } + + let packet_id = self.allocate_packet_id(); + let sub_options = SubscriptionOptions { + qos: options.qos, + no_local: options.no_local, + retain_as_published: options.retain_as_published, + retain_handling: convert_retain_handling(options.retain_handling), + }; + let topic_filter = TopicFilter::with_options(filter.to_string(), sub_options); + let mut subscribe = SubscribePacket::new(packet_id).add_filter_with_options(topic_filter); + if let Some(sub_id) = options.subscription_identifier { + subscribe = subscribe.with_subscription_identifier(sub_id); + } + + self.write_packet(&subscribe).await?; + + let outcome = self.await_ack(packet_id, "suback").await?; + let granted_qos = match outcome { + AckOutcome::SubAck(codes) => { + let first = codes.first().copied().ok_or_else(|| { + TestClientError::Unexpected("SUBACK with empty reason codes".into()) + })?; + first.granted_qos().ok_or_else(|| { + TestClientError::Unexpected(format!("SUBACK reason code {first:?}")) + })? + } + other => { + return Err(TestClientError::Unexpected(format!( + "expected SUBACK, got {other:?}" + ))); + } + }; + + Ok(Subscription::new(messages, packet_id, granted_qos)) + } + + /// Unsubscribes from `filter`. + /// + /// # Errors + /// Returns an error if the broker rejects the unsubscribe or returns + /// a non-success UNSUBACK reason code. + /// + /// # Panics + /// Panics if the internal subscription mutex has been poisoned. + pub async fn unsubscribe(&self, filter: &str) -> Result<(), TestClientError> { + { + let mut subs = self.shared.subscriptions.lock().unwrap(); + subs.retain(|entry| entry.filter != filter); + } + + let packet_id = self.allocate_packet_id(); + let unsubscribe = UnsubscribePacket::new(packet_id).add_filter(filter.to_string()); + self.write_packet(&unsubscribe).await?; + + match self.await_ack(packet_id, "unsuback").await? { + AckOutcome::UnsubAck(codes) => { + if codes + .iter() + .all(mqtt5_protocol::packet::unsuback::UnsubAckReasonCode::is_success) + { + Ok(()) + } else { + Err(TestClientError::Unexpected(format!( + "UNSUBACK reason codes {codes:?}" + ))) + } + } + other => Err(TestClientError::Unexpected(format!( + "expected UNSUBACK, got {other:?}" + ))), + } + } + + /// Performs a normal DISCONNECT handshake. + /// + /// # Errors + /// Returns an error if writing the DISCONNECT packet fails. + pub async fn disconnect(&self) -> Result<(), TestClientError> { + let disconnect = DisconnectPacket::normal(); + let mut buf = BytesMut::with_capacity(8); + disconnect.encode(&mut buf)?; + let mut writer = self.shared.writer.lock().await; + if let Some(stream) = writer.as_mut() { + let _ = stream.write_all(&buf).await; + let _ = stream.flush().await; + let _ = stream.shutdown().await; + } + *writer = None; + drop(writer); + self.stop_reader(); + Ok(()) + } + + /// Drops the TCP connection without sending a DISCONNECT packet. + /// + /// # Errors + /// Never returns an error in the current implementation. + pub async fn disconnect_abnormally(&self) -> Result<(), TestClientError> { + let mut writer = self.shared.writer.lock().await; + if let Some(mut stream) = writer.take() { + let _ = stream.shutdown().await; + } + drop(writer); + self.stop_reader(); + Ok(()) + } + + fn stop_reader(&self) { + if let Some(handle) = self.reader_task.lock().unwrap().take() { + handle.abort(); + } + } +} + +impl Drop for RawTestClient { + fn drop(&mut self) { + if let Ok(mut writer) = self.shared.writer.try_lock() { + drop(writer.take()); + } + self.stop_reader(); + } +} + +fn convert_retain_handling(handling: RetainHandling) -> PacketRetainHandling { + match handling { + RetainHandling::SendAtSubscribe => PacketRetainHandling::SendAtSubscribe, + RetainHandling::SendIfNew => PacketRetainHandling::SendAtSubscribeIfNew, + RetainHandling::DontSend => PacketRetainHandling::DoNotSend, + } +} + +async fn reader_loop( + mut reader: OwnedReadHalf, + shared: Arc, +) -> Result<(), TestClientError> { + let mut buf = BytesMut::with_capacity(READ_BUF_INITIAL); + loop { + let Ok(packet) = read_packet(&mut reader, &mut buf).await else { + drop_pending_acks(&shared.pending_acks); + return Ok(()); + }; + if let Err(err) = dispatch_packet(packet, &shared).await { + drop_pending_acks(&shared.pending_acks); + return Err(err); + } + } +} + +async fn read_packet( + reader: &mut OwnedReadHalf, + buf: &mut BytesMut, +) -> Result { + loop { + if let Some(packet) = try_parse_packet(buf)? { + return Ok(packet); + } + let mut chunk = [0u8; 4096]; + let n = reader.read(&mut chunk).await?; + if n == 0 { + return Err(TestClientError::Disconnected); + } + buf.extend_from_slice(&chunk[..n]); + } +} + +fn try_parse_packet(buf: &mut BytesMut) -> Result, TestClientError> { + if buf.is_empty() { + return Ok(None); + } + let mut peek = &buf[..]; + let fixed_header = match FixedHeader::decode(&mut peek) { + Ok(fh) => fh, + Err(mqtt5_protocol::error::MqttError::MalformedPacket(msg)) + if msg.contains("fixed header") || msg.contains("Variable") => + { + return Ok(None); + } + Err(err) => return Err(err.into()), + }; + let header_len = buf.len() - peek.len(); + let total = header_len + fixed_header.remaining_length as usize; + if buf.len() < total { + return Ok(None); + } + let _ = buf.split_to(header_len); + let mut body = buf.split_to(fixed_header.remaining_length as usize); + let packet = Packet::decode_from_body(fixed_header.packet_type, &fixed_header, &mut body)?; + Ok(Some(packet)) +} + +async fn dispatch_packet(packet: Packet, shared: &SharedState) -> Result<(), TestClientError> { + match packet { + Packet::ConnAck(connack) => { + { + let mut slot = shared.connack.lock().unwrap(); + *slot = Some(ConnAckInfo { + session_present: connack.session_present, + }); + } + if !connack.reason_code.is_success() { + return Err(TestClientError::Unexpected(format!( + "CONNACK reason code {:?}", + connack.reason_code + ))); + } + } + Packet::Publish(publish) => { + handle_publish(publish, shared).await?; + } + Packet::PubAck(puback) => { + complete_ack( + &shared.pending_acks, + puback.packet_id, + AckOutcome::PubAck(puback.reason_code), + ); + } + Packet::PubRec(pubrec) => { + complete_ack( + &shared.pending_acks, + pubrec.packet_id, + AckOutcome::PubRec(pubrec.reason_code), + ); + } + Packet::PubRel(pubrel) => { + let pubcomp = PubCompPacket::new(pubrel.packet_id); + send_raw(shared, &pubcomp).await?; + } + Packet::PubComp(pubcomp) => { + complete_ack( + &shared.pending_acks, + pubcomp.packet_id, + AckOutcome::PubComp(pubcomp.reason_code), + ); + } + Packet::SubAck(suback) => { + complete_ack( + &shared.pending_acks, + suback.packet_id, + AckOutcome::SubAck(suback.reason_codes), + ); + } + Packet::UnsubAck(unsuback) => { + complete_ack( + &shared.pending_acks, + unsuback.packet_id, + AckOutcome::UnsubAck(unsuback.reason_codes), + ); + } + Packet::Disconnect(_) => return Err(TestClientError::Disconnected), + _ => {} + } + Ok(()) +} + +async fn handle_publish( + publish: PublishPacket, + shared: &SharedState, +) -> Result<(), TestClientError> { + let received = ReceivedMessage::from_publish(&publish); + let topic = publish.topic_name.clone(); + let qos = publish.qos; + let packet_id = publish.packet_id; + + let entries: Vec = { + let subs = shared.subscriptions.lock().unwrap(); + subs.iter() + .filter(|entry| topic_matches_filter(&topic, &entry.filter)) + .map(|entry| Arc::clone(&entry.queue)) + .collect() + }; + + for queue in entries { + queue.lock().unwrap().push(received.clone()); + } + + match qos { + QoS::AtMostOnce => {} + QoS::AtLeastOnce => { + if let Some(id) = packet_id { + let puback = PubAckPacket::new(id); + send_raw(shared, &puback).await?; + } + } + QoS::ExactlyOnce => { + if let Some(id) = packet_id { + let pubrec = PubRecPacket::new(id); + send_raw(shared, &pubrec).await?; + } + } + } + + Ok(()) +} + +async fn send_raw(shared: &SharedState, packet: &P) -> Result<(), TestClientError> { + let mut buf = BytesMut::with_capacity(32); + packet.encode(&mut buf)?; + let mut writer = shared.writer.lock().await; + let stream = writer.as_mut().ok_or(TestClientError::Disconnected)?; + stream.write_all(&buf).await?; + stream.flush().await?; + Ok(()) +} + +fn complete_ack(acks: &AckMap, packet_id: u16, outcome: AckOutcome) { + if let Some(tx) = acks.lock().unwrap().remove(&packet_id) { + let _ = tx.send(outcome); + } +} + +fn drop_pending_acks(acks: &AckMap) { + let mut guard = acks.lock().unwrap(); + guard.clear(); +} + +#[cfg(all(test, feature = "inprocess-fixture"))] +mod tests { + use super::*; + use crate::sut::InProcessFixture; + use std::time::Duration; + use ulid::Ulid; + + #[tokio::test] + async fn raw_qos0_roundtrip() { + let fixture = InProcessFixture::start().await; + let addr = fixture.tcp_socket_addr(); + + let sub_opts = ConnectOptions::new(format!("conf-raw-test-sub-{}", Ulid::new())); + let subscriber = RawTestClient::connect(addr, sub_opts).await.unwrap(); + let subscription = subscriber + .subscribe("raw/test/qos0", SubscribeOptions::default()) + .await + .unwrap(); + + let pub_opts = ConnectOptions::new(format!("conf-raw-test-pub-{}", Ulid::new())); + let publisher = RawTestClient::connect(addr, pub_opts).await.unwrap(); + publisher + .publish_with_options("raw/test/qos0", b"hello", PublishOptions::default()) + .await + .unwrap(); + + let msg = subscription + .expect_publish(Duration::from_secs(3)) + .await + .expect("message should arrive"); + assert_eq!(msg.topic, "raw/test/qos0"); + assert_eq!(msg.payload, b"hello"); + + subscriber.disconnect().await.unwrap(); + publisher.disconnect().await.unwrap(); + } +} diff --git a/crates/mqtt5-conformance/src/transport.rs b/crates/mqtt5-conformance/src/transport.rs new file mode 100644 index 00000000..4c4e4529 --- /dev/null +++ b/crates/mqtt5-conformance/src/transport.rs @@ -0,0 +1,75 @@ +//! Transport abstraction for raw MQTT packet I/O. +//! +//! [`RawMqttClient`](crate::raw_client::RawMqttClient) is generic over any byte +//! stream that implements the [`Transport`] trait. Today this covers +//! [`TcpTransport`]; future phases add TLS and WebSocket implementations so the +//! conformance suite can drive a broker over every protocol from the same +//! test code. + +#![allow(clippy::missing_errors_doc)] + +use std::io; +use std::net::SocketAddr; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::TcpStream; + +/// A byte-oriented transport that speaks raw MQTT packets. +/// +/// Implementations must be `Send + Unpin` so tests can move them across await +/// points and between tasks on the multi-threaded runtime. +pub trait Transport: AsyncRead + AsyncWrite + Send + Unpin {} + +impl Transport for T {} + +/// A plain TCP transport backed by a tokio [`TcpStream`]. +pub struct TcpTransport { + stream: TcpStream, +} + +impl TcpTransport { + /// Opens a TCP connection to `addr`. + pub async fn connect(addr: SocketAddr) -> io::Result { + let stream = TcpStream::connect(addr).await?; + Ok(Self { stream }) + } + + /// Consumes this transport and returns the underlying [`TcpStream`]. + #[must_use] + pub fn into_inner(self) -> TcpStream { + self.stream + } +} + +impl AsyncRead for TcpTransport { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.stream).poll_read(cx, buf) + } +} + +impl AsyncWrite for TcpTransport { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.stream).poll_write(cx, buf) + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.stream).poll_flush(cx) + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.stream).poll_shutdown(cx) + } +} diff --git a/crates/mqtt5-conformance/tests/fixtures/emqx.toml b/crates/mqtt5-conformance/tests/fixtures/emqx.toml new file mode 100644 index 00000000..02bd9c94 --- /dev/null +++ b/crates/mqtt5-conformance/tests/fixtures/emqx.toml @@ -0,0 +1,34 @@ +# EMQX 5.x — enterprise MQTT platform with clustering and rule engine. +# Full transport support including QUIC, ACL, and SCRAM-SHA-256/512 auth. +# WebSocket listens on port 8083 (not the common 8080). + +name = "emqx-5.x" +address = "mqtt://127.0.0.1:1883" +tls_address = "mqtts://127.0.0.1:8883" +ws_address = "ws://127.0.0.1:8083/mqtt" + +[capabilities] +max_qos = 2 +retain_available = true +wildcard_subscription_available = true +subscription_identifier_available = true +shared_subscription_available = true +topic_alias_maximum = 65535 +server_receive_maximum = 32 +maximum_packet_size = 1048576 +assigned_client_id_supported = true +acl = true +injected_user_properties = [] + +[capabilities.transports] +tcp = true +tls = true +websocket = true +quic = true + +[capabilities.enhanced_auth] +methods = ["SCRAM-SHA-256", "SCRAM-SHA-512"] + +[hooks] +restart_command = "emqx restart" +cleanup_command = "" diff --git a/crates/mqtt5-conformance/tests/fixtures/external-mqtt5.toml b/crates/mqtt5-conformance/tests/fixtures/external-mqtt5.toml new file mode 100644 index 00000000..8094c05d --- /dev/null +++ b/crates/mqtt5-conformance/tests/fixtures/external-mqtt5.toml @@ -0,0 +1,90 @@ +# mqtt-lib running as an external process. +# Same capabilities as the in-process fixture but tested over real TCP. +# This file serves as the reference template — every field is annotated. + +# string — human-readable broker identifier shown in reports +name = "mqtt5-external" + +# string — TCP address in scheme://host:port format (scheme: "mqtt" or omit) +address = "mqtt://127.0.0.1:1883" + +# string — TLS address (scheme: "mqtts"); empty string = TLS not available +# tls_address = "mqtts://127.0.0.1:8883" + +# string — WebSocket address (scheme: "ws" or "wss"); empty string = WS not available +# ws_address = "ws://127.0.0.1:8080/mqtt" + +[capabilities] +# u8 (0, 1, or 2) — maximum QoS level the broker supports [default: 2] +max_qos = 2 + +# bool — broker supports retained messages [default: true] +retain_available = true + +# bool — broker supports wildcard topic filters (+, #) [default: true] +wildcard_subscription_available = true + +# bool — broker supports subscription identifiers [default: true] +subscription_identifier_available = true + +# bool — broker supports shared subscriptions ($share/...) [default: true] +shared_subscription_available = true + +# u16 — maximum topic alias value the broker accepts (0 = disabled) [default: 65535] +topic_alias_maximum = 65535 + +# u16 — server receive maximum from CONNACK (0 = use protocol default) [default: 65535] +server_receive_maximum = 65535 + +# u32 — maximum packet size in bytes (0 = protocol maximum 268435456) [default: 268435456] +maximum_packet_size = 268435456 + +# bool — broker assigns client IDs when client sends empty string [default: true] +assigned_client_id_supported = true + +# string[] — user property keys the broker injects on PUBLISH [default: []] +injected_user_properties = ["x-mqtt-sender", "x-mqtt-client-id"] + +# bool — broker uses DISCONNECT (not CONNACK) for auth failures [default: false] +# auth_failure_uses_disconnect = false + +# string — how broker handles unsupported properties [default: "DisconnectMalformed"] +# unsupported_property_behavior = "DisconnectMalformed" + +# string — shared subscription distribution strategy [default: "RoundRobin"] +# shared_subscription_distribution = "RoundRobin" + +# bool — broker enforces inbound receive maximum [default: true] +# enforces_inbound_receive_maximum = true + +# bool — broker supports topic-level ACL rules [default: false] +# acl = false + +[capabilities.transports] +# bool — TCP transport available [default: true] +tcp = true + +# bool — TLS transport available [default: false] +tls = false + +# bool — WebSocket transport available [default: false] +websocket = false + +# bool — QUIC transport available [default: false] +quic = false + +# [capabilities.enhanced_auth] +# string[] — supported enhanced auth method names [default: []] +# methods = ["SCRAM-SHA-256"] + +# [capabilities.hooks] +# bool — broker can be restarted between tests [default: false] +# restart = false +# bool — broker state can be cleaned up between tests [default: false] +# cleanup = false + +# [hooks] +# string — shell command to restart the broker [default: ""] +# restart_command = "systemctl restart mqtt-broker" +# string — shell command to clean up broker state [default: ""] +# cleanup_command = "" diff --git a/crates/mqtt5-conformance/tests/fixtures/hivemq-ce.toml b/crates/mqtt5-conformance/tests/fixtures/hivemq-ce.toml new file mode 100644 index 00000000..f420f0e2 --- /dev/null +++ b/crates/mqtt5-conformance/tests/fixtures/hivemq-ce.toml @@ -0,0 +1,34 @@ +# HiveMQ Community Edition — free tier of the HiveMQ broker. +# No TLS in CE (Enterprise-only). WebSocket on port 8000. +# No ACL or enhanced auth support in the community edition. + +name = "hivemq-ce" +address = "mqtt://127.0.0.1:1883" +tls_address = "" +ws_address = "ws://127.0.0.1:8000/mqtt" + +[capabilities] +max_qos = 2 +retain_available = true +wildcard_subscription_available = true +subscription_identifier_available = true +shared_subscription_available = true +topic_alias_maximum = 5 +server_receive_maximum = 10 +maximum_packet_size = 268435456 +assigned_client_id_supported = true +acl = false +injected_user_properties = [] + +[capabilities.transports] +tcp = true +tls = false +websocket = true +quic = false + +[capabilities.enhanced_auth] +methods = [] + +[hooks] +restart_command = "" +cleanup_command = "" diff --git a/crates/mqtt5-conformance/tests/fixtures/inprocess.toml b/crates/mqtt5-conformance/tests/fixtures/inprocess.toml new file mode 100644 index 00000000..39282ab8 --- /dev/null +++ b/crates/mqtt5-conformance/tests/fixtures/inprocess.toml @@ -0,0 +1,25 @@ +# In-process mqtt-lib broker (used by `cargo test`). +# Addresses are empty because the harness binds to a random loopback port. +# Primarily used for parser tests and fast iteration during development. + +name = "mqtt5-inprocess" +address = "" +tls_address = "" +ws_address = "" + +[capabilities] +max_qos = 2 +retain_available = true +wildcard_subscription_available = true +subscription_identifier_available = true +shared_subscription_available = true +topic_alias_maximum = 65535 +server_receive_maximum = 65535 +assigned_client_id_supported = true +injected_user_properties = ["x-mqtt-sender", "x-mqtt-client-id"] + +[capabilities.transports] +tcp = true +tls = false +websocket = false +quic = false diff --git a/crates/mqtt5-conformance/tests/fixtures/mosquitto-2.x.toml b/crates/mqtt5-conformance/tests/fixtures/mosquitto-2.x.toml new file mode 100644 index 00000000..ecbd6284 --- /dev/null +++ b/crates/mqtt5-conformance/tests/fixtures/mosquitto-2.x.toml @@ -0,0 +1,34 @@ +# Mosquitto 2.x — popular open-source MQTT broker. +# Supports TCP, TLS, and WebSocket. No enhanced auth or ACL in default config. +# Topic alias maximum and receive maximum are lower than protocol defaults. + +name = "mosquitto-2.x" +address = "mqtt://127.0.0.1:1883" +tls_address = "mqtts://127.0.0.1:8883" +ws_address = "ws://127.0.0.1:8080/mqtt" + +[capabilities] +max_qos = 2 +retain_available = true +wildcard_subscription_available = true +subscription_identifier_available = true +shared_subscription_available = true +topic_alias_maximum = 10 +server_receive_maximum = 20 +maximum_packet_size = 268435456 +assigned_client_id_supported = true +auth_failure_uses_disconnect = false +injected_user_properties = [] + +[capabilities.transports] +tcp = true +tls = true +websocket = true +quic = false + +[capabilities.enhanced_auth] +methods = [] + +[hooks] +restart_command = "systemctl restart mosquitto" +cleanup_command = "" diff --git a/crates/mqtt5-conformance/tests/section3_connack.rs b/crates/mqtt5-conformance/tests/section3_connack.rs index 92a13719..865f1309 100644 --- a/crates/mqtt5-conformance/tests/section3_connack.rs +++ b/crates/mqtt5-conformance/tests/section3_connack.rs @@ -1,11 +1,11 @@ use mqtt5::broker::config::{BrokerConfig, StorageBackend, StorageConfig}; -use mqtt5::{ConnectOptions, MqttClient, QoS, SubscribeOptions}; -use mqtt5_conformance::harness::{unique_client_id, ConformanceBroker}; -use mqtt5_conformance::raw_client::{ - put_mqtt_string, wrap_fixed_header, RawMqttClient, RawPacketBuilder, -}; +use mqtt5_conformance::harness::unique_client_id; +use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +use mqtt5_conformance::sut::{inprocess_sut, inprocess_sut_with_config}; +use mqtt5_conformance::test_client::TestClient; use mqtt5_protocol::protocol::v5::properties::PropertyId; use mqtt5_protocol::protocol::v5::reason_codes::ReasonCode; +use mqtt5_protocol::types::{QoS, SubscribeOptions}; use std::net::SocketAddr; use std::time::Duration; @@ -22,200 +22,10 @@ fn memory_config() -> BrokerConfig { .with_storage(storage_config) } -// --------------------------------------------------------------------------- -// Group 1: CONNACK Structure (raw client) -// --------------------------------------------------------------------------- - -/// `[MQTT-3.2.2-1]` Byte 1 is the Connect Acknowledge Flags. Bits 7-1 are -/// reserved and MUST be set to 0. -/// -/// Connects with a valid CONNECT and verifies the CONNACK flags byte has -/// bits 7-1 all zero. -#[tokio::test] -async fn connack_reserved_flags_zero() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - - let client_id = unique_client_id("flags"); - raw.send_raw(&RawPacketBuilder::valid_connect(&client_id)) - .await - .unwrap(); - - let connack = raw.expect_connack(TIMEOUT).await; - assert!(connack.is_some(), "Must receive CONNACK"); - let (flags, reason) = connack.unwrap(); - assert_eq!(reason, 0x00, "Reason code must be Success"); - assert_eq!( - flags & 0xFE, - 0, - "[MQTT-3.2.2-1] CONNACK flags bits 7-1 must be zero" - ); -} - -/// `[MQTT-3.2.0-2]` The Server MUST NOT send more than one CONNACK in a -/// Network Connection. -/// -/// Connects, subscribes, publishes, and reads all responses to verify no -/// second CONNACK appears. -#[tokio::test] -async fn connack_only_one_per_connection() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - - let client_id = unique_client_id("one-connack"); - raw.send_raw(&RawPacketBuilder::valid_connect(&client_id)) - .await - .unwrap(); - - let connack = raw.expect_connack(TIMEOUT).await; - assert!(connack.is_some(), "Must receive first CONNACK"); - let (_, reason) = connack.unwrap(); - assert_eq!(reason, 0x00, "Must accept connection"); - - raw.send_raw(&RawPacketBuilder::subscribe("test/connack", 0)) - .await - .unwrap(); - - raw.send_raw(&RawPacketBuilder::publish_qos0("test/connack", b"hello")) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(500)).await; - - let mut connack_count = 0u32; - loop { - let data = raw.read_packet_bytes(Duration::from_millis(200)).await; - match data { - Some(bytes) if !bytes.is_empty() => { - if bytes[0] == 0x20 { - connack_count += 1; - } - } - _ => break, - } - } - - assert_eq!( - connack_count, 0, - "[MQTT-3.2.0-2] Server must not send a second CONNACK" - ); -} - -// --------------------------------------------------------------------------- -// Group 2: Session Present + Error Handling (raw client) -// --------------------------------------------------------------------------- - -/// `[MQTT-3.2.2-6]` If a Server sends a CONNACK packet containing a non-zero -/// Reason Code it MUST set Session Present to 0. -/// -/// Sends a CONNECT with an unsupported protocol version (99) and verifies -/// the error CONNACK has `session_present=false`. -#[tokio::test] -async fn connack_session_present_zero_on_error() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - - raw.send_raw(&RawPacketBuilder::connect_with_protocol_version(99)) - .await - .unwrap(); - - let connack = raw.expect_connack(TIMEOUT).await; - assert!(connack.is_some(), "Must receive error CONNACK"); - let (flags, reason) = connack.unwrap(); - assert_ne!(reason, 0x00, "Must be a non-success reason code"); - assert_eq!( - flags & 0x01, - 0, - "[MQTT-3.2.2-6] Session Present must be 0 when reason code is non-zero" - ); -} - -/// `[MQTT-3.2.2-7]` If a Server sends a CONNACK packet containing a Reason -/// Code of 128 or greater it MUST then close the Network Connection. -/// -/// Sends a CONNECT with unsupported protocol version, verifies the error -/// CONNACK, then verifies the connection is closed. -#[tokio::test] -async fn connack_error_code_closes_connection() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - - raw.send_raw(&RawPacketBuilder::connect_with_protocol_version(99)) - .await - .unwrap(); - - let response = raw.read_packet_bytes(TIMEOUT).await; - assert!(response.is_some(), "Must receive CONNACK"); - let data = response.unwrap(); - assert_eq!(data[0], 0x20, "Must be CONNACK"); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.2.2-7] Server must close connection after error CONNACK (reason >= 128)" - ); -} - -/// `[MQTT-3.2.2-8]` The Server sending the CONNACK packet MUST use one of -/// the Connect Reason Code values. -/// -/// Verifies that a successful CONNACK uses 0x00 (Success) and that an error -/// CONNACK uses a valid CONNACK reason code (0x84 for unsupported version). -#[tokio::test] -async fn connack_uses_valid_reason_code() { - let broker = ConformanceBroker::start().await; - - let mut raw_ok = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("valid-rc"); - raw_ok - .send_raw(&RawPacketBuilder::valid_connect(&client_id)) - .await - .unwrap(); - let ok_connack = raw_ok.expect_connack_packet(TIMEOUT).await; - assert!(ok_connack.is_some(), "Must receive success CONNACK"); - assert_eq!( - ok_connack.unwrap().reason_code, - ReasonCode::Success, - "[MQTT-3.2.2-8] Success CONNACK must have reason code 0x00" - ); - - let mut raw_err = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - raw_err - .send_raw(&RawPacketBuilder::connect_with_protocol_version(99)) - .await - .unwrap(); - let err_connack = raw_err.expect_connack_packet(TIMEOUT).await; - assert!(err_connack.is_some(), "Must receive error CONNACK"); - assert_eq!( - err_connack.unwrap().reason_code, - ReasonCode::UnsupportedProtocolVersion, - "[MQTT-3.2.2-8] Unsupported protocol version must use reason code 0x84" - ); -} - -// --------------------------------------------------------------------------- -// Group 3: CONNACK Properties (decoded ConnAckPacket) -// --------------------------------------------------------------------------- - -/// Verifies the broker advertises server capability properties in CONNACK: -/// `TopicAliasMaximum`, `RetainAvailable`, `MaximumPacketSize`, -/// `WildcardSubscriptionAvailable`, `SubscriptionIdentifierAvailable`, -/// `SharedSubscriptionAvailable`. #[tokio::test] async fn connack_properties_server_capabilities() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let sut = inprocess_sut().await; + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -274,17 +84,12 @@ async fn connack_properties_server_capabilities() { ); } -/// `[MQTT-3.2.2-9]` When the broker limits Maximum `QoS`, the CONNACK must -/// advertise `MaximumQoS` property. -/// -/// Starts a broker with `maximum_qos=1` and verifies the CONNACK contains -/// `MaximumQoS=1`. #[tokio::test] async fn connack_maximum_qos_advertised() { let config = memory_config().with_maximum_qos(1); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("maxqos"); @@ -313,59 +118,13 @@ async fn connack_maximum_qos_advertised() { } } -/// `[MQTT-3.2.2-16]` When two clients connect with empty client IDs, the -/// server must assign different unique `ClientID`s. -#[tokio::test] -async fn connack_assigned_client_id_unique() { - let broker = ConformanceBroker::start().await; - - let mut ids = Vec::new(); - for _ in 0..2 { - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - raw.send_raw(&build_connect_empty_client_id()) - .await - .unwrap(); - - let connack = raw - .expect_connack_packet(TIMEOUT) - .await - .expect("Must receive CONNACK"); - - assert_eq!(connack.reason_code, ReasonCode::Success); - let assigned = connack.properties.get(PropertyId::AssignedClientIdentifier); - assert!( - assigned.is_some(), - "[MQTT-3.2.2-16] CONNACK must include Assigned Client Identifier" - ); - if let Some(mqtt5_protocol::PropertyValue::Utf8String(id)) = assigned { - ids.push(id.clone()); - } else { - panic!("Assigned Client Identifier has wrong type"); - } - } - - assert_ne!( - ids[0], ids[1], - "[MQTT-3.2.2-16] Assigned Client Identifiers must be unique" - ); -} - -/// `[MQTT-3.2.2-22]` If the Server receives a CONNECT packet containing a -/// non-zero Keep Alive and it does not support that value, the Server sets -/// Server Keep Alive to the value it supports. -/// -/// Configures the broker with `server_keep_alive=30s`. A raw client connects -/// with keepalive=60s. The CONNACK must contain `ServerKeepAlive` property -/// equal to 30. #[tokio::test] async fn server_keep_alive_override() { let mut config = memory_config(); config.server_keep_alive = Some(Duration::from_secs(30)); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("ska"); @@ -394,21 +153,12 @@ async fn server_keep_alive_override() { } } -// --------------------------------------------------------------------------- -// Group 4: Will Rejection (raw client + custom config) -// --------------------------------------------------------------------------- - -/// `[MQTT-3.2.2-12]` If the Server receives a CONNECT packet containing a -/// Will `QoS` that exceeds its capabilities, it MUST reject the connection. -/// -/// Starts a broker with `maximum_qos=0`, sends a CONNECT with Will QoS=1, -/// and verifies rejection with reason code 0x9B (`QoSNotSupported`). #[tokio::test] async fn connack_will_qos_exceeds_maximum_rejected() { let config = memory_config().with_maximum_qos(0); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("will-qos"); @@ -432,18 +182,12 @@ async fn connack_will_qos_exceeds_maximum_rejected() { ); } -/// `[MQTT-3.2.2-13]` If the Server receives a CONNECT packet containing a -/// Will Message with Will Retain set to 1, and it does not support retained -/// messages, the Server MUST reject the connection request. -/// -/// Starts a broker with `retain_available=false`, sends a CONNECT with -/// Will Retain=1, and verifies rejection with 0x9A (`RetainNotSupported`). #[tokio::test] async fn connack_will_retain_rejected_when_unsupported() { let config = memory_config().with_retain_available(false); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("will-ret"); @@ -467,46 +211,30 @@ async fn connack_will_retain_rejected_when_unsupported() { ); } -// --------------------------------------------------------------------------- -// Group 5: Subscribe with Limited QoS (high-level client + custom config) -// --------------------------------------------------------------------------- - -/// `[MQTT-3.2.2-9]` `[MQTT-3.2.2-10]` If a Server does not support `QoS` 1 -/// or `QoS` 2, it MUST still accept SUBSCRIBE packets and downgrade the -/// granted `QoS`. -/// -/// Starts a broker with `maximum_qos=0`, subscribes with `QoS` 1, and -/// verifies the subscription is accepted (downgraded, not rejected). #[tokio::test] async fn connack_accepts_subscribe_any_qos_with_limited_max() { let config = memory_config().with_maximum_qos(0); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let client = MqttClient::new(unique_client_id("sub-limited")); - client - .connect(broker.address()) + let client = TestClient::connect_with_prefix(&sut, "sub-limited") .await .expect("connect must succeed"); - - let result = client.subscribe("test/limited", |_| {}).await; + let result = client + .subscribe("test/limited", SubscribeOptions::default()) + .await; assert!( result.is_ok(), "[MQTT-3.2.2-9] Server must accept SUBSCRIBE even with limited MaximumQoS" ); - let opts = ConnectOptions::new(unique_client_id("sub-limited2")).with_clean_start(true); - let client2 = MqttClient::with_options(opts.clone()); - Box::pin(client2.connect_with_options(broker.address(), opts)) + let client2 = TestClient::connect_with_prefix(&sut, "sub-limited2") .await .expect("connect must succeed"); - let sub_opts = SubscribeOptions { qos: QoS::ExactlyOnce, ..Default::default() }; - let result2 = client2 - .subscribe_with_options("test/limited2", sub_opts, |_| {}) - .await; + let result2 = client2.subscribe("test/limited2", sub_opts).await; assert!( result2.is_ok(), "[MQTT-3.2.2-10] Server must accept QoS 2 SUBSCRIBE and downgrade" @@ -515,22 +243,3 @@ async fn connack_accepts_subscribe_any_qos_with_limited_max() { client.disconnect().await.ok(); client2.disconnect().await.ok(); } - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn build_connect_empty_client_id() -> Vec { - use bytes::{BufMut, BytesMut}; - - let mut body = BytesMut::new(); - body.put_u16(4); - body.put_slice(b"MQTT"); - body.put_u8(5); - body.put_u8(0x02); - body.put_u16(60); - body.put_u8(0); - put_mqtt_string(&mut body, ""); - - wrap_fixed_header(0x10, &body) -} diff --git a/crates/mqtt5-conformance/tests/section3_disconnect.rs b/crates/mqtt5-conformance/tests/section3_disconnect.rs deleted file mode 100644 index 1bc30553..00000000 --- a/crates/mqtt5-conformance/tests/section3_disconnect.rs +++ /dev/null @@ -1,265 +0,0 @@ -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; -use std::time::Duration; - -const TIMEOUT: Duration = Duration::from_secs(3); - -// --------------------------------------------------------------------------- -// Group 1: Will Suppression/Publication on Disconnect -// --------------------------------------------------------------------------- - -/// `[MQTT-3.14.4-3]` On receipt of DISCONNECT with Reason Code 0x00 the -/// Server MUST discard the Will Message without publishing it. -#[tokio::test] -async fn disconnect_normal_suppresses_will() { - let broker = ConformanceBroker::start().await; - let will_id = unique_client_id("disc-normal"); - let will_topic = format!("will/{will_id}"); - - let collector = MessageCollector::new(); - let subscriber = connected_client("disc-norm-sub", &broker).await; - subscriber - .subscribe(&will_topic, collector.callback()) - .await - .expect("subscribe failed"); - tokio::time::sleep(Duration::from_millis(100)).await; - - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - raw.send_raw(&RawPacketBuilder::connect_with_will_and_keepalive( - &will_id, 60, - )) - .await - .unwrap(); - raw.expect_connack(TIMEOUT).await.expect("expected CONNACK"); - - raw.send_raw(&RawPacketBuilder::disconnect_normal()) - .await - .unwrap(); - tokio::time::sleep(Duration::from_millis(500)).await; - - assert_eq!( - collector.count(), - 0, - "[MQTT-3.14.4-3] will must NOT be published on normal disconnect (0x00)" - ); - - subscriber.disconnect().await.expect("disconnect failed"); -} - -/// DISCONNECT with reason code 0x04 (`DisconnectWithWillMessage`) MUST -/// still trigger will publication. -#[tokio::test] -async fn disconnect_with_will_message_publishes_will() { - let broker = ConformanceBroker::start().await; - let will_id = unique_client_id("disc-0x04"); - let will_topic = format!("will/{will_id}"); - - let collector = MessageCollector::new(); - let subscriber = connected_client("disc-04-sub", &broker).await; - subscriber - .subscribe(&will_topic, collector.callback()) - .await - .expect("subscribe failed"); - tokio::time::sleep(Duration::from_millis(100)).await; - - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - raw.send_raw(&RawPacketBuilder::connect_with_will_and_keepalive( - &will_id, 60, - )) - .await - .unwrap(); - raw.expect_connack(TIMEOUT).await.expect("expected CONNACK"); - - raw.send_raw(&RawPacketBuilder::disconnect_with_reason(0x04)) - .await - .unwrap(); - - assert!( - collector.wait_for_messages(1, Duration::from_secs(3)).await, - "will must be published when DISCONNECT reason is 0x04 (DisconnectWithWillMessage)" - ); - let msgs = collector.get_messages(); - assert_eq!(msgs[0].topic, will_topic); - assert_eq!(msgs[0].payload, b"offline"); - - subscriber.disconnect().await.expect("disconnect failed"); -} - -/// Connect with will + keepalive=2s, drop TCP without sending DISCONNECT. -/// Will MUST be published after keep-alive timeout. -#[tokio::test] -async fn tcp_drop_publishes_will() { - let broker = ConformanceBroker::start().await; - let will_id = unique_client_id("disc-drop"); - let will_topic = format!("will/{will_id}"); - - let collector = MessageCollector::new(); - let subscriber = connected_client("disc-drop-sub", &broker).await; - subscriber - .subscribe(&will_topic, collector.callback()) - .await - .expect("subscribe failed"); - tokio::time::sleep(Duration::from_millis(100)).await; - - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - raw.send_raw(&RawPacketBuilder::connect_with_will_and_keepalive( - &will_id, 2, - )) - .await - .unwrap(); - raw.expect_connack(TIMEOUT).await.expect("expected CONNACK"); - - drop(raw); - - assert!( - collector.wait_for_messages(1, Duration::from_secs(6)).await, - "will must be published after TCP drop (keep-alive timeout triggers abnormal disconnect)" - ); - let msgs = collector.get_messages(); - assert_eq!(msgs[0].topic, will_topic); - assert_eq!(msgs[0].payload, b"offline"); - - subscriber.disconnect().await.expect("disconnect failed"); -} - -// --------------------------------------------------------------------------- -// Group 2: Reason Code Handling -// --------------------------------------------------------------------------- - -/// `[MQTT-3.14.2-1]` Send DISCONNECT with various valid reason codes -/// (0x00, 0x04, 0x80) and verify the broker accepts them cleanly. -#[tokio::test] -async fn disconnect_valid_reason_codes_accepted() { - let broker = ConformanceBroker::start().await; - - for &reason in &[0x00u8, 0x04, 0x80] { - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id(&format!("disc-rc-{reason:02x}")); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - let packet = if reason == 0x00 { - RawPacketBuilder::disconnect_normal() - } else { - RawPacketBuilder::disconnect_with_reason(reason) - }; - raw.send_raw(&packet).await.unwrap(); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.14.2-1] broker must accept valid reason code 0x{reason:02x} and close connection" - ); - } -} - -/// Send DISCONNECT with an invalid reason code byte (0x03 is not in the -/// valid set). The broker must close the connection. -#[tokio::test] -async fn disconnect_invalid_reason_code_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("disc-bad-rc"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::disconnect_with_reason(0x03)) - .await - .unwrap(); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.14.2-1] broker must reject invalid reason code 0x03 and close connection" - ); -} - -// --------------------------------------------------------------------------- -// Group 3: Server-Initiated Disconnect -// --------------------------------------------------------------------------- - -/// Sending a second CONNECT packet is a protocol error. The server MUST -/// send DISCONNECT with 0x82 (Protocol Error) and close the connection. -#[tokio::test] -async fn server_disconnect_on_protocol_error() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("disc-proto-err"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::valid_connect("second-connect")) - .await - .unwrap(); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "server must disconnect client after receiving second CONNECT" - ); -} - -/// Same as above — verify the server DISCONNECT uses a valid reason code -/// from the specification's allowed set. -#[tokio::test] -async fn server_disconnect_uses_valid_reason_code() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("disc-rc-check"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::valid_connect("second-connect-2")) - .await - .unwrap(); - - let valid_disconnect_codes: &[u8] = &[ - 0x00, 0x04, 0x80, 0x81, 0x82, 0x83, 0x87, 0x89, 0x8B, 0x8D, 0x8E, 0x93, 0x94, 0x95, 0x96, - 0x97, 0x98, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, 0xA1, 0xA2, - ]; - - if let Some(reason_code) = raw.expect_disconnect_packet(TIMEOUT).await { - assert!( - valid_disconnect_codes.contains(&reason_code), - "server DISCONNECT reason code 0x{reason_code:02x} is not in the valid set" - ); - } -} - -// --------------------------------------------------------------------------- -// Group 4: Post-Disconnect Behavior -// --------------------------------------------------------------------------- - -/// `[MQTT-3.14.4-1]`/`[MQTT-3.14.4-2]` After sending DISCONNECT, sender -/// must close the connection. Verify no PINGRESP to a PINGREQ sent after -/// client DISCONNECT (connection should be closed). -#[tokio::test] -async fn no_packets_after_client_disconnect() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("disc-no-pkt"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::disconnect_normal()) - .await - .unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - - let _ = raw.send_raw(&RawPacketBuilder::pingreq()).await; - - assert!( - !raw.expect_pingresp(Duration::from_secs(1)).await, - "[MQTT-3.14.4-1] no PINGRESP should be received after client sent DISCONNECT" - ); -} diff --git a/crates/mqtt5-conformance/tests/section3_publish_flow.rs b/crates/mqtt5-conformance/tests/section3_publish_flow.rs index b6089abf..68481bf8 100644 --- a/crates/mqtt5-conformance/tests/section3_publish_flow.rs +++ b/crates/mqtt5-conformance/tests/section3_publish_flow.rs @@ -1,6 +1,7 @@ use mqtt5::broker::config::{BrokerConfig, StorageBackend, StorageConfig}; -use mqtt5_conformance::harness::{unique_client_id, ConformanceBroker}; +use mqtt5_conformance::harness::unique_client_id; use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +use mqtt5_conformance::sut::inprocess_sut_with_config; use std::net::SocketAddr; use std::time::Duration; @@ -15,137 +16,12 @@ fn memory_config() -> BrokerConfig { .with_storage(storage_config) } -/// `[MQTT-3.3.4-7]` The Server MUST NOT send more than Receive Maximum -/// `QoS` 1 and `QoS` 2 PUBLISH packets for which it has not received PUBACK, -/// PUBCOMP, or PUBREC with a Reason Code of 128 or greater from the Client. -/// -/// A raw subscriber connects with `receive_maximum=2`. A raw publisher sends -/// 4 `QoS` 1 messages in quick succession. The subscriber reads without sending -/// PUBACKs and verifies that only 2 PUBLISH packets arrive within a reasonable -/// window (the broker queues the rest pending acknowledgement). -#[tokio::test] -async fn receive_maximum_limits_outbound_publishes() { - let broker = ConformanceBroker::start().await; - let topic = format!("recv-max/{}", unique_client_id("flow")); - - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let sub_id = unique_client_id("sub-rm"); - sub.send_raw(&RawPacketBuilder::connect_with_receive_maximum(&sub_id, 2)) - .await - .unwrap(); - let connack = sub.expect_connack(Duration::from_secs(2)).await; - assert!(connack.is_some(), "Subscriber must receive CONNACK"); - let (_, reason) = connack.unwrap(); - assert_eq!(reason, 0x00, "Subscriber CONNACK must be Success"); - - sub.send_raw(&RawPacketBuilder::subscribe(&topic, 1)) - .await - .unwrap(); - let suback = sub.expect_suback(Duration::from_secs(2)).await; - assert!(suback.is_some(), "Must receive SUBACK"); - - let mut pub_raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let pub_id = unique_client_id("pub-rm"); - pub_raw - .send_raw(&RawPacketBuilder::valid_connect(&pub_id)) - .await - .unwrap(); - let pub_connack = pub_raw.expect_connack(Duration::from_secs(2)).await; - assert!(pub_connack.is_some(), "Publisher must receive CONNACK"); - - for i in 1..=4u16 { - pub_raw - .send_raw(&RawPacketBuilder::publish_qos1( - &topic, - &[u8::try_from(i).unwrap()], - i, - )) - .await - .unwrap(); - } - tokio::time::sleep(Duration::from_millis(500)).await; - - let publish_count = count_publish_packets_from_raw(&mut sub, Duration::from_secs(2)).await; - - assert_eq!( - publish_count, 2, - "[MQTT-3.3.4-7] Server must not send more than receive_maximum (2) unACKed QoS 1 publishes" - ); -} - -async fn count_publish_packets_from_raw(client: &mut RawMqttClient, timeout: Duration) -> u32 { - let mut accumulated = Vec::new(); - let deadline = tokio::time::Instant::now() + timeout; - - loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - break; - } - match client - .read_packet_bytes(remaining.min(Duration::from_millis(500))) - .await - { - Some(data) => accumulated.extend_from_slice(&data), - None => break, - } - } - - count_publish_headers(&accumulated) -} - -fn count_publish_headers(data: &[u8]) -> u32 { - let mut count = 0; - let mut idx = 0; - while idx < data.len() { - let first_byte = data[idx]; - let packet_type = first_byte >> 4; - idx += 1; - - let mut remaining_len: u32 = 0; - let mut shift = 0; - loop { - if idx >= data.len() { - return count; - } - let byte = data[idx]; - idx += 1; - remaining_len |= u32::from(byte & 0x7F) << shift; - if byte & 0x80 == 0 { - break; - } - shift += 7; - if shift > 21 { - return count; - } - } - - let body_end = idx + remaining_len as usize; - if body_end > data.len() { - if packet_type == 3 { - count += 1; - } - return count; - } - - if packet_type == 3 { - count += 1; - } - idx = body_end; - } - count -} - #[tokio::test] async fn inbound_receive_maximum_exceeded_disconnects_with_0x93() { let config = memory_config().with_server_receive_maximum(2); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let mut client = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut client = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let cid = unique_client_id("recv-max-in"); diff --git a/crates/mqtt5-conformance/tests/section3_subscribe.rs b/crates/mqtt5-conformance/tests/section3_subscribe.rs index dd4a3de6..874d4df7 100644 --- a/crates/mqtt5-conformance/tests/section3_subscribe.rs +++ b/crates/mqtt5-conformance/tests/section3_subscribe.rs @@ -1,9 +1,9 @@ use mqtt5::broker::config::{BrokerConfig, StorageBackend, StorageConfig}; -use mqtt5::{QoS, SubscribeOptions}; -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; +use mqtt5_conformance::harness::unique_client_id; use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +use mqtt5_conformance::sut::{inprocess_sut, inprocess_sut_with_config}; +use mqtt5_conformance::test_client::TestClient; +use mqtt5_protocol::types::{QoS, SubscribeOptions}; use std::io::Write; use std::net::SocketAddr; use std::time::Duration; @@ -22,140 +22,6 @@ fn memory_config() -> BrokerConfig { .with_storage(storage_config) } -// --------------------------------------------------------------------------- -// Group 1: SUBSCRIBE Structure — Section 3.8 -// --------------------------------------------------------------------------- - -/// `[MQTT-3.8.1-1]` SUBSCRIBE fixed header flags MUST be `0x02`. -/// A raw SUBSCRIBE with flags `0x00` (byte `0x80`) must cause disconnect. -#[tokio::test] -async fn subscribe_invalid_flags_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("sub-flags"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_invalid_flags("test/topic", 0)) - .await - .unwrap(); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.8.1-1] server must disconnect on SUBSCRIBE with invalid flags" - ); -} - -/// `[MQTT-3.8.3-3]` SUBSCRIBE payload MUST contain at least one topic filter. -/// An empty-payload SUBSCRIBE must cause disconnect. -#[tokio::test] -async fn subscribe_empty_payload_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("sub-empty"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_empty_payload(1)) - .await - .unwrap(); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.8.3-3] server must disconnect on SUBSCRIBE with no topic filters" - ); -} - -/// `[MQTT-3.8.3-4]` `NoLocal=1` on a shared subscription is a Protocol Error. -/// Broker must disconnect. -#[tokio::test] -async fn subscribe_no_local_shared_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("sub-nolocal-share"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_shared_no_local( - "workers", "tasks/+", 1, 1, - )) - .await - .unwrap(); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.8.3-4] server must disconnect on NoLocal with shared subscription" - ); -} - -// --------------------------------------------------------------------------- -// Group 2: SUBACK Response — Section 3.9 -// --------------------------------------------------------------------------- - -/// `[MQTT-3.9.2-1]` SUBACK packet ID must match SUBSCRIBE packet ID. -#[tokio::test] -async fn suback_packet_id_matches() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("suback-pid"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - let packet_id: u16 = 42; - raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( - "test/suback-pid", - 0, - packet_id, - )) - .await - .unwrap(); - - let (ack_id, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("expected SUBACK from broker"); - - assert_eq!( - ack_id, packet_id, - "[MQTT-3.9.2-1] SUBACK packet ID must match SUBSCRIBE packet ID" - ); - assert_eq!(reason_codes.len(), 1, "SUBACK must contain one reason code"); -} - -/// `[MQTT-3.9.3-1]` SUBACK must contain one reason code per topic filter. -#[tokio::test] -async fn suback_reason_codes_per_filter() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("suback-multi"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - let filters = [("test/a", 0u8), ("test/b", 1), ("test/c", 2)]; - raw.send_raw(&RawPacketBuilder::subscribe_multiple(&filters, 10)) - .await - .unwrap(); - - let (ack_id, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("expected SUBACK from broker"); - - assert_eq!(ack_id, 10); - assert_eq!( - reason_codes.len(), - 3, - "[MQTT-3.9.3-1] SUBACK must contain one reason code per topic filter" - ); -} - -/// `[MQTT-3.9.3-2]` SUBACK reason codes must be in the same order as the -/// SUBSCRIBE topic filters. Mix of authorized and unauthorized filters. #[tokio::test] async fn suback_reason_codes_ordering() { let mut acl_file = NamedTempFile::new().unwrap(); @@ -165,9 +31,9 @@ async fn suback_reason_codes_ordering() { let config = memory_config().with_auth( mqtt5::broker::config::AuthConfig::new().with_acl_file(acl_file.path().to_path_buf()), ); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("suback-order"); @@ -201,52 +67,12 @@ async fn suback_reason_codes_ordering() { ); } -// --------------------------------------------------------------------------- -// Group 3: QoS Granting -// --------------------------------------------------------------------------- - -/// `[MQTT-3.9.3-3]` SUBACK grants the exact `QoS` requested on a default -/// broker (max `QoS` 2). -#[tokio::test] -async fn suback_grants_requested_qos() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("suback-qos"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - for qos in 0..=2u8 { - let topic = format!("test/qos{qos}"); - let packet_id = u16::from(qos) + 1; - raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( - &topic, qos, packet_id, - )) - .await - .unwrap(); - - let (ack_id, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("expected SUBACK from broker"); - - assert_eq!(ack_id, packet_id); - assert_eq!( - reason_codes[0], qos, - "SUBACK should grant QoS {qos}, got 0x{:02X}", - reason_codes[0] - ); - } -} - -/// `[MQTT-3.2.2-10]` / `[MQTT-3.9.3-3]` When broker's `maximum_qos=1`, -/// subscribing with `QoS` 2 gets downgraded to `QoS` 1 in SUBACK. #[tokio::test] async fn suback_downgrades_to_max_qos() { let config = memory_config().with_maximum_qos(1); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("suback-downgrade"); @@ -272,41 +98,38 @@ async fn suback_downgrades_to_max_qos() { ); } -/// Subscribe `QoS` 1, publish `QoS` 1, verify message delivery to subscriber. #[tokio::test] async fn suback_message_delivery_at_granted_qos() { - let broker = ConformanceBroker::start().await; - let subscriber = connected_client("sub-deliver", &broker).await; - let collector = MessageCollector::new(); + let sut = inprocess_sut().await; + let subscriber = TestClient::connect_with_prefix(&sut, "sub-deliver") + .await + .unwrap(); let opts = SubscribeOptions { qos: QoS::AtLeastOnce, ..Default::default() }; - subscriber - .subscribe_with_options("test/sub-deliver", opts, collector.callback()) + let subscription = subscriber + .subscribe("test/sub-deliver", opts) .await .unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; - let publisher = connected_client("sub-deliver-pub", &broker).await; + let publisher = TestClient::connect_with_prefix(&sut, "sub-deliver-pub") + .await + .unwrap(); publisher - .publish("test/sub-deliver", b"delivered".to_vec()) + .publish("test/sub-deliver", b"delivered") .await .unwrap(); assert!( - collector.wait_for_messages(1, TIMEOUT).await, + subscription.wait_for_messages(1, TIMEOUT).await, "subscriber should receive message at granted QoS" ); - let msgs = collector.get_messages(); + let msgs = subscription.snapshot(); assert_eq!(msgs[0].payload, b"delivered"); } -// --------------------------------------------------------------------------- -// Group 4: Authorization & Quota -// --------------------------------------------------------------------------- - -/// SUBACK reason code `NotAuthorized` (0x87) when ACL denies subscribe. #[tokio::test] async fn suback_not_authorized() { let mut acl_file = NamedTempFile::new().unwrap(); @@ -316,9 +139,9 @@ async fn suback_not_authorized() { let config = memory_config().with_auth( mqtt5::broker::config::AuthConfig::new().with_acl_file(acl_file.path().to_path_buf()), ); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("sub-noauth"); @@ -344,13 +167,12 @@ async fn suback_not_authorized() { ); } -/// SUBACK reason code `QuotaExceeded` (0x97) when subscription limit is reached. #[tokio::test] async fn suback_quota_exceeded() { let config = memory_config().with_max_subscriptions_per_client(2); - let broker = ConformanceBroker::start_with_config(config).await; + let sut = inprocess_sut_with_config(config).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); let client_id = unique_client_id("sub-quota"); @@ -390,254 +212,3 @@ async fn suback_quota_exceeded() { rc3[0] ); } - -// --------------------------------------------------------------------------- -// Group 5: Subscription Replacement -// --------------------------------------------------------------------------- - -/// Subscribing twice to the same topic with different `QoS` replaces the -/// existing subscription. Only one copy of a published message is delivered. -#[tokio::test] -async fn subscribe_replaces_existing() { - let broker = ConformanceBroker::start().await; - - let mut raw_sub = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let sub_id = unique_client_id("sub-replace"); - raw_sub.connect_and_establish(&sub_id, TIMEOUT).await; - - raw_sub - .send_raw(&RawPacketBuilder::subscribe_with_packet_id( - "test/replace", - 0, - 1, - )) - .await - .unwrap(); - let (_, rc1) = raw_sub.expect_suback(TIMEOUT).await.expect("SUBACK 1"); - assert_eq!(rc1[0], 0x00, "first subscribe should grant QoS 0"); - - raw_sub - .send_raw(&RawPacketBuilder::subscribe_with_packet_id( - "test/replace", - 1, - 2, - )) - .await - .unwrap(); - let (_, rc2) = raw_sub.expect_suback(TIMEOUT).await.expect("SUBACK 2"); - assert_eq!(rc2[0], 0x01, "second subscribe should grant QoS 1"); - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("sub-replace-pub", &broker).await; - publisher - .publish("test/replace", b"once".to_vec()) - .await - .unwrap(); - - let first = raw_sub.expect_publish(TIMEOUT).await; - assert!(first.is_some(), "subscriber should receive the message"); - - let duplicate = raw_sub.expect_publish(Duration::from_millis(500)).await; - assert!( - duplicate.is_none(), - "subscriber should receive only one copy (replacement, not duplicate subscription)" - ); -} - -// --------------------------------------------------------------------------- -// Group 6: Subscribe Options Validation — Section 3.8.3 -// --------------------------------------------------------------------------- - -/// `[MQTT-3.8.3-5]` Reserved bits in the subscribe options byte MUST be zero. -/// Setting bits 6-7 to non-zero is a protocol error that must cause disconnect. -#[tokio::test] -async fn subscribe_reserved_option_bits_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("sub-reserved-bits"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_with_options( - "test/reserved-bits", - 0xC0, - 1, - )) - .await - .unwrap(); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.8.3-5] server must disconnect on SUBSCRIBE with reserved option bits set" - ); -} - -/// `[MQTT-3.8.3-1]` Topic filter in SUBSCRIBE must be valid UTF-8. -/// Sending invalid UTF-8 bytes must cause disconnect. -#[tokio::test] -async fn subscribe_invalid_utf8_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("sub-bad-utf8"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_invalid_utf8(1)) - .await - .unwrap(); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-3.8.3-1] server must disconnect on SUBSCRIBE with invalid UTF-8 topic filter" - ); -} - -// --------------------------------------------------------------------------- -// Group 7: Retain Handling — Section 3.8.4 -// --------------------------------------------------------------------------- - -/// `[MQTT-3.8.4-4]` When Retain Handling is 0 (`SendAtSubscribe`), retained -/// messages are sent on every subscribe, including re-subscribes. -#[tokio::test] -async fn retain_handling_zero_sends_on_resubscribe() { - let config = memory_config(); - let broker = ConformanceBroker::start_with_config(config).await; - - let publisher = connected_client("rh0-pub", &broker).await; - let pub_opts = mqtt5::PublishOptions { - qos: QoS::AtMostOnce, - retain: true, - ..Default::default() - }; - publisher - .publish_with_options("test/rh0/topic", b"retained-payload", pub_opts) - .await - .unwrap(); - tokio::time::sleep(Duration::from_millis(200)).await; - - let subscriber = connected_client("rh0-sub", &broker).await; - let collector = MessageCollector::new(); - let sub_opts = SubscribeOptions { - qos: QoS::AtMostOnce, - retain_handling: mqtt5::RetainHandling::SendAtSubscribe, - ..Default::default() - }; - subscriber - .subscribe_with_options("test/rh0/topic", sub_opts.clone(), collector.callback()) - .await - .unwrap(); - - assert!( - collector.wait_for_messages(1, TIMEOUT).await, - "first subscribe with RetainHandling=0 must deliver retained message" - ); - let msgs = collector.get_messages(); - assert_eq!(msgs[0].payload, b"retained-payload"); - - collector.clear(); - - subscriber - .subscribe_with_options("test/rh0/topic", sub_opts, collector.callback()) - .await - .unwrap(); - - assert!( - collector.wait_for_messages(1, TIMEOUT).await, - "[MQTT-3.8.4-4] re-subscribe with RetainHandling=0 must deliver retained message again" - ); - let msgs2 = collector.get_messages(); - assert_eq!(msgs2[0].payload, b"retained-payload"); -} - -// --------------------------------------------------------------------------- -// Group 8: Delivered QoS — Section 3.8.4 -// --------------------------------------------------------------------------- - -/// `[MQTT-3.8.4-8]` The delivered `QoS` is the minimum of the published `QoS` -/// and the subscription's granted `QoS`. Subscribe at `QoS` 0, publish at `QoS` 1 -/// → delivered at `QoS` 0. -#[tokio::test] -async fn delivered_qos_is_minimum_sub0_pub1() { - let broker = ConformanceBroker::start().await; - let tag = unique_client_id("minqos01"); - let topic = format!("minqos/{tag}"); - - let mut raw_sub = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let sub_id = unique_client_id("sub-mq01"); - raw_sub.connect_and_establish(&sub_id, TIMEOUT).await; - - raw_sub - .send_raw(&RawPacketBuilder::subscribe_with_packet_id(&topic, 0, 1)) - .await - .unwrap(); - let (_, rc) = raw_sub.expect_suback(TIMEOUT).await.expect("SUBACK"); - assert_eq!(rc[0], 0x00, "granted QoS should be 0"); - tokio::time::sleep(Duration::from_millis(100)).await; - - let mut raw_publisher = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let pub_id = unique_client_id("pub-mq01"); - raw_publisher.connect_and_establish(&pub_id, TIMEOUT).await; - - raw_publisher - .send_raw(&RawPacketBuilder::publish_qos1(&topic, b"hello", 1)) - .await - .unwrap(); - - let msg = raw_sub.expect_publish(TIMEOUT).await; - assert!(msg.is_some(), "subscriber should receive the message"); - let (qos, _, payload) = msg.unwrap(); - assert_eq!(payload, b"hello"); - assert_eq!( - qos, 0, - "[MQTT-3.8.4-8] delivered QoS must be min(pub=1, sub=0) = 0" - ); -} - -/// `[MQTT-3.8.4-8]` Subscribe at `QoS` 1, publish at `QoS` 2 → delivered at `QoS` 1. -#[tokio::test] -async fn delivered_qos_is_minimum_sub1_pub2() { - let broker = ConformanceBroker::start().await; - let tag = unique_client_id("minqos12"); - let topic = format!("minqos/{tag}"); - - let mut raw_sub = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let sub_id = unique_client_id("sub-mq12"); - raw_sub.connect_and_establish(&sub_id, TIMEOUT).await; - - raw_sub - .send_raw(&RawPacketBuilder::subscribe_with_packet_id(&topic, 1, 1)) - .await - .unwrap(); - let (_, rc) = raw_sub.expect_suback(TIMEOUT).await.expect("SUBACK"); - assert_eq!(rc[0], 0x01, "granted QoS should be 1"); - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("pub-mq12", &broker).await; - let pub_opts = mqtt5::PublishOptions { - qos: QoS::ExactlyOnce, - ..Default::default() - }; - publisher - .publish_with_options(&topic, b"world", pub_opts) - .await - .unwrap(); - - let msg = raw_sub.expect_publish(TIMEOUT).await; - assert!(msg.is_some(), "subscriber should receive the message"); - let (qos, _, payload) = msg.unwrap(); - assert_eq!(payload, b"world"); - assert_eq!( - qos, 1, - "[MQTT-3.8.4-8] delivered QoS must be min(pub=2, sub=1) = 1" - ); -} diff --git a/crates/mqtt5-conformance/tests/section4_enhanced_auth.rs b/crates/mqtt5-conformance/tests/section4_enhanced_auth.rs index b5975abb..c19c0210 100644 --- a/crates/mqtt5-conformance/tests/section4_enhanced_auth.rs +++ b/crates/mqtt5-conformance/tests/section4_enhanced_auth.rs @@ -2,8 +2,9 @@ use mqtt5::broker::auth::{AuthProvider, AuthResult, EnhancedAuthResult}; use mqtt5::error::Result; use mqtt5::packet::connect::ConnectPacket; use mqtt5::protocol::v5::reason_codes::ReasonCode; -use mqtt5_conformance::harness::{unique_client_id, ConformanceBroker}; +use mqtt5_conformance::harness::unique_client_id; use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; +use mqtt5_conformance::sut::inprocess_sut_with_auth_provider; use std::future::Future; use std::net::SocketAddr; use std::pin::Pin; @@ -101,111 +102,13 @@ fn challenge_response_provider() -> Arc { )) } -/// `[MQTT-4.12.0-1]` If the Server does not support the Authentication -/// Method supplied by the Client, it MAY send a CONNACK with a Reason -/// Code of 0x8C (Bad Authentication Method). -/// -/// Sends CONNECT with an unknown Authentication Method to a broker using -/// `AllowAllAuth` (no enhanced auth support). Expects CONNACK 0x8C. -#[tokio::test] -async fn unsupported_auth_method_closes() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - - let client_id = unique_client_id("unsup-auth"); - let connect = RawPacketBuilder::connect_with_auth_method(&client_id, "UNKNOWN-METHOD"); - raw.send_raw(&connect).await.unwrap(); - - let connack = raw.expect_connack(Duration::from_secs(3)).await; - assert!( - connack.is_some(), - "[MQTT-4.12.0-1] Server must send CONNACK for unsupported auth method" - ); - let (_, reason_code) = connack.unwrap(); - assert_eq!( - reason_code, 0x8C, - "[MQTT-4.12.0-1] CONNACK reason code must be 0x8C (Bad Authentication Method)" - ); - - assert!( - raw.expect_disconnect(Duration::from_secs(2)).await, - "[MQTT-4.12.0-1] Server must close connection after rejecting auth method" - ); -} - -/// `[MQTT-4.12.0-6]` If the Client does not include an Authentication -/// Method in the CONNECT, the Server MUST NOT send an AUTH packet. -/// -/// Sends a plain CONNECT (no auth method), verifies CONNACK is received -/// without any preceding AUTH packet. Then sends a PINGREQ to confirm -/// the connection is operational with no AUTH packets in the stream. -#[tokio::test] -async fn no_auth_method_no_server_auth() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - - let client_id = unique_client_id("no-auth"); - let connect = RawPacketBuilder::valid_connect(&client_id); - raw.send_raw(&connect).await.unwrap(); - - let connack = raw.expect_connack(Duration::from_secs(3)).await; - assert!( - connack.is_some(), - "[MQTT-4.12.0-6] Server must send CONNACK for plain connect" - ); - let (_, reason_code) = connack.unwrap(); - assert_eq!( - reason_code, 0x00, - "[MQTT-4.12.0-6] CONNACK must be success for plain connect" - ); - - raw.send_raw(&RawPacketBuilder::pingreq()).await.unwrap(); - assert!( - raw.expect_pingresp(Duration::from_secs(3)).await, - "[MQTT-4.12.0-6] Connection must remain operational with no AUTH packets sent" - ); -} - -/// `[MQTT-4.12.0-7]` If the Client does not include an Authentication -/// Method in the CONNECT, the Client MUST NOT send an AUTH packet to the -/// Server. The Server treats this as a Protocol Error. -/// -/// Connects normally (no auth method), then sends an unsolicited AUTH -/// packet. Expects the broker to DISCONNECT or close. -#[tokio::test] -async fn unsolicited_auth_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - - let client_id = unique_client_id("unsol-auth"); - raw.connect_and_establish(&client_id, Duration::from_secs(3)) - .await; - - let auth = RawPacketBuilder::auth_with_method(0x19, "SOME-METHOD"); - raw.send_raw(&auth).await.unwrap(); - - assert!( - raw.expect_disconnect(Duration::from_secs(3)).await, - "[MQTT-4.12.0-7] Server must disconnect client that sends AUTH without prior auth method" - ); -} - /// `[MQTT-4.12.0-4]` If the initial CONNECT-triggered enhanced auth /// fails, the Server MUST send a CONNACK with an error Reason Code and /// close the connection. -/// -/// Uses a `ChallengeResponseAuth` provider. Sends CONNECT with the correct -/// method but wrong credentials (bad auth data on the continue step). #[tokio::test] async fn auth_failure_closes_connection() { - let broker = ConformanceBroker::start_with_auth_provider(challenge_response_provider()).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let sut = inprocess_sut_with_auth_provider(challenge_response_provider()).await; + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -231,20 +134,12 @@ async fn auth_failure_closes_connection() { ); } -/// `[MQTT-4.12.0-2]` The Server that does not support the Authentication -/// Method … If the Server requires additional information it sends an -/// AUTH packet with Reason Code 0x18 (Continue Authentication). -/// -/// `[MQTT-4.12.0-3]` The Client responds to an AUTH packet from the -/// Server by sending a further AUTH packet. This packet MUST contain -/// Reason Code 0x18 (Continue Authentication). -/// -/// Verifies the server sends AUTH with exactly Reason Code 0x18 during -/// the challenge-response flow. +/// `[MQTT-4.12.0-2]` Server AUTH packet has reason code 0x18 (Continue). +/// `[MQTT-4.12.0-3]` Client AUTH response has reason code 0x18. #[tokio::test] async fn auth_continue_has_correct_reason_code() { - let broker = ConformanceBroker::start_with_auth_provider(challenge_response_provider()).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let sut = inprocess_sut_with_auth_provider(challenge_response_provider()).await; + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -264,16 +159,12 @@ async fn auth_continue_has_correct_reason_code() { ); } -/// `[MQTT-4.12.0-5]` The Authentication Method is a UTF-8 Encoded String. -/// If the Authentication Method is present, it MUST be the same in all +/// `[MQTT-4.12.0-5]` The Authentication Method MUST be the same in all /// AUTH packets within the flow. -/// -/// Verifies the server echoes the same Authentication Method property -/// in its AUTH packet as was sent in CONNECT. #[tokio::test] async fn auth_method_consistent_in_flow() { - let broker = ConformanceBroker::start_with_auth_provider(challenge_response_provider()).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let sut = inprocess_sut_with_auth_provider(challenge_response_provider()).await; + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); @@ -296,13 +187,10 @@ async fn auth_method_consistent_in_flow() { /// `[MQTT-4.12.1-2]` If re-authentication fails, the Server MUST send a /// DISCONNECT with an appropriate Reason Code and close the connection. -/// -/// Successfully authenticates with enhanced auth, then sends re-auth -/// with bad credentials. Expects DISCONNECT. #[tokio::test] async fn reauth_failure_disconnects() { - let broker = ConformanceBroker::start_with_auth_provider(challenge_response_provider()).await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) + let sut = inprocess_sut_with_auth_provider(challenge_response_provider()).await; + let mut raw = RawMqttClient::connect_tcp(sut.expect_tcp_addr()) .await .unwrap(); diff --git a/crates/mqtt5-conformance/tests/section4_shared_sub.rs b/crates/mqtt5-conformance/tests/section4_shared_sub.rs deleted file mode 100644 index 3dde55c5..00000000 --- a/crates/mqtt5-conformance/tests/section4_shared_sub.rs +++ /dev/null @@ -1,437 +0,0 @@ -use mqtt5::{PublishOptions, QoS}; -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; -use std::time::Duration; - -const TIMEOUT: Duration = Duration::from_secs(3); - -// --------------------------------------------------------------------------- -// Group 1: Shared Subscription Format Validation — Section 4.8.2 -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn shared_sub_valid_format_accepted() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("shared-valid"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( - "$share/mygroup/sensor/+", - 0, - 1, - )) - .await - .unwrap(); - - let (_, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("[MQTT-4.8.2-1] must receive SUBACK"); - assert_eq!(reason_codes.len(), 1); - assert_eq!( - reason_codes[0], 0x00, - "[MQTT-4.8.2-1] valid shared subscription must be granted QoS 0" - ); -} - -#[tokio::test] -async fn shared_sub_share_name_with_wildcard_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("shared-badname"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_multiple( - &[("$share/gr+oup/topic", 0), ("$share/gr#oup/topic", 0)], - 1, - )) - .await - .unwrap(); - - let (_, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("[MQTT-4.8.2-2] must receive SUBACK"); - assert_eq!(reason_codes.len(), 2); - assert_eq!( - reason_codes[0], 0x8F, - "[MQTT-4.8.2-2] ShareName with + must return TopicFilterInvalid (0x8F)" - ); - assert_eq!( - reason_codes[1], 0x8F, - "[MQTT-4.8.2-2] ShareName with # must return TopicFilterInvalid (0x8F)" - ); -} - -#[tokio::test] -async fn shared_sub_incomplete_format_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("shared-incomplete"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( - "$share/grouponly", - 0, - 1, - )) - .await - .unwrap(); - - let (_, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("[MQTT-4.8.2-1] must receive SUBACK"); - assert_eq!(reason_codes.len(), 1); - assert_eq!( - reason_codes[0], 0x8F, - "[MQTT-4.8.2-1] incomplete $share/grouponly (no second /) must return TopicFilterInvalid" - ); -} - -// --------------------------------------------------------------------------- -// Group 2: Shared Subscription Message Distribution — Section 4.8.2 -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn shared_sub_round_robin_delivery() { - let broker = ConformanceBroker::start().await; - - let worker1 = connected_client("shared-rr-w1", &broker).await; - let collector1 = MessageCollector::new(); - worker1 - .subscribe("$share/workers/tasks", collector1.callback()) - .await - .unwrap(); - - let worker2 = connected_client("shared-rr-w2", &broker).await; - let collector2 = MessageCollector::new(); - worker2 - .subscribe("$share/workers/tasks", collector2.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("shared-rr-pub", &broker).await; - for i in 0..6 { - publisher - .publish("tasks", format!("msg-{i}").as_bytes()) - .await - .unwrap(); - } - - tokio::time::sleep(Duration::from_millis(500)).await; - - let count1 = collector1.count(); - let count2 = collector2.count(); - assert_eq!( - count1 + count2, - 6, - "all 6 messages must be delivered across shared group" - ); - assert!( - count1 >= 1 && count2 >= 1, - "both shared subscribers must receive at least one message: w1={count1}, w2={count2}" - ); -} - -#[tokio::test] -async fn shared_sub_mixed_with_regular() { - let broker = ConformanceBroker::start().await; - - let shared = connected_client("shared-mix-s", &broker).await; - let collector_shared = MessageCollector::new(); - shared - .subscribe("$share/workers/tasks", collector_shared.callback()) - .await - .unwrap(); - - let regular = connected_client("shared-mix-r", &broker).await; - let collector_regular = MessageCollector::new(); - regular - .subscribe("tasks", collector_regular.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("shared-mix-pub", &broker).await; - for i in 0..4 { - publisher - .publish("tasks", format!("msg-{i}").as_bytes()) - .await - .unwrap(); - } - - collector_regular.wait_for_messages(4, TIMEOUT).await; - collector_shared.wait_for_messages(4, TIMEOUT).await; - - tokio::time::sleep(Duration::from_millis(300)).await; - - assert_eq!( - collector_regular.count(), - 4, - "regular subscriber must receive all 4 messages" - ); - assert_eq!( - collector_shared.count(), - 4, - "sole shared group member must receive all 4 messages" - ); -} - -// --------------------------------------------------------------------------- -// Group 3: Shared Subscription Retained Messages — Section 4.8 -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn shared_sub_no_retained_on_subscribe() { - let broker = ConformanceBroker::start().await; - - let publisher = connected_client("shared-ret-pub", &broker).await; - publisher - .publish_with_options( - "sensor/temp", - b"25.5", - PublishOptions { - retain: true, - ..Default::default() - }, - ) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(200)).await; - - let shared_sub = connected_client("shared-ret-s", &broker).await; - let collector_shared = MessageCollector::new(); - shared_sub - .subscribe("$share/readers/sensor/temp", collector_shared.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(300)).await; - - assert_eq!( - collector_shared.count(), - 0, - "shared subscription must not receive retained messages on subscribe" - ); - - let regular_sub = connected_client("shared-ret-r", &broker).await; - let collector_regular = MessageCollector::new(); - regular_sub - .subscribe("sensor/temp", collector_regular.callback()) - .await - .unwrap(); - - collector_regular.wait_for_messages(1, TIMEOUT).await; - let msgs = collector_regular.get_messages(); - assert_eq!( - msgs.len(), - 1, - "regular subscription must receive retained message" - ); - assert_eq!(msgs[0].payload, b"25.5"); -} - -// --------------------------------------------------------------------------- -// Group 4: Shared Subscription Unsubscribe and Multiple Groups — Section 4.8 -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn shared_sub_unsubscribe_stops_delivery() { - let broker = ConformanceBroker::start().await; - - let subscriber = connected_client("shared-unsub-s", &broker).await; - let collector = MessageCollector::new(); - subscriber - .subscribe("$share/workers/tasks", collector.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("shared-unsub-pub", &broker).await; - publisher.publish("tasks", b"before").await.unwrap(); - - collector.wait_for_messages(1, TIMEOUT).await; - assert_eq!( - collector.count(), - 1, - "must receive message before unsubscribe" - ); - - subscriber - .unsubscribe("$share/workers/tasks") - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - publisher.publish("tasks", b"after").await.unwrap(); - - tokio::time::sleep(Duration::from_millis(300)).await; - - assert_eq!( - collector.count(), - 1, - "must not receive messages after unsubscribe from shared subscription" - ); -} - -#[tokio::test] -async fn shared_sub_multiple_groups_independent() { - let broker = ConformanceBroker::start().await; - - let client_a = connected_client("shared-grpA", &broker).await; - let collector_a = MessageCollector::new(); - client_a - .subscribe("$share/groupA/topic", collector_a.callback()) - .await - .unwrap(); - - let client_b = connected_client("shared-grpB", &broker).await; - let collector_b = MessageCollector::new(); - client_b - .subscribe("$share/groupB/topic", collector_b.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("shared-grp-pub", &broker).await; - publisher.publish("topic", b"payload").await.unwrap(); - - collector_a.wait_for_messages(1, TIMEOUT).await; - collector_b.wait_for_messages(1, TIMEOUT).await; - - assert_eq!( - collector_a.count(), - 1, - "groupA must receive the message independently" - ); - assert_eq!( - collector_b.count(), - 1, - "groupB must receive the message independently" - ); -} - -#[tokio::test] -async fn shared_sub_respects_granted_qos() { - let broker = ConformanceBroker::start().await; - let topic = format!("shared-qos/{}", unique_client_id("t")); - let shared_filter = format!("$share/qgrp/{topic}"); - - let mut sub = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let sub_id = unique_client_id("sub-sqos"); - sub.connect_and_establish(&sub_id, TIMEOUT).await; - - sub.send_raw(&RawPacketBuilder::subscribe_with_packet_id( - &shared_filter, - 0, - 1, - )) - .await - .unwrap(); - let (_, reason_codes) = sub - .expect_suback(TIMEOUT) - .await - .expect("must receive SUBACK"); - assert_eq!( - reason_codes[0], 0x00, - "shared subscription must be granted at QoS 0" - ); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let mut pub_raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let pub_id = unique_client_id("pub-sqos"); - pub_raw.connect_and_establish(&pub_id, TIMEOUT).await; - - pub_raw - .send_raw(&RawPacketBuilder::publish_qos1(&topic, b"qos1-data", 1)) - .await - .unwrap(); - let _ = pub_raw.expect_puback(TIMEOUT).await; - - let (qos, recv_topic, payload) = sub - .expect_publish(TIMEOUT) - .await - .expect("[MQTT-4.8.2-3] subscriber must receive message"); - - assert_eq!(recv_topic, topic); - assert_eq!(payload, b"qos1-data"); - assert_eq!( - qos, 0, - "[MQTT-4.8.2-3] message published at QoS 1 must be downgraded to granted QoS 0" - ); -} - -#[tokio::test] -async fn shared_sub_puback_error_discards() { - let broker = ConformanceBroker::start().await; - let topic = format!("shared-err/{}", unique_client_id("t")); - let shared_filter = format!("$share/errgrp/{topic}"); - - let mut worker = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let worker_id = unique_client_id("shared-err-w"); - worker.connect_and_establish(&worker_id, TIMEOUT).await; - worker - .send_raw(&RawPacketBuilder::subscribe_with_packet_id( - &shared_filter, - 1, - 1, - )) - .await - .unwrap(); - let _ = worker.expect_suback(TIMEOUT).await; - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("shared-err-pub", &broker).await; - let pub_opts = PublishOptions { - qos: QoS::AtLeastOnce, - ..Default::default() - }; - publisher - .publish_with_options(&topic, b"reject-me", pub_opts) - .await - .unwrap(); - - let received = worker - .expect_publish_with_id(TIMEOUT) - .await - .expect("sole shared subscriber must receive the message"); - - let (_, packet_id, _, _, _) = received; - worker - .send_raw(&RawPacketBuilder::puback_with_reason(packet_id, 0x80)) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(500)).await; - - let redistributed = worker.read_packet_bytes(Duration::from_secs(1)).await; - assert!( - redistributed.is_none(), - "[MQTT-4.8.2-6] message rejected with PUBACK error must not be redistributed" - ); -} diff --git a/crates/mqtt5-conformance/tests/section4_topic.rs b/crates/mqtt5-conformance/tests/section4_topic.rs deleted file mode 100644 index 41c2faa2..00000000 --- a/crates/mqtt5-conformance/tests/section4_topic.rs +++ /dev/null @@ -1,500 +0,0 @@ -use mqtt5_conformance::harness::{ - connected_client, unique_client_id, ConformanceBroker, MessageCollector, -}; -use mqtt5_conformance::raw_client::{RawMqttClient, RawPacketBuilder}; -use std::time::Duration; - -const TIMEOUT: Duration = Duration::from_secs(3); - -// --------------------------------------------------------------------------- -// Group 1: Topic Filter Wildcard Rules — Section 4.7.1 -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn multi_level_wildcard_must_be_last() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("mlwild-last"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( - "sport/tennis/#/ranking", - 0, - 1, - )) - .await - .unwrap(); - - let (_, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("[MQTT-4.7.1-1] must receive SUBACK"); - assert_eq!(reason_codes.len(), 1); - assert_eq!( - reason_codes[0], 0x8F, - "[MQTT-4.7.1-1] # not last must return TopicFilterInvalid (0x8F)" - ); -} - -#[tokio::test] -async fn multi_level_wildcard_must_be_full_level() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("mlwild-full"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id( - "sport/tennis#", - 0, - 1, - )) - .await - .unwrap(); - - let (_, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("[MQTT-4.7.1-1] must receive SUBACK"); - assert_eq!(reason_codes.len(), 1); - assert_eq!( - reason_codes[0], 0x8F, - "[MQTT-4.7.1-1] tennis# (not full level) must return TopicFilterInvalid" - ); -} - -#[tokio::test] -async fn single_level_wildcard_must_be_full_level() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("slwild-full"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_multiple( - &[("sport+", 0), ("sport/+tennis", 0)], - 1, - )) - .await - .unwrap(); - - let (_, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("[MQTT-4.7.1-2] must receive SUBACK"); - assert_eq!(reason_codes.len(), 2); - assert_eq!( - reason_codes[0], 0x8F, - "[MQTT-4.7.1-2] sport+ must return TopicFilterInvalid" - ); - assert_eq!( - reason_codes[1], 0x8F, - "[MQTT-4.7.1-2] sport/+tennis must return TopicFilterInvalid" - ); -} - -#[tokio::test] -async fn valid_wildcard_filters_accepted() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("wild-ok"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_multiple( - &[ - ("sport/+", 0), - ("sport/#", 0), - ("+/tennis/#", 0), - ("#", 0), - ("+", 0), - ], - 1, - )) - .await - .unwrap(); - - let (_, reason_codes) = raw - .expect_suback(TIMEOUT) - .await - .expect("must receive SUBACK for valid wildcards"); - assert_eq!(reason_codes.len(), 5); - for (i, rc) in reason_codes.iter().enumerate() { - assert_eq!(*rc, 0x00, "filter {i} must be granted QoS 0, got {rc:#04x}"); - } -} - -// --------------------------------------------------------------------------- -// Group 2: Dollar-Prefix Topic Matching — Section 4.7.2 -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn dollar_topics_not_matched_by_root_wildcards() { - let broker = ConformanceBroker::start().await; - - let sub_hash = connected_client("dollar-hash", &broker).await; - let collector_hash = MessageCollector::new(); - sub_hash - .subscribe("#", collector_hash.callback()) - .await - .unwrap(); - - let sub_plus = connected_client("dollar-plus", &broker).await; - let collector_plus = MessageCollector::new(); - sub_plus - .subscribe("+/info", collector_plus.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("dollar-pub", &broker).await; - publisher - .publish("$SYS/test", b"sys-payload") - .await - .unwrap(); - publisher.publish("$SYS/info", b"sys-info").await.unwrap(); - - tokio::time::sleep(Duration::from_millis(500)).await; - - assert_eq!( - collector_hash.count(), - 0, - "[MQTT-4.7.2-1] # must not match $SYS/test" - ); - assert_eq!( - collector_plus.count(), - 0, - "[MQTT-4.7.2-1] +/info must not match $SYS/info" - ); -} - -#[tokio::test] -async fn dollar_topics_matched_by_explicit_prefix() { - let broker = ConformanceBroker::start().await; - - let subscriber = connected_client("dollar-explicit", &broker).await; - let collector = MessageCollector::new(); - subscriber - .subscribe("$SYS/#", collector.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("dollar-explpub", &broker).await; - publisher.publish("$SYS/test", b"payload").await.unwrap(); - - collector.wait_for_messages(1, TIMEOUT).await; - - tokio::time::sleep(Duration::from_millis(300)).await; - - let msgs = collector.get_messages(); - let found = msgs - .iter() - .find(|m| m.topic == "$SYS/test") - .expect("$SYS/# must match $SYS/test"); - assert_eq!(found.payload, b"payload"); -} - -// --------------------------------------------------------------------------- -// Group 3: Topic Name/Filter Minimum Rules — Section 4.7.3 -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn topic_filter_must_not_be_empty() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("empty-filter"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::subscribe_with_packet_id("", 0, 1)) - .await - .unwrap(); - - let response = raw.expect_suback(TIMEOUT).await; - match response { - Some((_, reason_codes)) => { - assert_eq!(reason_codes.len(), 1); - assert_eq!( - reason_codes[0], 0x8F, - "[MQTT-4.7.3-1] empty filter must return TopicFilterInvalid" - ); - } - None => { - assert!( - raw.expect_disconnect(Duration::from_millis(100)).await, - "[MQTT-4.7.3-1] empty filter must cause SUBACK 0x8F or disconnect" - ); - } - } -} - -#[tokio::test] -async fn null_char_in_topic_name_rejected() { - let broker = ConformanceBroker::start().await; - let mut raw = RawMqttClient::connect_tcp(broker.socket_addr()) - .await - .unwrap(); - let client_id = unique_client_id("null-topic"); - raw.connect_and_establish(&client_id, TIMEOUT).await; - - raw.send_raw(&RawPacketBuilder::publish_qos0("test\0/topic", b"payload")) - .await - .unwrap(); - - assert!( - raw.expect_disconnect(TIMEOUT).await, - "[MQTT-4.7.3-2] null char in topic name must cause disconnect" - ); -} - -// --------------------------------------------------------------------------- -// Group 4: Topic Matching Correctness — Section 4.7 -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn single_level_wildcard_matches_one_level() { - let broker = ConformanceBroker::start().await; - - let subscriber = connected_client("sl-match", &broker).await; - let collector = MessageCollector::new(); - subscriber - .subscribe("sport/+/player", collector.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("sl-pub", &broker).await; - publisher - .publish("sport/tennis/player", b"match") - .await - .unwrap(); - publisher - .publish("sport/tennis/doubles/player", b"no-match") - .await - .unwrap(); - - assert!( - collector.wait_for_messages(1, TIMEOUT).await, - "sport/+/player must match sport/tennis/player" - ); - - tokio::time::sleep(Duration::from_millis(300)).await; - - let msgs = collector.get_messages(); - assert_eq!( - msgs.len(), - 1, - "sport/+/player must not match sport/tennis/doubles/player" - ); - assert_eq!(msgs[0].topic, "sport/tennis/player"); -} - -#[tokio::test] -async fn multi_level_wildcard_matches_all_descendants() { - let broker = ConformanceBroker::start().await; - - let subscriber = connected_client("ml-match", &broker).await; - let collector = MessageCollector::new(); - subscriber - .subscribe("sport/#", collector.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("ml-pub", &broker).await; - publisher.publish("sport", b"zero").await.unwrap(); - publisher.publish("sport/tennis", b"one").await.unwrap(); - publisher - .publish("sport/tennis/player", b"two") - .await - .unwrap(); - - assert!( - collector.wait_for_messages(3, TIMEOUT).await, - "sport/# must match sport, sport/tennis, and sport/tennis/player" - ); - - let msgs = collector.get_messages(); - let topics: Vec<&str> = msgs.iter().map(|m| m.topic.as_str()).collect(); - assert!(topics.contains(&"sport")); - assert!(topics.contains(&"sport/tennis")); - assert!(topics.contains(&"sport/tennis/player")); -} - -// --------------------------------------------------------------------------- -// Group 5: Message Delivery & Ordering — Sections 4.5 & 4.6 -// --------------------------------------------------------------------------- - -/// `[MQTT-4.5.0-1]` The server MUST deliver published messages to clients -/// that have matching subscriptions. -#[tokio::test] -async fn server_delivers_to_matching_subscribers() { - let broker = ConformanceBroker::start().await; - let tag = unique_client_id("deliver"); - let topic = format!("deliver/{tag}"); - - let collector_exact = MessageCollector::new(); - let sub_exact = connected_client("sub-exact", &broker).await; - sub_exact - .subscribe(&topic, collector_exact.callback()) - .await - .unwrap(); - - let filter_wild = format!("deliver/{tag}/+"); - let collector_wild = MessageCollector::new(); - let sub_wild = connected_client("sub-wild", &broker).await; - sub_wild - .subscribe(&filter_wild, collector_wild.callback()) - .await - .unwrap(); - - let collector_non = MessageCollector::new(); - let sub_non = connected_client("sub-non", &broker).await; - sub_non - .subscribe("other/topic", collector_non.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("pub-deliver", &broker).await; - publisher.publish(&topic, b"match-payload").await.unwrap(); - - assert!( - collector_exact.wait_for_messages(1, TIMEOUT).await, - "[MQTT-4.5.0-1] exact-match subscriber must receive the message" - ); - let msgs = collector_exact.get_messages(); - assert_eq!(msgs[0].payload, b"match-payload"); - - tokio::time::sleep(Duration::from_millis(300)).await; - assert_eq!( - collector_wild.count(), - 0, - "wildcard subscriber for deliver/tag/+ must not match deliver/tag" - ); - assert_eq!( - collector_non.count(), - 0, - "non-matching subscriber must not receive the message" - ); -} - -/// `[MQTT-4.6.0-5]` Message ordering MUST be preserved per topic for the -/// same `QoS` level. Send multiple `QoS` 0 messages on the same topic and -/// verify they arrive in order. -#[tokio::test] -async fn message_ordering_preserved_same_qos() { - let broker = ConformanceBroker::start().await; - let tag = unique_client_id("order"); - let topic = format!("order/{tag}"); - - let collector = MessageCollector::new(); - let subscriber = connected_client("sub-order", &broker).await; - subscriber - .subscribe(&topic, collector.callback()) - .await - .unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("pub-order", &broker).await; - for i in 0u32..5 { - publisher - .publish(&topic, format!("msg-{i}").into_bytes()) - .await - .unwrap(); - } - - assert!( - collector.wait_for_messages(5, TIMEOUT).await, - "subscriber should receive all 5 messages" - ); - - let msgs = collector.get_messages(); - for (i, msg) in msgs.iter().enumerate() { - let expected = format!("msg-{i}"); - assert_eq!( - msg.payload, - expected.as_bytes(), - "[MQTT-4.6.0-5] message {i} must be in order, expected {expected}, got {}", - String::from_utf8_lossy(&msg.payload) - ); - } -} - -// --------------------------------------------------------------------------- -// Group 6: Unicode Normalization — Section 4.7.3 -// --------------------------------------------------------------------------- - -/// `[MQTT-4.7.3-4]` Topic matching MUST NOT apply Unicode normalization. -/// U+00C5 (A-ring precomposed) and U+0041 U+030A (A + combining ring) -/// are visually identical but must be treated as different topics. -#[tokio::test] -async fn topic_matching_no_unicode_normalization() { - let broker = ConformanceBroker::start().await; - let tag = unique_client_id("unicode"); - - let precomposed = format!("uni/{tag}/\u{00C5}"); - let decomposed = format!("uni/{tag}/A\u{030A}"); - - let collector_pre = MessageCollector::new(); - let sub_pre = connected_client("sub-pre", &broker).await; - sub_pre - .subscribe(&precomposed, collector_pre.callback()) - .await - .unwrap(); - - let collector_dec = MessageCollector::new(); - let sub_dec = connected_client("sub-dec", &broker).await; - sub_dec - .subscribe(&decomposed, collector_dec.callback()) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let publisher = connected_client("pub-unicode", &broker).await; - publisher - .publish(&precomposed, b"precomposed") - .await - .unwrap(); - - assert!( - collector_pre.wait_for_messages(1, TIMEOUT).await, - "precomposed subscriber must receive message on precomposed topic" - ); - - tokio::time::sleep(Duration::from_millis(300)).await; - assert_eq!( - collector_dec.count(), - 0, - "[MQTT-4.7.3-4] decomposed subscriber must NOT receive message on precomposed topic \ - (no Unicode normalization)" - ); - - publisher.publish(&decomposed, b"decomposed").await.unwrap(); - - assert!( - collector_dec.wait_for_messages(1, TIMEOUT).await, - "decomposed subscriber must receive message on decomposed topic" - ); - - tokio::time::sleep(Duration::from_millis(300)).await; - let pre_msgs = collector_pre.get_messages(); - assert_eq!( - pre_msgs.len(), - 1, - "[MQTT-4.7.3-4] precomposed subscriber must NOT receive message on decomposed topic" - ); -} diff --git a/crates/mqtt5/src/broker/storage/file_backend.rs b/crates/mqtt5/src/broker/storage/file_backend.rs index 10d73363..49b57f32 100644 --- a/crates/mqtt5/src/broker/storage/file_backend.rs +++ b/crates/mqtt5/src/broker/storage/file_backend.rs @@ -448,7 +448,8 @@ impl StorageBackend for FileBackend { } async fn get_session(&self, client_id: &str) -> Result> { - if let Some(session) = self.sessions_cache.read().await.get(client_id).cloned() { + let cached = self.sessions_cache.read().await.get(client_id).cloned(); + if let Some(session) = cached { if session.is_expired() { self.remove_session(client_id).await?; return Ok(None); diff --git a/crates/mqtt5/src/callback.rs b/crates/mqtt5/src/callback.rs index caf828ea..d4c821d1 100644 --- a/crates/mqtt5/src/callback.rs +++ b/crates/mqtt5/src/callback.rs @@ -1,10 +1,13 @@ use crate::error::Result; use crate::packet::publish::PublishPacket; +#[cfg(feature = "opentelemetry")] +use crate::telemetry::propagation::UserProperty; use crate::validation::strip_shared_subscription_prefix; use parking_lot::Mutex; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use tokio::sync::mpsc; /// Type alias for publish callback functions pub type PublishCallback = Arc; @@ -20,6 +23,13 @@ pub(crate) struct CallbackEntry { topic_filter: String, } +struct DispatchItem { + callbacks: Vec, + message: PublishPacket, + #[cfg(feature = "opentelemetry")] + user_props: Vec, +} + /// Manages message callbacks for topic subscriptions pub struct CallbackManager { /// Callbacks indexed by topic filter for exact matches @@ -30,6 +40,8 @@ pub struct CallbackManager { callback_registry: Arc>>, /// Next callback ID next_id: Arc, + /// FIFO worker channel; lazily spawned on first dispatch. + dispatch_tx: OnceLock>, } impl CallbackManager { @@ -41,6 +53,44 @@ impl CallbackManager { wildcard_callbacks: Arc::new(Mutex::new(Vec::new())), callback_registry: Arc::new(Mutex::new(HashMap::new())), next_id: Arc::new(AtomicU64::new(1)), + dispatch_tx: OnceLock::new(), + } + } + + fn dispatch_sender(&self) -> &mpsc::UnboundedSender { + self.dispatch_tx.get_or_init(|| { + let (tx, mut rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + while let Some(item) = rx.recv().await { + Self::run_dispatch_item(item); + } + }); + tx + }) + } + + #[cfg(feature = "opentelemetry")] + fn run_dispatch_item(item: DispatchItem) { + use crate::telemetry::propagation; + propagation::with_remote_context(&item.user_props, || { + let span = tracing::info_span!( + "message_received", + topic = %item.message.topic_name, + qos = ?item.message.qos, + payload_size = item.message.payload.len(), + retain = item.message.retain, + ); + let _enter = span.enter(); + for callback in item.callbacks { + callback(item.message.clone()); + } + }); + } + + #[cfg(not(feature = "opentelemetry"))] + fn run_dispatch_item(item: DispatchItem) { + for callback in item.callbacks { + callback(item.message.clone()); } } @@ -164,34 +214,24 @@ impl CallbackManager { } } - for callback in callbacks_to_call { - let message = message.clone(); + if callbacks_to_call.is_empty() { + return Ok(()); + } + + #[cfg(feature = "opentelemetry")] + let user_props = { + use crate::telemetry::propagation; + propagation::extract_user_properties(&message.properties) + }; + + let item = DispatchItem { + callbacks: callbacks_to_call, + message: message.clone(), #[cfg(feature = "opentelemetry")] - { - use crate::telemetry::propagation; - let user_props = propagation::extract_user_properties(&message.properties); - tokio::spawn(async move { - propagation::with_remote_context(&user_props, || { - let span = tracing::info_span!( - "message_received", - topic = %message.topic_name, - qos = ?message.qos, - payload_size = message.payload.len(), - retain = message.retain, - ); - let _enter = span.enter(); - callback(message); - }); - }); - } + user_props, + }; - #[cfg(not(feature = "opentelemetry"))] - { - tokio::spawn(async move { - callback(message); - }); - } - } + let _ = self.dispatch_sender().send(item); Ok(()) } diff --git a/crates/mqttv5-cli/Cargo.toml b/crates/mqttv5-cli/Cargo.toml index ca129442..6b9ba613 100644 --- a/crates/mqttv5-cli/Cargo.toml +++ b/crates/mqttv5-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqttv5-cli" -version = "0.27.0" +version = "0.27.1" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -22,7 +22,7 @@ opentelemetry = ["mqtt5/opentelemetry"] codec = ["mqtt5/codec-all"] [dependencies] -mqtt5 = "0.29" +mqtt5 = "0.31" anyhow = "1.0" tracing = "0.1" serde = { version = "1.0", features = ["derive"] }