diff --git a/.fusa-reqs.json b/.fusa-reqs.json index b6e1aea..bd9768e 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -1186,64 +1186,64 @@ }, { "id": "REQ-RL-001", - "title": "Token bucket with configurable rate and burst", - "text": "ratelimit::Controller shall implement a token bucket with configurable rate (tokens/s) and burst capacity.", + "title": "Manager::admit admits while the domain's bucket has tokens", + "text": "rcp::ratelimit::Manager::admit shall return a success (falsy) std::error_code for a target EndpointKey whose token bucket has at least one token available.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-RL-002", - "title": "Tokens refilled based on elapsed time", - "text": "The token bucket shall refill tokens proportionally to elapsed wall-clock time since the last take().", + "title": "Tokens refill proportionally to elapsed now_ms", + "text": "rcp::ratelimit::TokenBucket::take shall refill tokens proportionally to the elapsed now_ms since the previous take() call on the same bucket, before evaluating admission, capped at Config::burst.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-RL-003", - "title": "ErrBusy when bucket exhausted", - "text": "send() shall return ErrBusy immediately, without calling inner send(), when fewer than 1 token remains.", + "title": "admit() returns ErrAdmissionDenied once the bucket is exhausted", + "text": "rcp::ratelimit::Manager::admit shall return ratelimit::ErrAdmissionDenied, unchanged, once fewer than one token remains in the target EndpointKey's bucket.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-RL-004", - "title": "Critical commands exempt when configured", - "text": "When Config::exempt_critical is true, Priority::Critical commands shall bypass the token bucket.", + "title": "Safety-tagged requests bypass the bucket when exempted", + "text": "When Config::exempt_safety_tagged is true, rcp::ratelimit::Manager::admit shall return success for an is_safety_tagged=true request without consuming a token, regardless of the target bucket's remaining token count.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-RL-005", - "title": "Non-critical commands not exempt", - "text": "Priority::Normal and Priority::High commands shall always be subject to the token bucket.", + "title": "Safety-tagged requests are throttled when not exempted", + "text": "When Config::exempt_safety_tagged is false, rcp::ratelimit::Manager::admit shall subject an is_safety_tagged=true request to the same token-bucket admission check as any other request.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-RL-006", - "title": "Zone mismatch rejected", - "text": "send() shall return ErrZoneMismatch if cmd.zone differs from the inner controller's zone.", + "title": "Distinct (stream, endpoint) domains have independent buckets", + "text": "rcp::ratelimit::Manager shall maintain an independent token bucket per distinct EndpointKey, such that exhausting one EndpointKey's bucket shall not affect admission for any other EndpointKey.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-RL-007", - "title": "Close stops sends", - "text": "close() shall cause subsequent send() calls to return ErrClosed.", + "title": "reset() rebuilds a domain's bucket at full burst", + "text": "rcp::ratelimit::Manager::reset shall discard the target EndpointKey's bucket state, such that its next admit() call is evaluated against a freshly constructed bucket at full Config::burst capacity.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-RL-008", - "title": "send() after close returns ErrClosed", - "text": "Any send() call after close() shall return ErrClosed.", + "title": "ErrAdmissionDenied is a distinct error code", + "text": "ratelimit::ErrAdmissionDenied shall be a non-zero std::error_code in a category distinct from every other rcp error category.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1682,24 +1682,24 @@ }, { "id": "REQ-AUTH-001", - "title": "AuthController enforces AccessPolicy on every send", - "text": "AuthController.send() shall check the presented Identity against the AccessPolicy before forwarding any Command. If the identity is not permitted, ErrForbidden shall be returned without invoking the inner controller.", + "title": "AccessPolicy::permit grants access only on a full (identity, stream, endpoint, kind) match", + "text": "rcp::authz::AccessPolicy::permit shall return true only when at least one PolicyEntry for the given identity matches the given target stream, target endpoint, and request kind simultaneously; it shall return false if any one axis does not match.", "standard": "iec62443", "level": "SL-2", "asil": "ASIL-B" }, { "id": "REQ-AUTH-002", - "title": "Empty zone/type set means all allowed", - "text": "A PolicyEntry with an empty zones set shall permit any Zone. A PolicyEntry with an empty cmd_types set shall permit any CommandType.", + "title": "Empty streams/endpoints/kinds sets mean unrestricted on that axis", + "text": "A PolicyEntry with an empty streams set shall match any target stream. A PolicyEntry with an empty endpoints set shall match any target endpoint. A PolicyEntry with an empty kinds set shall match any request kind.", "standard": "iec62443", "level": "SL-2", "asil": "ASIL-B" }, { "id": "REQ-AUTH-003", - "title": "Identity resolver takes precedence over set_identity", - "text": "If an IdentityFn is supplied to AuthController, it shall be called on each send() and its result shall be used as the caller identity, superseding any value set via set_identity().", + "title": "Identity not covered by any PolicyEntry is denied", + "text": "rcp::authz::AccessPolicy::permit shall return false for any identity with no PolicyEntry present in the policy, regardless of the requested stream, endpoint, or kind.", "standard": "iec62443", "level": "SL-2", "asil": "ASIL-B" @@ -1714,24 +1714,24 @@ }, { "id": "REQ-AUTH-005", - "title": "AuthController::zone() delegates to inner", - "text": "AuthController::zone() shall return the same zone as the wrapped inner controller.", + "title": "check() returns ErrForbidden when permit() would deny", + "text": "rcp::authz::check shall return authz::ErrForbidden, unchanged, whenever the equivalent AccessPolicy::permit() call would return false.", "standard": "iec62443", "level": "SL-2", "asil": "ASIL-A" }, { "id": "REQ-AUTH-006", - "title": "AuthController::subscribe() delegates to inner", - "text": "AuthController::subscribe() shall forward the call to the inner controller unchanged.", + "title": "check() returns success when permit() would allow", + "text": "rcp::authz::check shall return a default-constructed (falsy) std::error_code whenever the equivalent AccessPolicy::permit() call would return true.", "standard": "iec62443", "level": "SL-2", "asil": "ASIL-A" }, { "id": "REQ-AUTH-007", - "title": "AuthController::close() delegates to inner", - "text": "AuthController::close() shall forward the call to the inner controller and return its error code.", + "title": "An identity with zero policy entries is denied by default", + "text": "rcp::authz::AccessPolicy::permit shall return false for any identity for which AccessPolicy::allow() has never been called, i.e. the policy defaults to deny, not allow.", "standard": "iec62443", "level": "SL-2", "asil": "ASIL-A" @@ -1739,7 +1739,7 @@ { "id": "REQ-AUTH-008", "title": "ErrForbidden is a distinct error code", - "text": "authz::ErrForbidden shall be a non-zero std::error_code distinct from all other rcp error codes.", + "text": "authz::ErrForbidden shall be a non-zero std::error_code in a category distinct from every other rcp error category.", "standard": "iec62443", "level": "SL-2", "asil": "ASIL-B" diff --git a/CMakeLists.txt b/CMakeLists.txt index 5ef77ee..0b9fa96 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.10.0 + VERSION 2.11.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/ROADMAP.md b/ROADMAP.md index a00ee21..d2f5a3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -873,6 +873,57 @@ file's own disclaimer pattern in `rcp/e2e.hpp`/`rcp/regmap.hpp`/ already does one layer up - Depends on: RC Server lifecycle & register map (v2.1.0) +**Done (v2.11.0):** Both `rcp/authz.hpp` and `rcp/ratelimit.hpp` are ADAPTed +in place per the Satellite Package Disposition table's entries for these +two files — the `AccessPolicy`/`PolicyEntry`/`AuthzErrc` and +`Config`/token-bucket shapes survive; only their keying axes change. +`authz.hpp`'s `PolicyEntry` and `AccessPolicy::permit` now key on +(`Identity`, target stream, target endpoint, request kind) instead of +(`Identity`, `Zone`, `CommandType`): a target stream is the same opaque +`uint64_t` per-connection key `rcp/regmap.hpp`'s `Ep0` and +`rcp/watchdog.hpp`'s `Manager` already use (typically an +`avtp::StreamId::to_u64()`), a target endpoint is an `avtp::ByteBusId`, and +a request kind is `rcp::request::RequestCategory` (v2.5.0) — already the +single taxonomy spanning the mandatory standard kind and every +conditional/cancellation kind. Per the roadmap's own framing, this package +does not reimplement `regmap.hpp`'s root-client/per-endpoint-owner access +model (v2.1.0) — it has no dependency on `regmap.hpp` at all — it adds an +independent policy gate a dispatch call site consults *in addition to* +that model. The pre-replacement `AuthController` wrapper is dropped +outright rather than rebound: there is no longer a single unified +`Controller::send()` chokepoint to wrap (that unification, if any, does +not land until the CLI/capi/adapt rebuilds at v2.16.0), so `permit`/`check` +are standalone primitives a dispatch call site invokes directly, the same +"primitives driven by the embedding application" pattern +`rcp/e2e.hpp`/`rcp/watchdog.hpp`/`rcp/request.hpp` already established. + +`ratelimit.hpp`'s token bucket is now one per (target stream, target +endpoint) admission-control domain (`ratelimit::EndpointKey`), keyed the +same stream/`byte_bus_id` way, instead of one per `Controller` +instance/`Zone`; `ratelimit::Manager` multiplexes domains lazily, mirroring +`rcp/watchdog.hpp`'s `Manager` multiplexing one `StreamWatchdog` per +stream. The pre-replacement `Priority::Critical` bypass — `Priority` being +`rcp/prioqueue.hpp`'s whole client-side-priority-wrapper concept, marked +DEPRECATE outright per the disposition table, with no TC18 analog — is +replaced by an explicit `is_safety_tagged` argument callers derive from +`rcp::request::is_safety_variant` (v2.6.0): the traffic class that +ultimately drives an endpoint through its configured safe state once it +executes is the closest real analog to "must not be dropped by an +admission-control layer" here. Like every other Phase 14 primitive header, +`TokenBucket::take`/`Manager::admit` take an explicit `now_ms` rather than +reading a clock internally, the same convention `rcp/e2e.hpp`'s +`RxWatchdog` and `rcp/watchdog.hpp`'s `StreamWatchdog` already use. + +Nothing else in this tree depended on the old `AuthController`/ +`ratelimit::Controller` APIs (only their own tests did), so no legacy shim +was needed, same as every other Phase 14 rebuild. New/rewritten coverage +lives in `tests/test_authz.cpp` (`REQ-AUTH-001..008`, entirely rewritten) +and `tests/test_ratelimit.cpp` (`REQ-RL-001..008`, entirely rewritten), +traced in `.fusa-reqs.json`. Full bit-for-bit conformance against other +TC18 implementations is not claimed — see this file's own disclaimer +pattern in `rcp/regmap.hpp`/`rcp/request.hpp`/`rcp/e2e.hpp`/ +`rcp/watchdog.hpp`. + ### 56. Test & Simulation Harness Rebuild (v2.12.0) - Rebuild `mock.hpp` as an in-process RC Server simulator implementing the diff --git a/include/rcp/authz.hpp b/include/rcp/authz.hpp index 65ee497..93a12bd 100644 --- a/include/rcp/authz.hpp +++ b/include/rcp/authz.hpp @@ -7,24 +7,64 @@ // fusa:req REQ-AUTH-007 // fusa:req REQ-AUTH-008 -// Command-level access control (ISO 21434 / IEC 62443 SL-2). +// Identity-based access-policy check (ISO 21434 / IEC 62443 SL-2) — a gate +// an embedding request-dispatch call site consults *in addition to* +// rcp/regmap.hpp's own root-client/per-endpoint-owner access model, not a +// replacement for it. // -// AuthController wraps any rcp::Controller and checks each Command against a -// signed AccessPolicy before forwarding. A policy declares which caller -// identities may send which CommandType values to which zones. +// ROADMAP.md milestone 55, "Authorization & Admission-Control Rebind +// (v2.11.0)": this header ADAPTs its pre-replacement content per the +// Satellite Package Disposition table's entry for `authz.hpp` — the +// `AccessPolicy`/`PolicyEntry`/`AuthzErrc` shapes survive, rebound from +// keying on (`Identity`, the pre-replacement `Zone`, the pre-replacement +// `CommandType`) onto (`Identity`, target server, target endpoint, request +// kind), addressed the way the rest of this codebase has addressed those +// concepts since v2.0.0/v2.1.0/v2.5.0: a target server is the opaque +// per-connection `uint64_t` stream key rcp/regmap.hpp's `Ep0` and +// rcp/watchdog.hpp's `Manager` already use (typically an +// `avtp::StreamId::to_u64()` — this header does not know or care how that +// key maps to a stream, only that it is stable for the lifetime of the +// connection, same disclaimer as those two headers); a target endpoint is +// an `avtp::ByteBusId`, unique only within its owning stream (extraction +// §2.1); a request kind is `rcp::request::RequestCategory` (v2.5.0), +// already the single taxonomy spanning the mandatory standard kind and +// every conditional/cancellation kind. // -// The default Identity is a string (certificate CN or pre-shared key label). -// Full certificate-chain validation is the responsibility of the TLS layer -// (include/rcp/tls.hpp); authz only checks the presented identity against the -// policy table. +// `rcp/regmap.hpp`'s `Ep0::claim_root_client`/`check_write_access` already +// grant a claimed root client whole-register-map write access, and a +// non-root owner endpoint-scoped write access, natively at the protocol +// level (v2.1.0) — this package's job narrows to policy layered *on top +// of* that (the roadmap's own framing), not reimplementing it. A server +// dispatch site that wants both gates enforced calls this header's +// `check`/`permit` and `regmap::Ep0`'s access checks independently; this +// header has no dependency on `regmap.hpp` and does not call into it. +// +// There is no longer a single unified `Controller::send()` chokepoint +// (that unification, if any, does not land until the CLI/capi/adapt +// rebuilds at v2.16.0 per the roadmap), so — unlike the pre-replacement +// `AuthController` wrapper — this header does not wrap or own a send +// path. `AccessPolicy::permit`/`check` are primitives a decode/dispatch +// call site invokes directly before acting on a decoded request, the same +// "primitives driven by the embedding application" pattern +// rcp/e2e.hpp/rcp/watchdog.hpp/rcp/request.hpp already established for +// every other Phase 13/14 header. +// +// Field names and behavior below implement TC18's *behavior* as described +// in an internal structured extraction of the OPEN Alliance TC18 Remote +// Control Protocol Specification v0.5.1_RC; no text from that document is +// reproduced here. The policy-table shape and stream/endpoint-keying +// convention chosen in this file are this implementation's own encoding, +// same as the equivalent disclaimers in rcp/regmap.hpp, rcp/request.hpp, +// rcp/e2e.hpp, and rcp/watchdog.hpp. #pragma once -#include "rcp.hpp" +#include +#include -#include -#include +#include #include #include +#include #include #include @@ -34,16 +74,34 @@ namespace authz { // ── Identity ────────────────────────────────────────────────────────────────── // Identity is the caller's verified string identifier (certificate CN or -// pre-shared label). Presented by the caller via Context::set_identity() -// or a thread-local before each send(). +// pre-shared label). Full certificate-chain validation is the +// responsibility of the TLS layer (include/rcp/tls.hpp); this header only +// checks a presented identity against the policy table below. using Identity = std::string; // ── PolicyEntry ─────────────────────────────────────────────────────────────── +// A policy entry grants `identity` access to any combination of streams, +// endpoints, and request kinds it names; an empty set on any one of the +// three axes means "unrestricted on that axis", mirroring the +// pre-replacement PolicyEntry's own empty-set-means-all convention. struct PolicyEntry { - Identity identity; - std::unordered_set zones; // empty = all zones - std::unordered_set cmd_types; // empty = all types + Identity identity; + + // Target server: empty = any stream. Populated with + // avtp::StreamId::to_u64() values (or whatever opaque per-connection + // key the embedding transport assigns — see this file's header + // comment). + std::unordered_set streams; + + // Target endpoint: empty = any endpoint on a permitted stream. A + // byte_bus_id is only unique within its owning stream, so this axis is + // meaningful only in combination with the streams axis above, not on + // its own. + std::unordered_set endpoints; + + // Request kind: empty = any kind. + std::unordered_set kinds; }; // ── AccessPolicy ────────────────────────────────────────────────────────────── @@ -56,21 +114,25 @@ class AccessPolicy { entries_.push_back(std::move(entry)); } - // permit checks whether identity may send cmd to zone. - bool permit(const Identity& id, Zone zone, CommandType type) const { + // permit checks whether `id` may send a `kind` request to `endpoint` on + // `stream` — true iff at least one entry for `id` matches all three + // axes (an empty set on an axis matches anything on that axis). + bool permit(const Identity& id, uint64_t stream, avtp::ByteBusId endpoint, + request::RequestCategory kind) const { std::lock_guard lk(mu_); for (auto& e : entries_) { if (e.identity != id) continue; - bool zone_ok = e.zones.empty() || e.zones.count(zone); - bool type_ok = e.cmd_types.empty() || e.cmd_types.count(type); - if (zone_ok && type_ok) return true; + bool stream_ok = e.streams.empty() || e.streams.count(stream); + bool endpoint_ok = e.endpoints.empty() || e.endpoints.count(endpoint); + bool kind_ok = e.kinds.empty() || e.kinds.count(kind); + if (stream_ok && endpoint_ok && kind_ok) return true; } return false; } private: - mutable std::mutex mu_; - std::vector entries_; + mutable std::mutex mu_; + std::vector entries_; }; // ── ErrForbidden ────────────────────────────────────────────────────────────── @@ -82,7 +144,7 @@ inline const std::error_category& authz_category() noexcept { const char* name() const noexcept override { return "rcp.authz"; } std::string message(int ev) const override { if (static_cast(ev) == AuthzErrc::forbidden) - return "rcp/authz: command forbidden by access policy"; + return "rcp/authz: request forbidden by access policy"; return "rcp/authz: unknown error"; } }; @@ -96,57 +158,14 @@ inline std::error_code make_error_code(AuthzErrc e) noexcept { inline const std::error_code ErrForbidden = make_error_code(AuthzErrc::forbidden); -// ── AuthController ──────────────────────────────────────────────────────────── - -// AuthController wraps any Controller and enforces an AccessPolicy. -// Set the caller identity via set_identity() before each send() call. -class AuthController final : public rcp::Controller { -public: - using IdentityFn = std::function; - - // Construct with a shared policy and a resolver that returns the current - // caller identity (e.g. a thread-local or TLS session attribute). - AuthController(std::shared_ptr inner, - std::shared_ptr policy, - IdentityFn identity_fn = {}) - : inner_(std::move(inner)) - , policy_(std::move(policy)) - , identity_fn_(std::move(identity_fn)) {} - - Zone zone() const noexcept override { return inner_->zone(); } - - std::error_code send(const rcp::Context& ctx, - const Command& cmd, - Response& out) override { - Identity id = identity_fn_ ? identity_fn_() : identity_; - if (!policy_->permit(id, cmd.zone, cmd.type)) - return ErrForbidden; - return inner_->send(ctx, cmd, out); - } - - std::error_code subscribe(const rcp::Context& ctx, - std::shared_ptr& out) override { - return inner_->subscribe(ctx, out); - } - - std::error_code close() override { return inner_->close(); } - - // set_identity sets a fixed caller identity for this controller instance. - void set_identity(Identity id) { identity_ = std::move(id); } - -private: - std::shared_ptr inner_; - std::shared_ptr policy_; - IdentityFn identity_fn_; - Identity identity_; -}; - -inline std::shared_ptr new_controller( - std::shared_ptr inner, - std::shared_ptr policy, - AuthController::IdentityFn id_fn = {}) { - return std::make_shared( - std::move(inner), std::move(policy), std::move(id_fn)); +// check is permit() re-expressed as the error-code idiom +// rcp/regmap.hpp's Ep0::check_write_access already uses elsewhere in this +// codebase — a dispatch call site that wants to `return` straight out of a +// failed check gets ErrForbidden without re-deriving it from a bool at +// every call site. +inline std::error_code check(const AccessPolicy& policy, const Identity& id, uint64_t stream, + avtp::ByteBusId endpoint, request::RequestCategory kind) { + return policy.permit(id, stream, endpoint, kind) ? std::error_code{} : ErrForbidden; } } // namespace authz diff --git a/include/rcp/ratelimit.hpp b/include/rcp/ratelimit.hpp index ae4efba..80a5e62 100644 --- a/include/rcp/ratelimit.hpp +++ b/include/rcp/ratelimit.hpp @@ -7,91 +7,196 @@ // fusa:req REQ-RL-007 // fusa:req REQ-RL-008 -// Per-zone token-bucket admission control against command flooding (SG-009, H-009). +// Per-endpoint token-bucket admission control (SG-009, H-009) — the +// client-side defense the specification leaves overrun-avoidance to +// (extraction §3.14): the specification models a request-queue overrun as +// an unconditional drop with no server-side flow control, so this package +// stays exactly where the pre-replacement design already put it, one layer +// above that unconditional drop. // -// Controller wraps any rcp::Controller and enforces a sustained rate limit and -// burst capacity. Priority::Critical commands bypass the bucket by default so -// watchdog kicks and emergency actuations are never throttled. All other -// commands consume one token; send() returns ErrBusy immediately when exhausted. +// ROADMAP.md milestone 55, "Authorization & Admission-Control Rebind +// (v2.11.0)": this header ADAPTs its pre-replacement content per the +// Satellite Package Disposition table's entry for `ratelimit.hpp` — the +// token-bucket admission-control concept and `Config` shape survive, +// rebound from one bucket per `Controller` instance (i.e., implicitly one +// per pre-replacement `Zone`) onto one bucket per (target server, target +// endpoint) admission-control domain, addressed the same +// stream-key/`avtp::ByteBusId` way rcp/authz.hpp (this milestone) and +// rcp/watchdog.hpp (v2.10.0) already address those concepts — see either +// header's own comment for the "opaque uint64_t stream key, typically +// avtp::StreamId::to_u64()" convention this file reuses unchanged. +// +// The pre-replacement `Priority::Critical` bypass has no analog in the +// target model — `rcp/prioqueue.hpp`'s whole client-side-priority-wrapper +// concept is DEPRECATE per the disposition table, not carried forward. +// The traffic class that must not be throttled here is instead +// `rcp::request::is_safety_variant`'s three MSB-set (0x8x) safety-tagged +// opcodes (v2.6.0): a safety-tagged request is what ultimately drives an +// endpoint through its configured safe state once it executes (see +// rcp/e2e.hpp's `may_execute_now`), making it the closest real analog to +// "must not be dropped by an admission-control layer" the roadmap calls +// for — `Manager::admit` below takes that classification as an explicit +// caller-supplied `is_safety_tagged` argument rather than re-deriving it, +// since a caller evaluating admission for a not-yet-fully-decoded request +// is the one place that classification is known. +// +// Like every other Phase 14 primitive header, this module has no clock, +// thread, or I/O of its own: `TokenBucket::take`/`Manager::admit` take an +// explicit `now_ms` rather than reading a clock internally, the same +// "explicit time, no clock of its own" convention rcp/e2e.hpp's +// `RxWatchdog` and rcp/watchdog.hpp's `StreamWatchdog` already use — +// deciding what clock feeds `now_ms` is the embedding application's job. +// +// Field names and behavior below implement TC18's *behavior* as described +// in an internal structured extraction of the specification named above; +// no text from that document is reproduced here. The token-bucket +// refill/burst mechanics and the per-endpoint-domain keying convention +// chosen in this file are this implementation's own encoding, same as the +// equivalent disclaimers in rcp/authz.hpp, rcp/e2e.hpp, and +// rcp/watchdog.hpp. #pragma once -#include "rcp.hpp" +#include -#include -#include -#include +#include +#include +#include +#include namespace rcp { namespace ratelimit { +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class RateLimitErrc : int { + admission_denied = 1, // the target endpoint's token bucket is exhausted +}; + +inline const std::error_category& ratelimit_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.ratelimit"; } + std::string message(int ev) const override { + if (static_cast(ev) == RateLimitErrc::admission_denied) + return "rcp/ratelimit: admission denied — endpoint token bucket exhausted"; + return "rcp/ratelimit: unknown error"; + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(RateLimitErrc e) noexcept { + return {static_cast(e), ratelimit_category()}; +} + +inline const std::error_code ErrAdmissionDenied = make_error_code(RateLimitErrc::admission_denied); + +// ── Config ──────────────────────────────────────────────────────────────────── + struct Config { - double rate = 100.0; // sustained token refill rate (tokens/second) - int burst = 20; // maximum token accumulation - bool exempt_critical = true; // if true, Priority::Critical bypasses bucket + double rate = 100.0; // sustained token refill rate (tokens/second) + int burst = 20; // maximum token accumulation + bool exempt_safety_tagged = true; // if true, is_safety_tagged admit() calls bypass the bucket }; -// DefaultConfig returns ASIL-B recommended values. +// default_config returns ASIL-B recommended values. inline Config default_config() { return Config{}; } -class Controller final : public rcp::Controller { -public: - explicit Controller(std::shared_ptr inner, Config cfg = {}) - : inner_(std::move(inner)), cfg_(cfg), - tokens_(static_cast(cfg.burst)), - last_(std::chrono::steady_clock::now()) {} - - Zone zone() const noexcept override { return inner_->zone(); } - - std::error_code send(const rcp::Context& ctx, - const Command& cmd, - Response& out) override { - if (closed_.load(std::memory_order_acquire)) - return ErrClosed; - bool exempt = cfg_.exempt_critical && (cmd.priority == Priority::Critical); - if (!exempt && !take()) - return ErrBusy; - return inner_->send(ctx, cmd, out); - } +// ── EndpointKey ─────────────────────────────────────────────────────────────── +// One admission-control domain: the (target server, target endpoint) pair +// a request-queue's finite capacity actually belongs to. `stream_key` is +// the same opaque, caller-derived uint64_t rcp/authz.hpp and +// rcp/watchdog.hpp key on; `byte_bus_id` is only unique within its owning +// stream (extraction §2.1), so the pair together, not either field alone, +// identifies one domain. + +struct EndpointKey { + uint64_t stream_key = 0; + avtp::ByteBusId byte_bus_id = 0; +}; - std::error_code subscribe(const rcp::Context& ctx, - std::shared_ptr& out) override { - return inner_->subscribe(ctx, out); - } +inline bool operator<(const EndpointKey& a, const EndpointKey& b) noexcept { + if (a.stream_key != b.stream_key) return a.stream_key < b.stream_key; + return a.byte_bus_id < b.byte_bus_id; +} - std::error_code close() override { - closed_.store(true, std::memory_order_release); - return inner_->close(); - } +inline bool operator==(const EndpointKey& a, const EndpointKey& b) noexcept { + return a.stream_key == b.stream_key && a.byte_bus_id == b.byte_bus_id; +} -private: - std::shared_ptr inner_; - Config cfg_; - std::atomic closed_{false}; - - std::mutex mu_; - double tokens_; - std::chrono::steady_clock::time_point last_; - - bool take() { - std::lock_guard lk(mu_); - auto now = std::chrono::steady_clock::now(); - double secs = std::chrono::duration(now - last_).count(); - last_ = now; - tokens_ += secs * cfg_.rate; - if (tokens_ > static_cast(cfg_.burst)) - tokens_ = static_cast(cfg_.burst); +// ── TokenBucket ─────────────────────────────────────────────────────────────── +// One domain's own bucket state. take() refills proportionally to elapsed +// time since the previous call before consuming a token, capped at +// cfg.burst; the very first call establishes the baseline without +// refilling (nothing has elapsed yet against an unset last-seen time), +// mirroring rcp/e2e.hpp's RxWatchdog::kick bootstrap handling. +class TokenBucket { +public: + explicit TokenBucket(Config cfg) noexcept + : cfg_(cfg), tokens_(static_cast(cfg.burst)) {} + + bool take(uint64_t now_ms) noexcept { + if (has_last_) { + double secs = static_cast(now_ms - last_ms_) / 1000.0; + tokens_ += secs * cfg_.rate; + if (tokens_ > static_cast(cfg_.burst)) + tokens_ = static_cast(cfg_.burst); + } + has_last_ = true; + last_ms_ = now_ms; if (tokens_ < 1.0) return false; tokens_ -= 1.0; return true; } + +private: + Config cfg_; + bool has_last_ = false; + uint64_t last_ms_ = 0; + double tokens_; }; -inline std::shared_ptr new_controller(std::shared_ptr inner, - Config cfg = {}) { - return std::make_shared(std::move(inner), cfg); -} +// ── Manager ─────────────────────────────────────────────────────────────────── +// Multiplexes one TokenBucket per EndpointKey, created lazily on first use +// with this Manager's shared Config — the multi-domain layer the roadmap +// calls for, mirroring rcp/watchdog.hpp's Manager multiplexing one +// StreamWatchdog per stream_key. +class Manager { +public: + explicit Manager(Config cfg = default_config()) : cfg_(cfg) {} + + // admit evaluates one inbound request's admission against `key`'s + // token bucket. A request the caller marks is_safety_tagged bypasses + // the bucket entirely when cfg.exempt_safety_tagged is set — see this + // file's header comment for why that classification, not the + // pre-replacement Priority::Critical, is what must not be throttled + // here. Returns ErrAdmissionDenied, unchanged, when the bucket is + // exhausted; other domains' buckets are unaffected by that outcome. + std::error_code admit(const EndpointKey& key, bool is_safety_tagged, uint64_t now_ms) { + if (cfg_.exempt_safety_tagged && is_safety_tagged) return {}; + auto it = buckets_.find(key); + if (it == buckets_.end()) + it = buckets_.emplace(key, TokenBucket(cfg_)).first; + return it->second.take(now_ms) ? std::error_code{} : ErrAdmissionDenied; + } + + // reset discards a domain's bucket state; its next admit() call + // rebuilds it from this Manager's shared Config as if never seen. + void reset(const EndpointKey& key) noexcept { buckets_.erase(key); } + + size_t domain_count() const noexcept { return buckets_.size(); } + +private: + Config cfg_; + std::map buckets_; +}; } // namespace ratelimit } // namespace rcp + +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index 7653385..0bfe074 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "2.10.0"; +constexpr std::string_view kVersion = "2.11.0"; } // namespace rcp diff --git a/tests/test_authz.cpp b/tests/test_authz.cpp index 7857c7d..9170b9d 100644 --- a/tests/test_authz.cpp +++ b/tests/test_authz.cpp @@ -6,156 +6,129 @@ // fusa:test REQ-AUTH-006 // fusa:test REQ-AUTH-007 // fusa:test REQ-AUTH-008 -#include -#include "rcp/authz.hpp" -#include "rcp/mock.hpp" +// Tests for rcp/authz.hpp — identity/(server, endpoint, request kind) +// access-policy check (ROADMAP.md milestone 55, "Authorization & +// Admission-Control Rebind", v2.11.0). + +#include +#include #include #include #include using namespace rcp; +using rcp::request::RequestCategory; -static std::shared_ptr make_ctrl(Zone z = Zone::FrontLeft) { - return std::make_shared(z); -} +namespace { -TEST_CASE("authz: permitted identity succeeds", "[authz]") { - auto policy = std::make_shared(); - policy->allow({"alice", {Zone::FrontLeft}, {CommandType::Set}}); +constexpr uint64_t kStreamA = 0x0102030405060001ULL; +constexpr uint64_t kStreamB = 0x0102030405060002ULL; - auto inner = make_ctrl(); - auto ctrl = authz::new_controller(inner, policy); - ctrl->set_identity("alice"); +} // namespace - Command cmd; - cmd.zone = Zone::FrontLeft; - cmd.type = CommandType::Set; - Response resp; - auto ec = ctrl->send(Context{}, cmd, resp); - REQUIRE_FALSE(ec); -} - -TEST_CASE("authz: denied identity returns ErrForbidden", "[authz]") { - auto policy = std::make_shared(); - policy->allow({"alice", {Zone::FrontLeft}, {CommandType::Set}}); +// ── Basic permit / deny ─────────────────────────────────────────────────────── - auto inner = make_ctrl(); - auto ctrl = authz::new_controller(inner, policy); - ctrl->set_identity("eve"); +TEST_CASE("authz: exact-match entry permits the identity", "[authz][REQ-AUTH-001]") { + authz::AccessPolicy policy; + policy.allow({"alice", {kStreamA}, {5}, {RequestCategory::Standard}}); - Command cmd; - cmd.zone = Zone::FrontLeft; - cmd.type = CommandType::Set; - Response resp; - auto ec = ctrl->send(Context{}, cmd, resp); - REQUIRE(ec == authz::ErrForbidden); + REQUIRE(policy.permit("alice", kStreamA, 5, RequestCategory::Standard)); } -TEST_CASE("authz: empty zones/types means all allowed", "[authz]") { - auto policy = std::make_shared(); - policy->allow({"admin", {}, {}}); // all zones, all types - - auto inner = make_ctrl(Zone::Central); - auto ctrl = authz::new_controller(inner, policy); - ctrl->set_identity("admin"); +TEST_CASE("authz: mismatched stream/endpoint/kind is denied independently", "[authz][REQ-AUTH-001]") { + authz::AccessPolicy policy; + policy.allow({"alice", {kStreamA}, {5}, {RequestCategory::Standard}}); - Command cmd; - cmd.zone = Zone::Central; - cmd.type = CommandType::Reset; - Response resp; - REQUIRE_FALSE(ctrl->send(Context{}, cmd, resp)); + REQUIRE_FALSE(policy.permit("alice", kStreamB, 5, RequestCategory::Standard)); + REQUIRE_FALSE(policy.permit("alice", kStreamA, 6, RequestCategory::Standard)); + REQUIRE_FALSE(policy.permit("alice", kStreamA, 5, RequestCategory::Triggered)); } -TEST_CASE("authz: wrong zone is forbidden", "[authz]") { - auto policy = std::make_shared(); - policy->allow({"alice", {Zone::FrontLeft}, {}}); +// ── Empty-set-means-any axes ────────────────────────────────────────────────── - auto inner = make_ctrl(Zone::RearRight); - auto ctrl = authz::new_controller(inner, policy); - ctrl->set_identity("alice"); +TEST_CASE("authz: empty streams/endpoints/kinds sets mean unrestricted on that axis", + "[authz][REQ-AUTH-002]") { + authz::AccessPolicy policy; + policy.allow({"admin", {}, {}, {}}); // unrestricted on every axis - Command cmd; - cmd.zone = Zone::RearRight; - cmd.type = CommandType::Set; - Response resp; - auto ec = ctrl->send(Context{}, cmd, resp); - REQUIRE(ec == authz::ErrForbidden); + REQUIRE(policy.permit("admin", kStreamA, 1, RequestCategory::Standard)); + REQUIRE(policy.permit("admin", kStreamB, 200, RequestCategory::Compound)); } -TEST_CASE("authz: identity_fn overrides set_identity", "[authz]") { - auto policy = std::make_shared(); - policy->allow({"dynamic", {}, {}}); - - auto inner = make_ctrl(); - auto ctrl = authz::new_controller(inner, policy, []{ return "dynamic"; }); - ctrl->set_identity("wrong"); +TEST_CASE("authz: one restricted axis still gates while the others stay open", + "[authz][REQ-AUTH-002]") { + authz::AccessPolicy policy; + policy.allow({"bob", {kStreamA}, {}, {}}); // only the stream axis is restricted - Command cmd; - cmd.zone = Zone::FrontLeft; - cmd.type = CommandType::Get; - Response resp; - REQUIRE_FALSE(ctrl->send(Context{}, cmd, resp)); + REQUIRE(policy.permit("bob", kStreamA, 7, RequestCategory::Chained)); + REQUIRE_FALSE(policy.permit("bob", kStreamB, 7, RequestCategory::Chained)); } -TEST_CASE("authz: zone() delegates to inner", "[authz][REQ-AUTH-005]") { - auto policy = std::make_shared(); - auto inner = make_ctrl(Zone::RearLeft); - auto ctrl = authz::new_controller(inner, policy); - REQUIRE(ctrl->zone() == Zone::RearLeft); -} +// ── Identity not covered by any entry ───────────────────────────────────────── -TEST_CASE("authz: subscribe() delegates to inner", "[authz][REQ-AUTH-006]") { - auto policy = std::make_shared(); - auto inner = make_ctrl(Zone::FrontLeft); - auto ctrl = authz::new_controller(inner, policy); +TEST_CASE("authz: unknown identity is denied", "[authz][REQ-AUTH-003]") { + authz::AccessPolicy policy; + policy.allow({"alice", {}, {}, {}}); - std::shared_ptr ch; - auto ec = ctrl->subscribe(Context{}, ch); - REQUIRE_FALSE(ec); - REQUIRE(ch != nullptr); - REQUIRE_FALSE(ctrl->close()); + REQUIRE_FALSE(policy.permit("eve", kStreamA, 1, RequestCategory::Standard)); } -TEST_CASE("authz: close() delegates to inner", "[authz][REQ-AUTH-007]") { - auto policy = std::make_shared(); - auto inner = make_ctrl(Zone::FrontLeft); - auto ctrl = authz::new_controller(inner, policy); - - REQUIRE_FALSE(ctrl->close()); - // After the inner controller is closed, sends through it report ErrClosed. - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE(inner->send(Context{}, cmd, resp) == ErrClosed); -} - -TEST_CASE("authz: ErrForbidden is a distinct error code", "[authz][REQ-AUTH-008]") { - REQUIRE(authz::ErrForbidden); - REQUIRE(authz::ErrForbidden != ErrClosed); - REQUIRE(authz::ErrForbidden != ErrBusy); - REQUIRE(std::string(authz::ErrForbidden.category().name()) == "rcp.authz"); -} +// ── Thread safety ───────────────────────────────────────────────────────────── TEST_CASE("authz: AccessPolicy permits concurrently without data races", "[authz][REQ-AUTH-004]") { - auto policy = std::make_shared(); - policy->allow({"alice", {Zone::FrontLeft}, {CommandType::Set}}); + authz::AccessPolicy policy; + policy.allow({"alice", {kStreamA}, {5}, {RequestCategory::Standard}}); std::atomic permits{0}; std::vector ts; for (int t = 0; t < 8; ++t) { ts.emplace_back([&] { for (int i = 0; i < 5000; ++i) { - // Concurrent readers (permit) interleaved with a writer (allow). - if (policy->permit("alice", Zone::FrontLeft, CommandType::Set)) + if (policy.permit("alice", kStreamA, 5, RequestCategory::Standard)) permits.fetch_add(1, std::memory_order_relaxed); if ((i & 0x3ff) == 0) - policy->allow({"tmp", {Zone::Central}, {}}); + policy.allow({"tmp", {kStreamB}, {}, {}}); } }); } for (auto& th : ts) th.join(); REQUIRE(permits.load() == 8 * 5000); } + +// ── check() error-code idiom ────────────────────────────────────────────────── + +TEST_CASE("authz: check() returns ErrForbidden when permit() would deny", + "[authz][REQ-AUTH-005]") { + authz::AccessPolicy policy; + policy.allow({"alice", {kStreamA}, {5}, {RequestCategory::Standard}}); + + auto ec = authz::check(policy, "alice", kStreamB, 5, RequestCategory::Standard); + REQUIRE(ec == authz::ErrForbidden); +} + +TEST_CASE("authz: check() returns success when permit() would allow", + "[authz][REQ-AUTH-006]") { + authz::AccessPolicy policy; + policy.allow({"alice", {kStreamA}, {5}, {RequestCategory::Standard}}); + + auto ec = authz::check(policy, "alice", kStreamA, 5, RequestCategory::Standard); + REQUIRE_FALSE(ec); +} + +// ── Default deny ────────────────────────────────────────────────────────────── + +TEST_CASE("authz: an identity with zero policy entries is denied by default", + "[authz][REQ-AUTH-007]") { + authz::AccessPolicy policy; + REQUIRE_FALSE(policy.permit("nobody", kStreamA, 1, RequestCategory::Standard)); +} + +// ── ErrForbidden identity ───────────────────────────────────────────────────── + +TEST_CASE("authz: ErrForbidden is a distinct, non-zero error code", "[authz][REQ-AUTH-008]") { + REQUIRE(authz::ErrForbidden); + REQUIRE(std::string(authz::ErrForbidden.category().name()) == "rcp.authz"); +} diff --git a/tests/test_ratelimit.cpp b/tests/test_ratelimit.cpp index 0ea5884..02bd7d8 100644 --- a/tests/test_ratelimit.cpp +++ b/tests/test_ratelimit.cpp @@ -7,123 +7,145 @@ // fusa:test REQ-RL-007 // fusa:test REQ-RL-008 +// Tests for rcp/ratelimit.hpp — per-endpoint token-bucket admission control +// (ROADMAP.md milestone 55, "Authorization & Admission-Control Rebind", +// v2.11.0). + #include -#include #include using namespace rcp; -static std::shared_ptr make_mock(Zone z = Zone::FrontLeft) { - return std::make_shared(z); +namespace { + +ratelimit::EndpointKey make_key(uint64_t stream_key, avtp::ByteBusId bus) { + ratelimit::EndpointKey k; + k.stream_key = stream_key; + k.byte_bus_id = bus; + return k; } -// ── Basic send ──────────────────────────────────────────────────────────────── +} // namespace -TEST_CASE("RateLimit::send forwards command when bucket has tokens", "[ratelimit][REQ-RL-001]") { - auto inner = make_mock(); +// ── Basic admission ─────────────────────────────────────────────────────────── + +TEST_CASE("ratelimit: admit succeeds while the domain's bucket has tokens", + "[ratelimit][REQ-RL-001]") { ratelimit::Config cfg; cfg.rate = 1000; cfg.burst = 10; - ratelimit::Controller rl(inner, cfg); + ratelimit::Manager mgr(cfg); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE_FALSE(rl.send(Context::background(), cmd, resp)); - REQUIRE(resp.status == ResponseStatus::OK); + auto key = make_key(1, 5); + REQUIRE_FALSE(mgr.admit(key, /*is_safety_tagged=*/false, /*now_ms=*/0)); } -TEST_CASE("RateLimit::zone returns inner zone", "[ratelimit][REQ-RL-002]") { - auto inner = make_mock(Zone::Central); - ratelimit::Controller rl(inner); - REQUIRE(rl.zone() == Zone::Central); +// ── Refill proportional to elapsed time ─────────────────────────────────────── + +TEST_CASE("ratelimit: tokens refill proportionally to elapsed now_ms", + "[ratelimit][REQ-RL-002]") { + ratelimit::Config cfg; + cfg.rate = 1.0; // 1 token/second + cfg.burst = 1; + ratelimit::Manager mgr(cfg); + auto key = make_key(1, 5); + + // Drain the single burst token at t=0. + REQUIRE_FALSE(mgr.admit(key, false, 0)); + // Immediately retrying at the same instant finds nothing refilled yet. + REQUIRE(mgr.admit(key, false, 0) == ratelimit::ErrAdmissionDenied); + // 1000ms later, exactly one token has refilled at 1 token/second. + REQUIRE_FALSE(mgr.admit(key, false, 1000)); } -// ── Token exhaustion ────────────────────────────────────────────────────────── +// ── Exhaustion ──────────────────────────────────────────────────────────────── -TEST_CASE("RateLimit::send returns ErrBusy when bucket exhausted", "[ratelimit][REQ-RL-003]") { - auto inner = make_mock(); +TEST_CASE("ratelimit: admit returns ErrAdmissionDenied once the bucket is exhausted", + "[ratelimit][REQ-RL-003]") { ratelimit::Config cfg; - cfg.rate = 0.001; // nearly zero refill + cfg.rate = 0.0; // no refill at all cfg.burst = 2; - cfg.exempt_critical = false; - ratelimit::Controller rl(inner, cfg); + ratelimit::Manager mgr(cfg); + auto key = make_key(1, 5); - Command cmd; - cmd.zone = Zone::FrontLeft; - cmd.priority = Priority::Normal; - Response resp; + REQUIRE_FALSE(mgr.admit(key, false, 0)); + REQUIRE_FALSE(mgr.admit(key, false, 0)); + REQUIRE(mgr.admit(key, false, 0) == ratelimit::ErrAdmissionDenied); +} - // Drain the 2 burst tokens. - { auto ec = rl.send(Context::background(), cmd, resp); (void)ec; } - { auto ec = rl.send(Context::background(), cmd, resp); (void)ec; } +// ── Safety-tagged exemption ─────────────────────────────────────────────────── - // Next send should hit ErrBusy. - auto ec = rl.send(Context::background(), cmd, resp); - REQUIRE(ec == ErrBusy); +TEST_CASE("ratelimit: safety-tagged requests bypass the bucket when exempted", + "[ratelimit][REQ-RL-004]") { + ratelimit::Config cfg; + cfg.rate = 0.0; + cfg.burst = 1; + cfg.exempt_safety_tagged = true; + ratelimit::Manager mgr(cfg); + auto key = make_key(1, 5); + + REQUIRE_FALSE(mgr.admit(key, false, 0)); // drains the one token + REQUIRE(mgr.admit(key, false, 0) == ratelimit::ErrAdmissionDenied); + // A safety-tagged request still gets through despite the exhausted bucket. + REQUIRE_FALSE(mgr.admit(key, /*is_safety_tagged=*/true, 0)); } -// ── Critical exemption ──────────────────────────────────────────────────────── - -TEST_CASE("Critical commands bypass the bucket when exempt_critical=true", "[ratelimit][REQ-RL-004]") { - auto inner = make_mock(); +TEST_CASE("ratelimit: safety-tagged requests are throttled when not exempted", + "[ratelimit][REQ-RL-005]") { ratelimit::Config cfg; - cfg.rate = 0.001; - cfg.burst = 1; - cfg.exempt_critical = true; - ratelimit::Controller rl(inner, cfg); - - Command cmd; - cmd.zone = Zone::FrontLeft; - cmd.priority = Priority::Normal; - Response resp; - - // Exhaust the 1 token. - { auto ec = rl.send(Context::background(), cmd, resp); (void)ec; } - - // Critical should still pass. - cmd.priority = Priority::Critical; - auto ec = rl.send(Context::background(), cmd, resp); - REQUIRE_FALSE(ec); + cfg.rate = 0.0; + cfg.burst = 1; + cfg.exempt_safety_tagged = false; + ratelimit::Manager mgr(cfg); + auto key = make_key(1, 5); + + REQUIRE_FALSE(mgr.admit(key, false, 0)); + REQUIRE(mgr.admit(key, /*is_safety_tagged=*/true, 0) == ratelimit::ErrAdmissionDenied); } -TEST_CASE("Critical commands do NOT bypass bucket when exempt_critical=false", "[ratelimit][REQ-RL-005]") { - auto inner = make_mock(); +// ── Independent per-domain buckets ──────────────────────────────────────────── + +TEST_CASE("ratelimit: distinct (stream, endpoint) domains have independent buckets", + "[ratelimit][REQ-RL-006]") { ratelimit::Config cfg; - cfg.rate = 0.001; - cfg.burst = 1; - cfg.exempt_critical = false; - ratelimit::Controller rl(inner, cfg); - - Command cmd; - cmd.zone = Zone::FrontLeft; - cmd.priority = Priority::Normal; - Response resp; - { auto ec = rl.send(Context::background(), cmd, resp); (void)ec; } - - cmd.priority = Priority::Critical; - auto ec = rl.send(Context::background(), cmd, resp); - REQUIRE(ec == ErrBusy); + cfg.rate = 0.0; + cfg.burst = 1; + ratelimit::Manager mgr(cfg); + + auto key_a = make_key(1, 5); + auto key_b = make_key(1, 6); // same stream, different endpoint + auto key_c = make_key(2, 5); // different stream, same endpoint + + REQUIRE_FALSE(mgr.admit(key_a, false, 0)); + REQUIRE(mgr.admit(key_a, false, 0) == ratelimit::ErrAdmissionDenied); + + // Neither sibling domain was affected by draining key_a's bucket. + REQUIRE_FALSE(mgr.admit(key_b, false, 0)); + REQUIRE_FALSE(mgr.admit(key_c, false, 0)); + REQUIRE(mgr.domain_count() == 3); } -// ── Zone mismatch / closed ──────────────────────────────────────────────────── +// ── reset() ─────────────────────────────────────────────────────────────────── + +TEST_CASE("ratelimit: reset() rebuilds a domain's bucket at full burst", + "[ratelimit][REQ-RL-007]") { + ratelimit::Config cfg; + cfg.rate = 0.0; + cfg.burst = 1; + ratelimit::Manager mgr(cfg); + auto key = make_key(1, 5); + + REQUIRE_FALSE(mgr.admit(key, false, 0)); + REQUIRE(mgr.admit(key, false, 0) == ratelimit::ErrAdmissionDenied); -TEST_CASE("RateLimit::send returns ErrZoneMismatch on wrong zone", "[ratelimit][REQ-RL-006]") { - auto inner = make_mock(Zone::FrontLeft); - ratelimit::Controller rl(inner); - Command cmd; - cmd.zone = Zone::RearLeft; - Response resp; - REQUIRE(rl.send(Context::background(), cmd, resp) == ErrZoneMismatch); + mgr.reset(key); + REQUIRE_FALSE(mgr.admit(key, false, 0)); } -TEST_CASE("RateLimit::close stops sends", "[ratelimit][REQ-RL-007][REQ-RL-008]") { - auto inner = make_mock(); - ratelimit::Controller rl(inner); - REQUIRE_FALSE(rl.close()); +// ── Error identity ───────────────────────────────────────────────────────────── - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE(rl.send(Context::background(), cmd, resp) == ErrClosed); +TEST_CASE("ratelimit: ErrAdmissionDenied is a distinct, non-zero error code", + "[ratelimit][REQ-RL-008]") { + REQUIRE(ratelimit::ErrAdmissionDenied); + REQUIRE(std::string(ratelimit::ErrAdmissionDenied.category().name()) == "rcp.ratelimit"); }