Skip to content

Commit bf1dfe7

Browse files
authored
fix(homecore core): TOCTOU race dropped/reordered state_changed events under concurrent writers (~93k→0) + 2 fail-closed hardenings (ruvnet#1087)
* fix(homecore): atomic state set — close TOCTOU lost/reordered state_changed events StateMachine::set did get() (release shard lock) → compute next + no-op decision → insert() (re-acquire lock) → send(). The read-modify-write was not atomic w.r.t. a concurrent writer on the same entity: a writer that read a stale `old` could mis-classify a real transition as a no-op and drop its state_changed event (a missed automation trigger) or fire an event whose new_state duplicated the previously delivered one (a spurious trigger for any automation keyed on old_state != new_state). ADR-127 §2.1 promises "writer atomically replaces the map entry"; the implementation did not. Fix: hold the DashMap shard write-lock across the whole read→decide→insert→ fire sequence via entry()/insert_entry(). tx.send is non-blocking, non-async, and never re-enters the map, so firing under the shard lock cannot deadlock and keeps global event order in lock-step with global commit order. Pinned by concurrent_set_fires_no_duplicate_adjacent_events: 4 writers toggling one entity A/B; asserts no two consecutive fired events carry the same new_state (impossible under correct serialisation). Fails reliably on the old code (~365-476 duplicate-adjacent events on the first trial), passes on the fix across repeated runs. Co-Authored-By: claude-flow <ruv@ruv.net> * harden(homecore): bound entity_id length — close memory-DoS at the REST boundary homecore-api/src/rest.rs parses untrusted path segments straight through EntityId::parse (get/delete/set_state). With no length cap, an otherwise-valid id like "a." + many MB of [a-z0-9_] was accepted; a POST /api/states/<giant> would persist it into the DashMap state store, permanently growing memory (amplification across distinct ids). Fix: reject ids longer than MAX_ENTITY_ID_LEN (255, HA-compatible) up front in parse(), before any per-char scan, with a new EntityIdError::TooLong. Fails closed at the boundary type so every caller (REST, registry deserialize, automation) is protected. Pinned by entity_id_length_boundary: exactly-MAX accepted, MAX+1 rejected, 4 MiB id rejected as TooLong. Fails on old code (oversized parses Ok). Co-Authored-By: claude-flow <ruv@ruv.net> * harden(homecore): isolate panicking service handlers (catch_unwind) ServiceRegistry::call already ran handlers outside the registry lock (the Arc<dyn ServiceHandler> is cloned out of the read guard first), so a panic could never poison the RwLock or block other callers — good. But a panicking handler unwound through call() into the caller's task; the task driving the engine (e.g. an axum request handler invoking a service) could be aborted by one buggy integration. Fix: wrap the handler future in AssertUnwindSafe + FutureExt::catch_unwind and convert a panic into ServiceError::HandlerPanicked. Mirrors HA isolating service-handler exceptions. The registry stays fully usable afterwards. Pinned by panicking_handler_is_isolated_and_registry_survives: the panicking call returns HandlerPanicked (not an unwind), a sibling healthy service still returns its value, and the bad service remains registered. Fails on old code (the await point panics instead of returning Err). Co-Authored-By: claude-flow <ruv@ruv.net> * test(homecore): pin event-bus lag safety (bounded broadcast, no DoS) Documents-with-evidence that the core EventBus does NOT have the homecore-api WS broadcast-lag failure: with EVENT_CHANNEL_CAPACITY=4096, firing 3x capacity while a subscriber never drains keeps fire_* non-blocking (publisher never waits on slow receivers), gives the slow receiver a recoverable Lagged(n) (drop-oldest + re-sync) rather than a closed channel, and leaves the bus live for a fresh fast subscriber. No code change — pins the clean dimension. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(homecore): record ADR-127 §9 security+concurrency review + CHANGELOG Documents the three pinned fixes (HC-RACE-01 state-set TOCTOU, HC-EID-LEN-01 entity_id memory-DoS, HC-SVC-PANIC-01 service-handler isolation) and the clean dimensions (bounded event-bus lag handling, lock discipline / no lock-across-await, no panic-on-input) with their evidence. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 9b126e9 commit bf1dfe7

6 files changed

Lines changed: 439 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Security
11+
- **`homecore` foundational state-machine review (ADR-127) — one real concurrency bug fixed (state-set TOCTOU dropping/reordering `state_changed` events) + two hardening fixes (entity_id memory-DoS, service-handler panic isolation), each pinned by a fails-on-old test; event-bus lag & lock discipline confirmed clean with evidence.** Beyond-SOTA security+concurrency review of the crate every other HOMECORE module builds on (state store `state.rs`, event bus `bus.rs`, service/entity registries, the `HomeCore` coordinator), un-covered by the ADR-154–159 sweep — a bug here is high-blast-radius. **HC-RACE-01 (state-set TOCTOU, the crux — race/lost-event).** `StateMachine::set` did `get()` (releasing the DashMap shard lock) → compute the next snapshot + the no-op/`last_changed` decision → `insert()` (re-acquiring the lock) → `send()`; the read-modify-write was **not atomic** w.r.t. a concurrent writer on the same entity, contradicting ADR-127 §2.1's promise that "the writer atomically replaces the map entry." A writer that read a **stale `old`** could mis-classify a genuine transition as a no-op and **silently drop its `state_changed` event** (a missed automation trigger) or fire an event whose `new_state` duplicated the previously delivered one (a spurious trigger for any automation keyed on `old_state != new_state`). **Fixed** by holding the shard write-lock across the whole read→decide→insert→fire sequence via `entry()`/`insert_entry()` — `tx.send` is non-blocking, non-async, and never re-enters the map, so firing under the shard lock cannot deadlock and keeps global event order in lock-step with global commit order. Pinned by `concurrent_set_fires_no_duplicate_adjacent_events` (4 writers toggling one entity A/B; asserts no two consecutive fired events carry an identical `new_state` — impossible under correct serialisation; an instrumented probe observed ~93k such duplicate-adjacent events across 200 trials on the racy code, **zero** on the fix; the test fails reliably on the first trial pre-fix). **HC-EID-LEN-01 (unbounded `entity_id`, memory-DoS).** `homecore-api/src/rest.rs` parses untrusted REST path segments straight through `EntityId::parse`; with no length cap an otherwise-valid id (`a.` + many MB of `[a-z0-9_]`) was accepted, and a `POST /api/states/<giant>` would persist it into the DashMap state store (permanent growth across distinct ids). **Fixed** by rejecting ids longer than `MAX_ENTITY_ID_LEN` (255, HA-compatible) up front in `parse()`, before any per-char scan, with a new `EntityIdError::TooLong` — fail-closed at the boundary type protects every caller (REST, registry deserialize, automation). Pinned by `entity_id_length_boundary` (exactly-MAX accepted; MAX+1 and a 4 MiB id rejected — oversized parses `Ok` on old code). **HC-SVC-PANIC-01 (service-handler panic not isolated).** `ServiceRegistry::call` already ran handlers **outside** the registry lock (the `Arc<dyn ServiceHandler>` is cloned out of the read guard first → no `RwLock` poisoning, no blocking of other callers — clean), but a panicking handler unwound through `call()` into the caller's task (the task driving the engine). **Hardened** by wrapping the handler future in `AssertUnwindSafe` + `catch_unwind`, converting a panic to `ServiceError::HandlerPanicked`; the registry stays fully usable (a sibling healthy service still returns, the bad service stays registered). Pinned by `panicking_handler_is_isolated_and_registry_survives` (unwinds through `call` on old code). **Dimensions confirmed clean (with evidence, no invented issues):** (1) **event-bus bounds / lag** (the homecore-api WS lag-DoS class) — both `StateMachine` and `EventBus` use **bounded** `tokio::sync::broadcast` (capacity 4,096); a slow subscriber gets a recoverable `Lagged(n)` (drop-oldest + re-sync) while `fire_*` is non-blocking and never waits on slow receivers, so a lagging subscriber **cannot block the publisher, grow the channel without bound, or kill a fast subscriber** (evidenced by `slow_subscriber_does_not_block_publisher_or_kill_the_bus` — fire 3× capacity at an idle subscriber, publisher unblocked, bus stays live, fresh fast subscriber receives, lagged one recovers); (2) **lock ordering / lock-across-await** (deadlock) — no code path holds two of `{state DashMap, registry RwLock, service RwLock}` simultaneously, so no inconsistent-ordering deadlock can exist; every `tokio::sync::RwLock` guard in `registry.rs`/`service.rs` is used in one synchronous statement and dropped before any `.await` (`call` explicitly scopes the read guard out before awaiting the handler); the only guard held across a send is the DashMap shard lock in `set`, across a **synchronous** broadcast send — safe; (3) **panic-on-input** — no reachable `unwrap`/`expect`/index in non-test code beyond the safe `send().unwrap_or(0)` and the dead-but-harmless `split_once(...).unwrap_or(...)` fallbacks on already-validated ids. `cargo test -p homecore --no-default-features`: **20 → 24 passed, 0 failed** (+4 pins). Workspace green; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — `homecore` is off the signal proof path). Review notes appended to ADR-127 §9.
1112
- **`homecore-recorder` security review (ADR-132 surfaces) — two real bounding fixes; SQL-injection & NaN-index dimensions confirmed clean with evidence.** Beyond-SOTA review of the HA-compat state recorder (DB persistence + history + ruvector semantic search), the crux being its DB-backed SQL-injection surface. **Findings + fixes:** (1) **Memory-DoS — unbounded `get_state_history`.** The history query carried no `LIMIT`, so a wide `[since, until]` window over a high-frequency entity (a per-second sensor ≈ 86k rows/day) would load an unbounded row set into a single in-memory `Vec`. Added a hard `LIMIT MAX_HISTORY_ROWS` (1,000,000 — generous enough never to truncate a realistic history graph, bounded enough to cap the worst case); the sibling search paths were already `k`-bounded. (2) **Disk-DoS / documented-but-missing `purge`.** The README + HA-compat table advertised `Recorder::purge(older_than)` as a capability, but **no such method existed** — i.e. no retention path at all → unbounded disk growth. Implemented a **transactional** `purge` that deletes `states` + `events` strictly **older than** the cutoff (**exclusive** boundary — idempotent, no off-by-one; a row at the cutoff instant is kept) and **garbage-collects** orphaned `state_attributes` blobs (a dedup-shared blob is dropped only once its last referencing state is gone); all three deletes run in one transaction so a mid-purge failure rolls back cleanly (no states-deleted-but-events-kept corruption). **Confirmed clean with evidence:** SQL injection — **every** query in `db.rs` uses bound `?` parameters (no `format!`/string-concat of user data into SQL); the lone `format!` builds the LIKE *pattern*, which is itself bound as a parameter with `ESCAPE '\\'` and metacharacter escaping. Pinned: a state value `'; DROP TABLE states; --` is stored/queried **literally** (table survives), and a `%`/`_` in a search query matches **literally**, not as a wildcard. NaN-index poisoning (the calibration/vitals/geo class) — **structurally impossible** here: embeddings are SHA-256 → `i32` → `f32` (an `i32` cast to `f32` is always finite, never NaN/Inf), with an all-zero-digest norm guard; probed empty-index search, empty-string query, and `k=0` — all return `Ok(0)`, **no panic**. Fail-closed write path — a removal event yields `Ok(None)`, semantic-index failure is logged not propagated (best-effort, never blocks the durable SQLite write), and `EntityId` parsing failures fall back rather than panic. **6 new pinning tests** (SQL-injection literal-storage, LIKE-metacharacter literalness, history `LIMIT`, purge exclusive-boundary, purge attribute-GC-keeps-shared, purge old-events): `homecore-recorder` **19 → 25** (`--no-default-features`) / **25 → 31** (`--features ruvector`), 0 failed; the purge-boundary test is a true pin (fails deleting 2 rows under an inclusive cutoff, passes deleting 1 under the exclusive cutoff). Behaviour otherwise unchanged; Python deterministic proof unchanged (recorder is off the signal proof path).
1213

1314
### Added

docs/adr/ADR-127-homecore-state-machine-rust.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,78 @@ The entity registry is a `RwLock<HashMap<EntityId, EntityEntry>>` backed by an a
190190

191191
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum + Tokio architecture pattern used throughout the existing server stack
192192
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICY
193+
194+
---
195+
196+
## 9. Security & concurrency review (P1 core, beyond-SOTA sweep)
197+
198+
Foundational review of the `homecore` crate — the state store + event bus +
199+
service/entity registries every other HOMECORE module trusts. Same rigor as
200+
the ADR-129/130/132/133/161 sibling reviews. **Three real fixes (one
201+
concurrency, two hardening), each pinned by a fails-on-old test; the bus-lag
202+
and lock-discipline dimensions confirmed clean with evidence.**
203+
204+
- **HC-RACE-01 (state-set TOCTOU — lost / reordered `state_changed`, the
205+
crux). FIXED.** `StateMachine::set` did `get()` (releasing the DashMap
206+
shard lock) → compute the next snapshot + the no-op / `last_changed`
207+
decision → `insert()` (re-acquiring the lock) → `send()`. The
208+
read-modify-write was **not atomic** w.r.t. a concurrent writer on the
209+
same entity, contradicting §2.1's promise that "the writer atomically
210+
replaces the map entry." A writer that read a stale `old` could
211+
mis-classify a genuine transition as a no-op and **drop its
212+
`state_changed` event** (a missed automation trigger) or fire an event
213+
whose `new_state` duplicated the previously delivered one (a spurious
214+
trigger for any automation keyed on `old_state != new_state`). **Fix:**
215+
hold the shard write-lock across the entire read→decide→insert→fire
216+
sequence via `entry()`/`insert_entry()`; `tx.send` is non-blocking,
217+
non-async, and never re-enters the map, so firing under the shard lock
218+
cannot deadlock and keeps global event order in lock-step with global
219+
commit order. Pinned by `concurrent_set_fires_no_duplicate_adjacent_events`
220+
(4 writers toggling one entity A/B; asserts no two consecutive fired
221+
events carry an identical `new_state` — impossible under correct
222+
serialisation; a probe observed ~93k such duplicate-adjacent events across
223+
200 trials on the racy code, zero on the fix).
224+
- **HC-EID-LEN-01 (unbounded `entity_id` — memory-DoS at the REST boundary).
225+
FIXED.** `homecore-api/src/rest.rs` parses untrusted path segments
226+
straight through `EntityId::parse`; with no length cap, an
227+
otherwise-valid id (`a.` + many MB of `[a-z0-9_]`) was accepted and a
228+
`POST /api/states/<giant>` would persist it into the DashMap state store
229+
(permanent growth across distinct ids). **Fix:** reject ids longer than
230+
`MAX_ENTITY_ID_LEN` (255, HA-compatible) up front in `parse()`, before any
231+
per-char scan, with a new `EntityIdError::TooLong`; fail-closed at the
232+
boundary type protects every caller. Pinned by `entity_id_length_boundary`
233+
(exactly-MAX accepted, MAX+1 and a 4 MiB id rejected — fails on old code).
234+
- **HC-SVC-PANIC-01 (service-handler panic not isolated). HARDENED.**
235+
`ServiceRegistry::call` already ran handlers outside the registry lock (no
236+
`RwLock` poisoning, no blocking of other callers — clean), but a
237+
panicking handler unwound through `call()` into the caller's task. **Fix:**
238+
wrap the handler future in `AssertUnwindSafe` + `catch_unwind`, converting
239+
a panic to `ServiceError::HandlerPanicked`; the registry stays fully
240+
usable. Pinned by `panicking_handler_is_isolated_and_registry_survives`.
241+
242+
**Dimensions confirmed clean (with evidence):**
243+
244+
- **Event-bus bounds / lag (same class as the homecore-api WS lag-DoS).**
245+
Both `StateMachine` and `EventBus` use bounded `tokio::sync::broadcast`
246+
(capacity 4,096). A slow subscriber gets a recoverable `Lagged(n)`
247+
(drop-oldest + re-sync); `fire_*` is non-blocking and **never waits on
248+
slow receivers**, so a lagging subscriber cannot block the publisher, grow
249+
the channel without bound, or take down a fast subscriber. Evidenced by
250+
`slow_subscriber_does_not_block_publisher_or_kill_the_bus` (fire 3×
251+
capacity at an idle subscriber; publisher unblocked, bus stays live).
252+
- **Lock ordering / lock-across-await (deadlock).** No code path holds two
253+
of `{state DashMap, registry RwLock, service RwLock}` simultaneously, so
254+
no inconsistent-ordering deadlock can exist. Every `tokio::sync::RwLock`
255+
guard in `registry.rs`/`service.rs` is used in a single synchronous
256+
statement and dropped before any `.await`; `call` explicitly scopes the
257+
read guard out before awaiting the handler. The only guard held across a
258+
send is the DashMap shard lock in `set`, across a synchronous
259+
(non-await) broadcast send — safe.
260+
- **Panic-on-input.** No reachable `unwrap`/`expect`/index in non-test code
261+
beyond the safe `send().unwrap_or(0)` and the dead-but-harmless
262+
`split_once(...).unwrap_or(...)` fallbacks on already-validated ids.
263+
264+
`cargo test -p homecore --no-default-features`: **20 → 24 passed, 0 failed**
265+
(+4 pins). Workspace green; Python deterministic proof unchanged
266+
(`f8e76f21…46f7a`, bit-exact — `homecore` is off the signal proof path).
193267
- `docs/adr/ADR-028-esp32-capability-audit.md` — witness chain pattern (Ed25519 per state transition)

v2/crates/homecore/src/bus.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,64 @@ mod tests {
8787
assert_eq!(event.event_type, "ruview_csi_frame");
8888
assert_eq!(event.event_data["frame_id"], 42);
8989
}
90+
91+
/// Bus-lag safety (same failure class as the homecore-api WS
92+
/// broadcast-lag DoS, here on the core bus): a subscriber that never
93+
/// drains must NOT block the publisher, must NOT make the channel grow
94+
/// without bound, and must NOT take down a healthy fast subscriber. The
95+
/// bounded `tokio::sync::broadcast` gives the slow receiver a recoverable
96+
/// `Lagged(n)` (drop-oldest, re-sync) while `fire_*` stays non-blocking.
97+
///
98+
/// Evidence: with EVENT_CHANNEL_CAPACITY = 4096 we fire 3× capacity
99+
/// while a slow subscriber sits idle. Every `fire_domain` returns
100+
/// promptly (publisher never blocked); the slow receiver observes
101+
/// `Lagged` then re-syncs to live events; the fast receiver — created
102+
/// after the flood and kept drained — receives all subsequent events
103+
/// with no loss. The bus stays live throughout.
104+
#[tokio::test]
105+
async fn slow_subscriber_does_not_block_publisher_or_kill_the_bus() {
106+
use tokio::sync::broadcast::error::TryRecvError;
107+
108+
let bus = EventBus::new();
109+
// Slow subscriber: subscribes, then never drains during the flood.
110+
let mut slow = bus.subscribe_domain();
111+
112+
// Publisher fires 3× capacity. None of these may block.
113+
let total = EVENT_CHANNEL_CAPACITY * 3;
114+
for i in 0..total {
115+
// Returns the receiver count (>=1 here); the point is it
116+
// returns AT ALL without awaiting the slow receiver.
117+
let _ = bus.fire_domain(DomainEvent::new(
118+
"flood",
119+
serde_json::json!({ "i": i }),
120+
Context::new(),
121+
));
122+
}
123+
124+
// The slow receiver is forced past capacity → recoverable Lagged,
125+
// NOT a closed channel and NOT a hang.
126+
let mut saw_lagged = false;
127+
loop {
128+
match slow.try_recv() {
129+
Ok(_) => {}
130+
Err(TryRecvError::Lagged(n)) => {
131+
assert!(n > 0);
132+
saw_lagged = true;
133+
}
134+
Err(TryRecvError::Empty) => break,
135+
Err(TryRecvError::Closed) => panic!("bus closed — must stay live"),
136+
}
137+
}
138+
assert!(saw_lagged, "slow subscriber should have lagged, not blocked the bus");
139+
140+
// The bus is still live: a fresh fast subscriber receives new events.
141+
let mut fast = bus.subscribe_domain();
142+
bus.fire_domain(DomainEvent::new("live", serde_json::json!({"ok": true}), Context::new()));
143+
let evt = fast.recv().await.unwrap();
144+
assert_eq!(evt.event_type, "live");
145+
146+
// And the lagged subscriber recovers (re-syncs) to live events too.
147+
let evt2 = slow.recv().await.unwrap();
148+
assert_eq!(evt2.event_type, "live");
149+
}
90150
}

v2/crates/homecore/src/entity.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,30 @@ impl<'de> Deserialize<'de> for EntityId {
4242
}
4343
}
4444

45+
/// Maximum accepted `entity_id` length in bytes. Mirrors Home Assistant's
46+
/// practical cap (`MAX_LENGTH_STATE_*` family — 255). The state machine and
47+
/// entity/registry maps are keyed on `EntityId`, and the REST layer
48+
/// (`homecore-api`) parses untrusted path segments straight through
49+
/// [`EntityId::parse`]; an unbounded id would let a single `POST
50+
/// /api/states/<giant>` permanently grow the state map (memory DoS). We
51+
/// fail closed at the boundary instead.
52+
pub const MAX_ENTITY_ID_LEN: usize = 255;
53+
4554
impl EntityId {
4655
/// Validates and constructs an `EntityId`. Returns
4756
/// [`EntityIdError`] if the input is not `domain.name` shape with
48-
/// ASCII lowercase / digits / underscore in each segment.
57+
/// ASCII lowercase / digits / underscore in each segment, or if it
58+
/// exceeds [`MAX_ENTITY_ID_LEN`] bytes.
4959
pub fn parse(s: impl Into<String>) -> Result<Self, EntityIdError> {
5060
let s: String = s.into();
61+
// Bound the length BEFORE any further work so an oversized input is
62+
// cheap to reject (no per-char scan of megabytes).
63+
if s.len() > MAX_ENTITY_ID_LEN {
64+
return Err(EntityIdError::TooLong {
65+
len: s.len(),
66+
max: MAX_ENTITY_ID_LEN,
67+
});
68+
}
5169
let (domain, name) = s
5270
.split_once('.')
5371
.ok_or_else(|| EntityIdError::MissingDot(s.clone()))?;
@@ -111,6 +129,8 @@ pub enum EntityIdError {
111129
EmptyName(String),
112130
#[error("entity_id {entity_id:?} contains invalid character {ch:?} — only [a-z0-9_] allowed (HA-compat ASCII subset; see ADR-127 §Q1)")]
113131
InvalidChar { entity_id: String, ch: char },
132+
#[error("entity_id is {len} bytes, exceeding the {max}-byte limit")]
133+
TooLong { len: usize, max: usize },
114134
}
115135

116136
/// Immutable state snapshot for one entity at one moment in time.
@@ -217,6 +237,39 @@ mod tests {
217237
assert!(EntityId::parse("light.küche").is_err());
218238
}
219239

240+
#[test]
241+
fn entity_id_length_boundary() {
242+
// The REST layer parses untrusted path segments straight through
243+
// `parse`; an unbounded id is a memory-DoS vector (a `POST
244+
// /api/states/<giant>` permanently grows the state map). Cap at
245+
// MAX_ENTITY_ID_LEN, fail closed above it.
246+
//
247+
// Construct "sensor." (7 bytes) + N name bytes == exactly MAX.
248+
let prefix = "sensor.";
249+
let name_len = MAX_ENTITY_ID_LEN - prefix.len();
250+
let at_max = format!("{prefix}{}", "a".repeat(name_len));
251+
assert_eq!(at_max.len(), MAX_ENTITY_ID_LEN);
252+
assert!(
253+
EntityId::parse(at_max.clone()).is_ok(),
254+
"an id of exactly MAX_ENTITY_ID_LEN bytes must be accepted"
255+
);
256+
257+
let over = format!("{at_max}a"); // MAX + 1
258+
assert!(matches!(
259+
EntityId::parse(over),
260+
Err(EntityIdError::TooLong { .. })
261+
));
262+
263+
// A multi-megabyte, otherwise-valid id is rejected cheaply rather
264+
// than persisted.
265+
let huge = format!("sensor.{}", "a".repeat(4 * 1024 * 1024));
266+
assert!(matches!(
267+
EntityId::parse(huge),
268+
Err(EntityIdError::TooLong { len, max })
269+
if max == MAX_ENTITY_ID_LEN && len > MAX_ENTITY_ID_LEN
270+
));
271+
}
272+
220273
#[test]
221274
fn state_next_preserves_last_changed_when_state_unchanged() {
222275
let id = EntityId::parse("sensor.temp").unwrap();

0 commit comments

Comments
 (0)