diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7b2d0238 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# Repository Agent Instructions + +## PlatformIO commands are single-process only + +Run only one PlatformIO process in this checkout at a time. This includes every +`pio run`, `pio test`, upload, clean, and scripted command that invokes +PlatformIO, even when the commands target different environments. + +PlatformIO reuses and may clean the shared `.pio/build` tree. Concurrent +PlatformIO processes can delete another process's object directories and cause +misleading compiler or linker failures. Wait for the active PlatformIO command +to finish before starting the next one. diff --git a/README.md b/README.md index b834ca02..461f6b62 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ To run unit tests, run the following command: pio test --environment native --verbose ``` +Run only one PlatformIO process in a checkout at a time. Do not overlap +`pio test`, `pio run`, uploads, cleans, or scripts that invoke PlatformIO, even +for different environments: they share and may clean `.pio/build`, which can +interrupt another build and produce misleading failures. + ## Road-Map / To-Do There are a number of fairly major features in the pipeline, with no particular time-frames attached yet. In very rough chronological order: diff --git a/docs/_javascript/filter_tool.js b/docs/_javascript/filter_tool.js index 00fc1966..c2a078b1 100644 --- a/docs/_javascript/filter_tool.js +++ b/docs/_javascript/filter_tool.js @@ -632,11 +632,18 @@ return Math.ceil(bytes); } + function channelMatcherSpecificity(rule) { + if (!rule.channel) return 0; + return rule.channel.startsWith("hash:") ? 1 : 2; + } + function sortedRules(rules) { return rules.slice().sort((left, right) => { const phase = PHASE_ORDER[left.phase] - PHASE_ORDER[right.phase]; if (phase !== 0) return phase; if (left.priority !== right.priority) return right.priority - left.priority; + const specificity = channelMatcherSpecificity(right) - channelMatcherSpecificity(left); + if (specificity !== 0) return specificity; if (left.id < right.id) return -1; if (left.id > right.id) return 1; return 0; @@ -694,7 +701,8 @@ format: POLICY_FORMAT, version: POLICY_VERSION, status: "design-preview", - evaluation: "phase, descending priority, stable rule ID; immutable receive-time matches", + evaluation: "phase, descending priority, channel specificity, stable rule ID; " + + "immutable receive-time matches", rules: sortedRules(rules.map(normalizeRule)), }; } diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 1a1ab0f5..54381395 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2030,8 +2030,11 @@ compile this table. least one surviving `retry` row. `flood.retry.bridge` still selects ordinary or bridge-bucket completion for the allowed packet. This action can accompany rewrite, rate, or stop, but not `drop`; compact syntax uses `f=r`. -- `priority=0-255`: Optional processing order. Higher values run first and - lower slot number breaks a tie. The default is `0`; `pri=` is an alias. +- `priority=0-255`: Optional primary processing order. Higher values run first. + At the same numeric priority, authenticated channel matches run before raw + `hash:XX` matches, which run before `channel=*`; lower slot number breaks the + remaining tie. The default is `0`; `pri=` is an alias. An explicitly higher + numeric priority still overrides this automatic specificity ordering. - `stop` or `action=stop`: Apply this matching row, then stop lower-order FPF7 rows from processing. It can stand alone or accompany drop, rewrite, or rate. A stop-only row acts as an exception to lower-priority FPF7 rules. @@ -2087,9 +2090,11 @@ are never affected. **Behavior:** Match fields within one row are ANDed. Every FPF7 row is matched against the same immutable receive-time packet, before any rule changes its -scope. Matching rows are processed in descending `priority`, with lower slot -number winning a tie. The first matching `stop` row is included and all -lower-order FPF7 matches are discarded. A stop cannot undo an earlier drop or +scope. Matching rows are processed in descending `priority`. At equal numeric +priority, authenticated channel matches precede raw hashes, which precede an +unrestricted channel matcher; lower slot wins after that. The first matching +`stop` row is included and all lower-order FPF7 matches are discarded. A stop +cannot undo an earlier drop or bypass hard forwarding gates or the other policy phases. A row with `path=blacklist` must meet the path condition as well as its other conditions; blacklist IDs can occur anywhere in the received path and their configured @@ -2165,15 +2170,18 @@ rules after it: ```text # Retry and preserve authenticated Public; drop other channel-hash 11 packets. -set flood.rule.2 type=any channel=public retry stop priority=200 -set flood.rule.3 type=any channel=hash:11 drop priority=100 +set flood.rule.2 type=any channel=public retry stop +set flood.rule.3 type=any channel=hash:11 drop ``` The Public row matches only after its MAC/decrypt check succeeds. A colliding -channel therefore misses that `stop` and reaches the raw-hash drop row. Without -the higher-priority `stop`, both rows match Public and the sticky `drop` action -wins. Omit `retry` from the Public row when only the forwarding exemption is -wanted. +channel therefore misses that `stop` and reaches the raw-hash drop row. Both +rows use the default numeric priority, but authenticated-channel specificity +automatically orders Public first even if its slot number is higher. Without +`stop`, both rows match Public and the sticky `drop` action wins. Omit `retry` +from the Public row when only the forwarding exemption is wanted. An operator +can deliberately reverse this order by assigning the hash row a higher numeric +`priority`. Deleting or replacing the last active `retry` row restores the legacy global retry eligibility. Firmware that predates the `retry`/`hash:XX` FPF7 extension diff --git a/docs/filter_tool.md b/docs/filter_tool.md index f8bec9da..c149407d 100644 --- a/docs/filter_tool.md +++ b/docs/filter_tool.md @@ -394,7 +394,8 @@ in the simulator below. The examples draw from

Rules match the same immutable packet facts. Ordering is phase, then - descending priority, then stable rule ID. Drop decisions are sticky. + descending priority, then authenticated channel, raw hash, or wildcard + specificity, then stable rule ID. Drop decisions are sticky.

Add a rule or load an example to start exploring. @@ -626,7 +627,10 @@ The simulator uses these rules: 1. Only flood retransmission enters the policy. Direct routing and local packet delivery remain outside it. 2. Every matcher reads the same immutable receive-time packet facts. -3. Rules run by phase, descending priority, then stable rule ID. +3. Rules run by phase and descending numeric priority. At an equal priority, + authenticated channel matches run before raw one-byte hash matches, which + run before wildcard channel matches; stable rule ID breaks the remaining + tie. An explicitly higher numeric priority overrides channel specificity. 4. A drop decision is sticky and cannot be undone by a later rule. 5. The first matching scope, timing, queue, and retry action in execution order wins. diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index 36ba6347..5fb8205c 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -326,8 +326,10 @@ del flood.rule all The command must be entered on one line. Match fields in one row are ANDed. Every row is matched against the same immutable packet state captured on receive, before any rule rewrites its scope. Matching rows are then processed -by descending `priority`; lower slot number wins a priority tie. Priority -defaults to `0`. +by descending numeric `priority`. At an equal numeric priority, authenticated +channel matches run before raw `hash:XX` matches, raw hashes run before +`channel=*`, and lower slot number breaks the remaining tie. Priority defaults +to `0`; an explicitly higher numeric priority overrides specificity. The first matching `stop` row ends the FPF7 forward phase after that row. Higher-order matches and the stop row still apply; lower-order matches do not. A stop-only @@ -392,8 +394,9 @@ Actions: has one or more, those rows are a retry allow-list: at least one must remain in the ordered, stop-truncated match set. The action may accompany rewrite, rate, or stop, but not `drop`; its compact flag is `f=r`. -- `priority=0-255` controls processing order. Higher values run first; lower - slot number breaks ties. `pri=` is the compact alias. +- `priority=0-255` controls the primary processing order. Higher values run + first. Equal values use automatic channel specificity (authenticated, then + raw hash, then wildcard) before slot order. `pri=` is the compact alias. - `stop` (or `action=stop`) applies this row and prevents lower-order FPF7 rows from acting. It can stand alone or accompany drop, rewrite, or rate. @@ -447,15 +450,17 @@ preserves authenticated Public while dropping other packets that use the same visible byte: ```text -set flood.rule.2 type=any channel=public retry stop priority=200 -set flood.rule.3 type=any channel=hash:11 drop priority=100 +set flood.rule.2 type=any channel=public retry stop +set flood.rule.3 type=any channel=hash:11 drop ``` The Public rule's MAC/decrypt check must succeed before its `stop` applies. A -colliding channel misses that rule and reaches the lower-priority hash drop. -Without `stop`, both rows match Public and the sticky drop wins. Remove `retry` -from the Public row if the exemption should not also opt Public into flood -retry. +colliding channel misses that rule and reaches the hash drop. With equal +numeric priorities, authenticated-channel specificity automatically puts the +Public rule first regardless of slot order. Without `stop`, both rows match +Public and the sticky drop wins. Remove `retry` from the Public row if the +exemption should not also opt Public into flood retry. An explicitly higher +numeric priority on the hash rule remains an operator override. The 240 KB STM32WL profiles keep `MESH_ENABLE_FLOOD_RULE_ENGINE=0` and retain the persistent compact FPF6 `flood.filter` and blacklist syntax below. They @@ -535,8 +540,9 @@ not grant a region bypass, and the unchanged packet is allowed to fail normal region enforcement. Other independently configured scope rows still apply in their normal order. -When multiple scope or region rows match, the highest-priority row wins; lower -slot number breaks a priority tie. +When multiple scope or region rows match, the highest numeric-priority row +wins. Equal priorities use authenticated channel, raw hash, then wildcard +specificity before the slot-number tie-break. Rewrite rows do not approve a packet: any matching drop row and every remaining forwarding gate can still reject it. A filter-assigned scope is trusted without local region-list validation, but `repeat`, `flood.max`, loop detection, and @@ -778,8 +784,9 @@ other words, the controls combine as deny rules: channel's ordinary fallback, then adds or replaces the scope from either a configured region or a direct `scope=` target. 3. All extended `flood.rule` match fields are evaluated against the same - original incoming packet. Matches are ordered by descending priority and - then ascending slot. The first matching `stop` row removes every later FPF7 + original incoming packet. Matches are ordered by descending numeric + priority, then authenticated/raw-hash/wildcard channel specificity, then + ascending slot. The first matching `stop` row removes every later FPF7 match. The highest-order remaining `scope=` or `region=` row may replace the channel-scope result; a direct scope does not require a region-list entry. 4. `repeat` and `flood.max*` are checked. diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index f48754d5..121f228b 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -85,7 +85,7 @@ set flood.retry.ignore none | `flood.channel.scope` | FPF7 rewrite-phase rows that add a transport scope to received unscoped floods or replace the scope of already-scoped floods. A bare target uses an existing flood-allowed region; `scope=` derives a public hashtag target directly without creating a region, exactly like `flood.filter scope=`. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. `path=blacklist` and `path=bucket:<1-6>` make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; path-qualified rows beat the ordinary channel fallback. `login:*` covers the remote-login family, and `other:*` covers every remaining flood type, including flood-form TRACE and OTA. Direct traceroute remains outside the flood table. Generalized builds commit these rows with the forward phase and blacklist; compact FPF6 builds retain separate storage. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] [path=blacklist|path=bucket:1-6] [tx=slow]`, `del flood.channel.scope.|all` | `set flood.channel.scope #rgdata scope=BlackHole86` | | `flood.channel.scope.require` | Switches group-channel region enforcement to opt-in when the table has entries. Listed authenticated `GRP_TXT`/`GRP_DATA` channels must arrive already scoped to a locally allowed region; unscoped, unknown, or denied incoming scopes are dropped before any rewrite can rescue them. Unlisted group channels bypass only the region gate and retain all other forwarding controls. An empty table preserves global region behavior; non-channel payloads are unchanged. ACL permission `4` can manage the table. | `get flood.channel.scope.require[.n]`, `set flood.channel.scope.require[.n] `, `del flood.channel.scope.require.|all` | `set flood.channel.scope.require #bot` | | `flood.filter` | Persistent flood-route rules selected by payload type and optional hop range. Generalized repeaters have 63 FPF7 forward slots; FULL ESP32 room servers have 31. Repeaters store their scope-rewrite phase, shared unordered blacklist, and channel-data compatibility state in the same atomic FPF7 image. `path=blacklist` is intended for forwarding abuse containment, including bulk internet-to-mesh dumping, but truncated path IDs are not authenticated identities. Fixed 240 KB STM32WL repeaters retain compact FPF6 filtering and separate blacklist storage. New generalized repeater tables seed slot 1 with `ota all suspend=tempradio` and slot 2 with an authenticated `#wardriving hops=5+` drop. Direct routes and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] [N|N+|N-M|all] [scope=] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.|all`; repeater only: `get/set/del flood.filter.blacklist[.n]` | `set flood.filter grp_txt all scope=local tx=slow` | -| `flood.rule` | Live alias for extended `flood.filter` on rule-engine repeaters and FULL ESP32 room servers. A row can AND packet type, hop range, optional channel match, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, allow flood retry, and/or stop lower-priority FPF7 rules. `channel=*` means no channel condition; `public`, `#name`, and keys authenticate; `hash:XX` is an unauthenticated one-byte fallback with collision/spoof risk. With no `retry` rows, global retry behavior is unchanged; once any exists, matching `retry` rows allow-list received floods while the global retry gates and `flood.retry.bridge` algorithm still apply. `scope=BlackHole86` directly derives a regionless sink scope; `region=BlackHole86` would require a configured flood-allowed region. All rows match the original receive-time packet; higher `priority` runs first and lower slot breaks a tie. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region makes its `region=` rewrite and paired `stop` inert until the name returns, allowing lower safety rows to run. Fixed 240 KB STM32WL profiles keep FPF6 and do not expose this alias; partition sizes are unchanged. | `get flood.rule[.n]`, `set flood.rule[.n] type= [hops=...] [channel=...] [prefix=...] [in=...] [priority=0-255]`, `del flood.rule.|all` | `set flood.rule.2 type=any channel=#hamradio retry` | +| `flood.rule` | Live alias for extended `flood.filter` on rule-engine repeaters and FULL ESP32 room servers. A row can AND packet type, hop range, optional channel match, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, allow flood retry, and/or stop lower-priority FPF7 rules. `channel=*` means no channel condition; `public`, `#name`, and keys authenticate; `hash:XX` is an unauthenticated one-byte fallback with collision/spoof risk. With no `retry` rows, global retry behavior is unchanged; once any exists, matching `retry` rows allow-list received floods while the global retry gates and `flood.retry.bridge` algorithm still apply. `scope=BlackHole86` directly derives a regionless sink scope; `region=BlackHole86` would require a configured flood-allowed region. All rows match the original receive-time packet. Higher numeric `priority` runs first; equal values automatically order authenticated channels before raw hashes before `channel=*`, then use lower slot as the final tie-break. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region makes its `region=` rewrite and paired `stop` inert until the name returns, allowing lower safety rows to run. Fixed 240 KB STM32WL profiles keep FPF6 and do not expose this alias; partition sizes are unchanged. | `get flood.rule[.n]`, `set flood.rule[.n] type= [hops=...] [channel=...] [prefix=...] [in=...] [priority=0-255]`, `del flood.rule.|all` | `set flood.rule.2 type=any channel=#hamradio retry` | | `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] [path=...]`, `del flood.moderation.|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` | | `clock.sync.mesh` | Defaults on for all repeater, sensor, and room-server builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh `, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` | | `clock.sync.mesh.edge` | Defaults on so edge infrastructure nodes can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so disabled forwarding and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. | `get clock.sync.mesh.edge`, `set clock.sync.mesh.edge ` | `set clock.sync.mesh.edge on` | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a2a3f112..296d888f 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -6362,11 +6362,15 @@ bool MyMesh::floodPacketFilterAllowsRetry(uint64_t match_mask) const { int MyMesh::nextFloodPacketFilterMatch(uint64_t match_mask, uint64_t visited_mask) const { uint8_t priorities[FLOOD_PACKET_FILTER_SLOTS]; + uint8_t specificities[FLOOD_PACKET_FILTER_SLOTS]; for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { priorities[i] = flood_packet_filters[i].priority; + specificities[i] = FloodFilterPolicy::channelMatcherSpecificity( + flood_packet_filters[i].channel_key_len); } return FloodFilterPolicy::nextOrderedRule( - match_mask, visited_mask, priorities, FLOOD_PACKET_FILTER_SLOTS); + match_mask, visited_mask, priorities, specificities, + FLOOD_PACKET_FILTER_SLOTS); } bool MyMesh::resolveFloodPacketFilterTargetRegion( @@ -6386,10 +6390,13 @@ bool MyMesh::resolveFloodPacketFilterTargetRegion( uint64_t MyMesh::applyFloodPacketFilterStop(uint64_t match_mask) { uint8_t priorities[FLOOD_PACKET_FILTER_SLOTS]; + uint8_t specificities[FLOOD_PACKET_FILTER_SLOTS]; uint8_t stop_flags[FLOOD_PACKET_FILTER_SLOTS]; for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { priorities[i] = flood_packet_filters[i].priority; const auto& entry = flood_packet_filters[i]; + specificities[i] = FloodFilterPolicy::channelMatcherSpecificity( + entry.channel_key_len); bool region_usable = true; if (entry.target_region_name[0] != 0) { TransportKey scope; @@ -6402,7 +6409,8 @@ uint64_t MyMesh::applyFloodPacketFilterStop(uint64_t match_mask) { region_usable) ? 1 : 0; } return FloodFilterPolicy::truncateRulesAtStop( - match_mask, priorities, stop_flags, FLOOD_PACKET_FILTER_SLOTS); + match_mask, priorities, specificities, stop_flags, + FLOOD_PACKET_FILTER_SLOTS); } uint64_t MyMesh::evaluateFloodPacketFilterMatches( diff --git a/examples/simple_room_server/FloodRuleEngine.cpp b/examples/simple_room_server/FloodRuleEngine.cpp index 49d164bc..3d9753ae 100644 --- a/examples/simple_room_server/FloodRuleEngine.cpp +++ b/examples/simple_room_server/FloodRuleEngine.cpp @@ -867,11 +867,14 @@ bool FloodRuleEngine::authenticateChannel( int FloodRuleEngine::nextMatch(uint32_t match_mask, uint32_t visited_mask) const { uint8_t priorities[RULE_SLOTS]; + uint8_t specificities[RULE_SLOTS]; for (int i = 0; i < RULE_SLOTS; i++) { priorities[i] = _entries[i].priority; + specificities[i] = FloodFilterPolicy::channelMatcherSpecificity( + _entries[i].channel_key_len); } return FloodFilterPolicy::nextOrderedRule( - match_mask, visited_mask, priorities, RULE_SLOTS); + match_mask, visited_mask, priorities, specificities, RULE_SLOTS); } bool FloodRuleEngine::resolveTargetRegion( @@ -891,10 +894,13 @@ bool FloodRuleEngine::resolveTargetRegion( uint32_t FloodRuleEngine::applyStop(uint32_t match_mask) { uint8_t priorities[RULE_SLOTS]; + uint8_t specificities[RULE_SLOTS]; uint8_t stop_flags[RULE_SLOTS]; for (int i = 0; i < RULE_SLOTS; i++) { priorities[i] = _entries[i].priority; const Entry& entry = _entries[i]; + specificities[i] = FloodFilterPolicy::channelMatcherSpecificity( + entry.channel_key_len); bool region_usable = true; if (entry.target_region_name[0] != 0) { TransportKey scope; @@ -907,7 +913,7 @@ uint32_t FloodRuleEngine::applyStop(uint32_t match_mask) { region_usable) ? 1 : 0; } return FloodFilterPolicy::truncateRulesAtStop( - match_mask, priorities, stop_flags, RULE_SLOTS); + match_mask, priorities, specificities, stop_flags, RULE_SLOTS); } uint32_t FloodRuleEngine::evaluate( diff --git a/scripts/test_filter_tool.js b/scripts/test_filter_tool.js index fd069ffa..7cc1e96c 100644 --- a/scripts/test_filter_tool.js +++ b/scripts/test_filter_tool.js @@ -219,16 +219,22 @@ test("loads the playground policy examples", () => { ); }); -test("orders by phase, descending priority, and stable ASCII ID", () => { +test("orders by phase, priority, channel specificity, and stable ASCII ID", () => { const definitions = [ "policy set z-last phase=forward owner=filter priority=20 when route=flood type=any hops=all do tag=z", "policy set rewrite-first phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do scope=#x", "policy set b-middle phase=forward owner=filter priority=30 when route=flood type=any hops=all do tag=b", "policy set A-first phase=forward owner=filter priority=20 when route=flood type=any hops=all do tag=a", + "policy set hash-middle phase=forward owner=filter priority=20 when route=flood " + + "type=grp_txt hops=all channel=hash:11 do tag=hash", + "policy set exact-first phase=forward owner=filter priority=20 when route=flood " + + "type=grp_txt hops=all channel=public do tag=exact", + "policy set hash-override phase=forward owner=filter priority=21 when route=flood " + + "type=grp_txt hops=all channel=hash:11 do tag=override", ]; assert.deepStrictEqual( tool.sortedRules(definitions.map(tool.parseDefinition)).map((rule) => rule.id), - ["rewrite-first", "b-middle", "A-first", "z-last"] + ["rewrite-first", "b-middle", "hash-override", "exact-first", "hash-middle", "A-first", "z-last"] ); }); diff --git a/src/helpers/FloodFilterPolicy.h b/src/helpers/FloodFilterPolicy.h index b1977c29..d844fc12 100644 --- a/src/helpers/FloodFilterPolicy.h +++ b/src/helpers/FloodFilterPolicy.h @@ -64,6 +64,15 @@ inline bool channelRequiresAuthentication(uint8_t key_len) { || key_len == CHANNEL_KEY_256_LEN; } +// Numeric rule priority remains the primary ordering control. At an equal +// priority, prefer a narrower channel identity so an authenticated exception +// can stop a colliding raw-hash rule regardless of their slot order. +inline uint8_t channelMatcherSpecificity(uint8_t key_len) { + if (channelRequiresAuthentication(key_len)) return 2; + if (channelHashOnly(key_len)) return 1; + return 0; +} + inline uint8_t encodeStoredRuleChannel(uint8_t key_len, bool retry_on_match) { return (uint8_t)(key_len @@ -381,27 +390,41 @@ inline bool sameChannelKey(uint8_t left_len, const uint8_t left[], template inline int nextOrderedRule(RuleMask match_mask, RuleMask visited_mask, - const uint8_t priorities[], uint8_t count) { + const uint8_t priorities[], + const uint8_t specificities[], uint8_t count) { if (priorities == NULL || count > sizeof(RuleMask) * 8U) return -1; int best = -1; for (uint8_t i = 0; i < count; i++) { RuleMask bit = (RuleMask)1U << i; if ((match_mask & bit) == 0 || (visited_mask & bit) != 0) continue; - if (best < 0 || priorities[i] > priorities[best]) best = i; + if (best < 0 || priorities[i] > priorities[best] + || (priorities[i] == priorities[best] + && specificities != NULL + && specificities[i] > specificities[best])) { + best = i; + } } return best; } +template +inline int nextOrderedRule(RuleMask match_mask, RuleMask visited_mask, + const uint8_t priorities[], uint8_t count) { + return nextOrderedRule(match_mask, visited_mask, priorities, NULL, count); +} + template inline RuleMask truncateRulesAtStop(RuleMask match_mask, const uint8_t priorities[], + const uint8_t specificities[], const uint8_t stop_flags[], uint8_t count) { if (stop_flags == NULL) return match_mask; RuleMask effective = 0; RuleMask visited = 0; while (true) { - int index = nextOrderedRule(match_mask, visited, priorities, count); + int index = nextOrderedRule( + match_mask, visited, priorities, specificities, count); if (index < 0) break; RuleMask bit = (RuleMask)1U << index; visited |= bit; @@ -411,6 +434,15 @@ inline RuleMask truncateRulesAtStop(RuleMask match_mask, return effective; } +template +inline RuleMask truncateRulesAtStop(RuleMask match_mask, + const uint8_t priorities[], + const uint8_t stop_flags[], + uint8_t count) { + return truncateRulesAtStop( + match_mask, priorities, NULL, stop_flags, count); +} + inline bool stopActionApplies(bool stop_on_match, bool has_region_target, bool region_target_usable) { // A direct scope= target is derived from its public name and is always diff --git a/test/test_flood_filter_policy/test_flood_filter_policy.cpp b/test/test_flood_filter_policy/test_flood_filter_policy.cpp index 65b89d33..c9a7be74 100644 --- a/test/test_flood_filter_policy/test_flood_filter_policy.cpp +++ b/test/test_flood_filter_policy/test_flood_filter_policy.cpp @@ -375,6 +375,59 @@ TEST(FloodRuleOrder, HighestPriorityWinsAndSlotBreaksTies) { matches, visited, priorities, 4)); } +TEST(FloodRuleOrder, ExactChannelBeatsHashAndWildcardAtEqualPriority) { + const uint8_t priorities[] = {40, 40, 40}; + const uint8_t specificities[] = { + FloodFilterPolicy::channelMatcherSpecificity( + FloodFilterPolicy::CHANNEL_HASH_ONLY_LEN), + FloodFilterPolicy::channelMatcherSpecificity(0), + FloodFilterPolicy::channelMatcherSpecificity( + FloodFilterPolicy::CHANNEL_KEY_128_LEN), + }; + uint32_t visited = 0; + + int first = FloodFilterPolicy::nextOrderedRule( + (uint32_t)0x07, visited, priorities, specificities, 3); + ASSERT_EQ(2, first); + visited |= (uint32_t)1U << first; + int second = FloodFilterPolicy::nextOrderedRule( + (uint32_t)0x07, visited, priorities, specificities, 3); + ASSERT_EQ(0, second); + visited |= (uint32_t)1U << second; + EXPECT_EQ(1, FloodFilterPolicy::nextOrderedRule( + (uint32_t)0x07, visited, priorities, + specificities, 3)); +} + +TEST(FloodRuleOrder, ExplicitPriorityOverridesChannelSpecificity) { + const uint8_t priorities[] = {41, 40}; + const uint8_t specificities[] = { + FloodFilterPolicy::channelMatcherSpecificity( + FloodFilterPolicy::CHANNEL_HASH_ONLY_LEN), + FloodFilterPolicy::channelMatcherSpecificity( + FloodFilterPolicy::CHANNEL_KEY_128_LEN), + }; + + EXPECT_EQ(0, FloodFilterPolicy::nextOrderedRule( + (uint32_t)0x03, (uint32_t)0, priorities, + specificities, 2)); +} + +TEST(FloodRuleOrder, ExactStopProtectsCollisionFromEarlierHashSlot) { + const uint8_t priorities[] = {0, 0}; + const uint8_t specificities[] = { + FloodFilterPolicy::channelMatcherSpecificity( + FloodFilterPolicy::CHANNEL_HASH_ONLY_LEN), + FloodFilterPolicy::channelMatcherSpecificity( + FloodFilterPolicy::CHANNEL_KEY_128_LEN), + }; + const uint8_t stop_flags[] = {0, 1}; + + EXPECT_EQ(0x02U, FloodFilterPolicy::truncateRulesAtStop( + (uint32_t)0x03, priorities, specificities, + stop_flags, 2)); +} + TEST(FloodRuleOrder, StopRemovesOnlyLowerOrderedMatches) { const uint8_t priorities[] = {10, 30, 20, 5}; const uint8_t stop_flags[] = {0, 0, 1, 0};