Extend forwarding filters and save moderation rules atomically

This commit is contained in:
mikecarper committed 2026-10-21 01:10:12 -07:00
1 parent 585393e36c
commit 953ad6defb
31 files changed
+3453 -342

No files matched your search

+5
View File
@@ -360,7 +360,12 @@ jobs:
- name: Verify bridge filter wiring and shorthand permissions
run: |
python3 -B test/test_flood_text_pattern.py -v
python3 -B test/test_flood_rule_extensions.py -v
python3 -B test/test_flood_rule_cli.py -v
python3 -B test/test_flood_moderation.py -v
python3 -B test/test_transport_filter_wiring.py -v
python3 -B test/test_room_filter_extensions.py -v
python3 -B test/test_rs232_runtime.py -v
python3 -B test/test_rs232_mqtt_runtime.py -v
python3 -B test/test_rs232_driver_lifecycle.py -v
+112 -17
View File
@@ -3333,7 +3333,7 @@ setting.
| Mode | Where the rule applies |
| --- | --- |
| `radio` | Ordinary LoRa flood forwarding. This is the default when `mode=` is omitted. |
| `radio` | Ordinary LoRa forwarding; flood-only unless `route=direct` or `route=any` is selected. This is the default mode. |
| `bridge` | Packets entering or leaving an RS232 or ESP-NOW bridge, and MeshCore RX/TX packet publication through MQTT. |
| `cross` | Transmissions copied to the other radio profile, including a copy's retries. The same-profile transmission remains eligible. |
| `bridge,cross` | Both transport paths using one rule. `cross,bridge` is equivalent. |
@@ -3398,6 +3398,10 @@ removed. The fixed-size STM32WL FPF6 build cannot match authenticated channels.
- `del flood.channel.scope.<n>`
- `del flood.channel.scope all`
`fs` is the short family name for all these commands. Short options are
`p=bl` (`path=blacklist`), `p=b1` through `p=b6` (`path=bucket:1` through
`path=bucket:6`), and `tx=s`/`tx=f` (`tx=slow`/`tx=fast`).
**Parameters:**
- `n`: Slot number within the table compiled for the target. Roomy ESP32 builds
provide `1-255`; classic ESP32 repeaters and nRF52/other
@@ -3606,6 +3610,9 @@ available for tables shared with `flood.filter path=blacklist` rules.
- `del flood.channel.scope.require.<n>`
- `del flood.channel.scope.require all`
`fsr` is the short family name, with the same arguments and region/scope-manager
permissions.
**Default:** Empty; normal global region enforcement remains active.
Once this table contains a row, received flood `GRP_TXT` and `GRP_DATA`
@@ -3671,6 +3678,10 @@ worked moderation examples, see [Flood Filtering and Moderation](flood_filtering
- `del flood.filter.<n>`
- `del flood.filter all`
Generalized rows also accept `sender=`, `text=`, `len=`, `route=`, `prob=`,
`enabled=`, and anchored `path=` chains. These use the same table and ordering;
see the parameters and short forms below.
The extended table is available on repeaters with the rule engine enabled and
on FULL-profile ESP32 room servers. A FULL room server exposes
`fr`, `flood.rule`, and `flood.filter`, has 31 slots, and requires an administrator
@@ -3721,7 +3732,35 @@ compile this table.
relayed login/admin traffic, but not direct packets or local delivery.
- `prefix=<ID[,ID...]>`: Optional ordered source-path prefix of one to three
pbyte IDs. IDs must all be 2, 4, or 6 hex characters, matching a packet's
1-, 2-, or 3-byte pbyte width. `path=<prefix>` is an alias.
1-, 2-, or 3-byte pbyte width. `p=` is the short form.
- `path=[^]ID>ID...[$]` or `pa=...`: Ordered chain of up to four IDs, matched
anywhere in a flood path. `^` anchors the earliest relay and `$` the most
recent; both require exactly the chain. Commas may replace `>`.
Each ID must use the packet's complete hash width. Direct paths and TRACE
SNR bytes never match. `path=blacklist` remains the separate unordered table.
- `sender=<pattern>` (`n=`), `text=<pattern>` (`x=`): Case-sensitive patterns
on the decrypted sender name and message body. Require an explicit
authenticated channel (`public`, `#name`, or key); `channel=*` and raw hashes
are insufficient. These match only plain group text. Patterns search
anywhere unless anchored with `^`/`$`, and support `.`, classes such as
`[A-Z]` or `[^0-9]`, `*`, `+`, `?`, groups, `|`, and escaped literals.
Use `[0-9]` for digits and quote values containing spaces. The maximum is
63 bytes per pattern. Matching uses complete fields, removes AES zero
padding, and shares a 131,072-operation work budget across one rule
evaluation. Exhaustion rejects the packet; it cannot grant an allow,
rewrite, or retry action.
A message without `:` has no sender field but can match a text rule.
- `len=N|N+|N-M` (`l=`): Inclusive range of payload bytes, excluding route/path
metadata. Ciphertext padding counts toward payload length.
- `route=flood|direct|any` (`rt=f|d|a`): Select route classes. Omitted radio
rules retain flood-only behavior. Direct/any rows support drop, rate, and
stop/allow; scope/region, retry, and TX timing require flood-only radio rules.
- `prob=1-100` (`pr=`): Apply the row to approximately this percentage of
otherwise-matching packets. Omitted means `100`; a failed roll leaves later
rows eligible. It is a sampling rule, not a forwarding quota.
- `enabled=on|off` (`e=1|0`): Keep a row while enabling or disabling it. To
change only this state, use `set fr.<n> e=off` or `set fr.<n> e=on`; its
conditions and actions stay saved.
- `in=any|none|scoped|allowed|unknown|scope:<name>|region:<name>`: Optional
condition on the original incoming scope, before any rule rewrites it.
`none` is an unscoped flood. `scope:name` is the exact public
@@ -3758,6 +3797,8 @@ compile this table.
wildcard, or has no usable transport key, both the rewrite and its `stop`
are inert so lower-order safety rows still run. A direct `scope=` target does
not depend on region configuration.
`allow`, `forward`, and `a` are aliases for `stop`; they do not override
earlier drops, normal forwarding gates, or separate moderation rules.
- `suspend=tempradio`: Optional. Skip this row while either `tempradio` or
`tempradio2` is active, including scheduled temporary sessions.
- `require=region`: Legacy alias for `in=allowed`. Apply the row only if the
@@ -3799,11 +3840,11 @@ The payload names follow the [MeshCore packet-format allocation](https://docs.me
| `0x0E` | `14` | reserved |
| `0x0F` | `raw_custom` | `PAYLOAD_TYPE_RAW_CUSTOM` |
**Route scope:** `mode=radio` rules are evaluated only for the two flood route values:
**Route scope:** `mode=radio` defaults to the two flood route values:
`ROUTE_TYPE_TRANSPORT_FLOOD` (`0x00`, flood plus transport codes) and
`ROUTE_TYPE_FLOOD` (`0x01`, unscoped flood). Direct routes `0x02` and `0x03`
are unaffected by radio rules. Bridge/cross rules also cover direct routes at
their respective transport boundaries.
require an explicit `route=direct` or `route=any` on a radio row. Bridge/cross
rules cover both route classes by default at their transport boundaries.
**Behavior:** Match fields within one row are ANDed. Radio rows are matched
against the same immutable receive-time packet, before any rule changes its
@@ -3853,7 +3894,10 @@ The packet is still received and can still be logged. Rules are persistent
data and can be changed over serial or authenticated remote CLI without an OTA
or reboot. `flood.rule` and `flood.filter` address the same table on extended
builds; FPF6 files are migrated in memory and the next save writes FPF7.
FPF1-FPF5 files are rejected and filtering fails open. FPF7 stores canonical
New sender/text, length, route, probability, enabled-state, or extended-path
fields use FPF8 instead. Old firmware without FPF8 support cannot load that
table; remove the new fields before downgrading and keep a copy of the rules.
FPF1-FPF5 files are rejected and filtering fails open. FPF7/FPF8 store canonical
region names rather than transient numeric region
IDs. Removing, reordering, or reusing a region ID therefore cannot silently
retarget a rule. If a saved input or target region name is absent, that input
@@ -3864,11 +3908,10 @@ mode; normal payload types can also use the temporary channel. Other rows
remain in force, and suspended rows resume after both temporary sessions end.
A malformed persisted table fails open (no general rules are applied).
Within one receive evaluation, rows that use the same channel key share one
authentication result. The cache is discarded after that packet and stores
neither plaintext nor passwords; different keys are authenticated separately.
Raw `hash:XX` rows skip this authentication and compare only the one visible
byte.
Within one receive evaluation, rows using the same channel key share the
initial authentication verdict; content patterns also decrypt into temporary
scratch for matching. The cache and plaintext are discarded after the packet.
Raw `hash:XX` rows skip authentication and compare only the visible byte.
**Per-channel retry examples:**
@@ -3962,24 +4005,56 @@ short setter; `get fr` lists the table. Unnumbered `set fr ...` reuses an
identical row or adds one to the first empty slot. `del fr.N` and `del fr all`
have the same behavior as their long forms.
Content, exceptions, and state changes use that same setter:
```text
set fr grp_txt c=#local x="^BEACON" d
set fr grp_txt c=#local n="^Alice$" pri=200 a
set fr grp_txt c=#local d
set fr.4 e=off
set fr.4 e=on
```
The Alice rule stops lower-order general drops; separate moderation and hard
forwarding gates still apply. Sender names remain unverified display text.
Saved older prefix rules keep their start-anchored semantics; when retyping an
old `path=A1,B2`, use `prefix=A1,B2` or `pa=^A1>B2`.
| Full token | Short token |
| --- | --- |
| `type=...` | Positional type or `t=...`; `*` means `any` |
| `hops=...` | Positional range or `h=...`; `*` means `all` |
| `mode=radio/bridge/cross/bridge,cross` | `m=r/b/c/bc` (choose one value) |
| `channel=...`, `prefix=...` | `c=...`, `p=...` |
| `path=[^]ID>ID...[$]` | `pa=...` |
| `sender=...`, `text=...` | `n=...`, `x=...` |
| `len=N/N+/N-M` | `l=...` |
| `route=flood/direct/any` | `rt=f/d/a` |
| `prob=N`, `enabled=on/off` | `pr=N`, `e=1/0` |
| `hashbytes=any/1/2/2+/3` | `pb=*/1/2/2+/3` (choose one value) |
| `path=blacklist` | `p=bl` (repeater only) |
| `in=any/none/scoped/allowed/unknown` | `i=*/n/s/a/u` (choose one value) |
| `in=scope:name`, `in=region:name` | `i=s:name`, `i=r:name` |
| `drop`, `stop`, `retry` | `d`, `s`, `r` |
| `allow`, `forward` | `a` (same action as `stop`) |
| `scope=name`, `region=name` | `s=name`, `r=name` |
| `rate=N/min`, `priority=N` | `q=N`, `pri=N` |
| `tx=slow`, `suspend=tempradio`, `retry` | `f=s`, `f=t`, `f=r`; combine as `f=str` |
All existing validation and permissions apply. `m=cb`, `m=b,c`, and `m=c,b`
also mean bridge and crossover. Short aliases for `flood.channel.scope`,
`flood.moderation`, and blacklist management are not added.
also mean bridge and crossover.
| Full family | Short family | Commands |
| --- | --- | --- |
| `flood.moderation` | `fm` | `get`, `set`, `del` |
| `flood.channel.scope` | `fs` | `get`, `set`, `del` |
| `flood.channel.scope.require` | `fsr` | `get`, `set`, `del` |
| `flood.filter.blacklist` | `fb` | `get`, `set`, `del` |
| `flood.advert` | `fa` | `get`, `clear` |
Slot arguments, output, validation, and permissions are shared with the full
family names. For example, `get fa key 1` inspects the full advert key and
`clear fa all` clears advert history.
The numbered long getters use this complete short setter when descriptive
fields would exceed a reply. Defaults are omitted and specific payload types
@@ -3993,7 +4068,7 @@ matching rule. Unknown or ambiguous references are rejected. See the
On generalized repeaters, filter rows, scope-rewrite rows, the shared
blacklist, and `flood.channel.data` compatibility state are committed in one
atomic FPF7 image. Compact FPF6 profiles retain separate files. Replacing or deleting
atomic FPF7/FPF8 image. Compact FPF6 profiles retain separate files. Replacing or deleting
the blacklist does not delete rows containing `path=blacklist`; such rows
remain dormant while the list is empty. Path hashes are truncated routing
identifiers, not authenticated identities, so this is a forwarding signal
@@ -4055,7 +4130,10 @@ The fixed 240 KB STM32WL profiles leave
`MESH_ENABLE_FLOOD_RULE_ENGINE=0` and retain the compact, persistent FPF6
`flood.filter` and blacklist commands. They still filter floods, but do not
expose the `flood.rule` alias or its extended channel, prefix, input-scope,
region-action, or rate fields. No partition size changes are required.
region-action, or rate fields, nor sender/text patterns, length, route,
probability, enabled-state, or anchored chains. Their compiled legacy
families still accept the `fm`/`fs`/`fsr`/`fb`/`fa` aliases; `fm` requires
moderation support in that build. No partition size changes are required.
---
@@ -4069,6 +4147,10 @@ region-action, or rate fields. No partition size changes are required.
- `del flood.moderation.<n>`
- `del flood.moderation all`
`fm` is the short family name. Options can use `d` (`drop`), `q=N`
(`rate=N/min`), `h=N` (`hops=N`), and `p=...` (`path=...`). The short rate
also accepts `q=N/min` or `q=N/m`; duplicate rate/drop options remain invalid.
**Parameters:**
- `n`: Moderation slot from `1` to `16`.
- `channel`:
@@ -4090,6 +4172,12 @@ At least one of `drop`, `rate=X/min`, or `hops=N` is required. Rate and hop
limits can be combined. Rate counters are local to this repeater and rule, use a
60-second window beginning with the first matching message, and reset on reboot.
Configured exact names are limited to 31 bytes. A wildcard `*` with a hop
restriction also matches longer valid sender names. The FGM1 storage format
is unchanged; saves use staging, readback, and backup recovery. A save reported
as unconfirmed may already have published, so rebooted settings can differ
from the restored live table.
**Decode and identity behavior:** Moderation applies only to flood
`PAYLOAD_TYPE_GRP_TXT`. The repeater first checks the packet's channel-hash byte,
then validates and decrypts with the configured key. It extracts the text before
@@ -4111,6 +4199,7 @@ retransmission is denied. There are no moderation rules by default.
set flood.moderation public "Noisy User" rate=5/min
set flood.moderation #local bot drop path=A1B2C3,D4E5F6
set flood.moderation.3 00112233445566778899AABBCCDDEEFF alice rate=10/min hops=4 path=71CE82
set fm public "Noisy User" q=5
get flood.moderation
get flood.moderation.3
del flood.moderation.3
@@ -4138,14 +4227,20 @@ del flood.moderation.3
non-secret operational/filter status commands and can change the forwarding
controls `repeat`, `loop.detect`, `flood.max*`, `flood.channel.data*`,
`flood.filter*`, `flood.rule*`, `fr[.N]`, and
`flood.moderation*`. It cannot read
`flood.moderation*`, including the `fm`, `fb`, and `fa` aliases. It cannot read
guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions,
ACL entries, radio settings, or other admin configuration. Permission `4`
is limited to region commands, `flood.channel.scope*`, and the same non-secret
is limited to region commands, `flood.channel.scope*` (including `fs` and
`fsr`), and the same non-secret
status allowlist.
Both delegated manager roles are protected from least-recently-active ACL
eviction like administrators.
The same filter aliases work inside enrolled
[signed fleet commands](fleet_management.md), including quoted sender/text
patterns. Fleet signature, expiry, replay, and command-size checks still apply;
ordinary channel text cannot authorize a change.
**Note:** Removes the entry when `permissions` is omitted
---
+149 -23
View File
@@ -13,12 +13,15 @@ not compile the rule table. Filters decide whether the node retransmits a
packet and can assign a transport scope before that decision. They do not stop
local reception, packet logging, or MQTT observation.
Only flood routes are filtered:
Radio rules default to the two flood routes:
- `0x00` / `ROUTE_TYPE_TRANSPORT_FLOOD` - flood routing with transport codes
- `0x01` / `ROUTE_TYPE_FLOOD` - unscoped flood routing
Direct routes `0x02` and `0x03` are never affected by these rules.
Generalized rules can opt into relayed direct routes (`0x02` and `0x03`) with
`route=direct`, or both route classes with `route=any`. Scope rewriting,
source-path matching, and the legacy moderation table remain flood-only.
Local delivery and requests addressed to this node are not relay decisions.
The route and payload values follow the upstream
[packet-format reference](https://docs.meshcore.io/packet_format/) and
[payload layouts](https://docs.meshcore.io/payloads/), with this fork's LoRa
@@ -163,7 +166,7 @@ get flood.moderation
```
`flood.rule` is an alias for `flood.filter`, not another table. Generalized
repeater FPF7 has 63 forward-rule slots plus scope-rewrite and shared-blacklist
repeaters have 63 forward-rule slots plus scope-rewrite and shared-blacklist
sections in the same atomic policy file. FULL room servers have 31 forward
slots and empty repeater-only sections. Compact target profiles retain their
separate FPF6-era controls. `flood.moderation` has 16 slots. A new
@@ -195,6 +198,10 @@ del flood.channel.scope.<slot>
del flood.channel.scope all
```
Use `fs` in place of `flood.channel.scope` for any of these commands. Its
optional fields also accept `p=bl` for `path=blacklist`, `p=b1` through `p=b6`
for `path=bucket:1` through `path=bucket:6`, and `tx=s`/`tx=f` for slow/fast.
The channel may be `public`, `#channel`, or a 128/256-bit hex key. A bare
target names an existing region with a usable transport key. Use
`scope=<name>` instead to derive a regionless public hashtag scope exactly as
@@ -327,7 +334,7 @@ targets, except that very-tight STM32WL builds retain one reusable direct
target. Both configured regions and regionless targets can be reused by any
number of rules.
On generalized builds these records are the FPF7 rewrite phase, and the file
On generalized builds these records are the shared rewrite phase, and the file
stores only through the highest occupied slot. Compact FPF6 builds retain the
standalone FCS5 file and the file sizes described by their build profile.
@@ -346,6 +353,9 @@ del flood.channel.scope.require.<slot>
del flood.channel.scope.require all
```
`fsr` is the short name for `flood.channel.scope.require`, with the same
arguments, slots, and permissions.
An empty table preserves the normal global region behavior. Once at least one
row exists, a group-channel packet that authenticates against a listed key must
arrive as `ROUTE_TYPE_TRANSPORT_FLOOD` with a transport code matching a locally
@@ -414,9 +424,12 @@ On repeaters with the rule engine enabled and on FULL-profile ESP32 room
servers, `fr`, `flood.rule`, and `flood.filter` name the same persistent
table. The evaluator is fixed firmware, but every row is data, so an
authenticated operator can add, replace, inspect, or delete a row without an
OTA or reboot. Existing `flood.filter` commands remain compatible. Only FPF6
and FPF7 files are accepted; FPF1-FPF5 files are rejected and filtering fails
open. A row saved by the extended engine uses FPF7.
OTA or reboot. Existing `flood.filter` commands remain compatible. FPF6,
FPF7, and FPF8 files are accepted; FPF1-FPF5 files are rejected and filtering
fails open. New sender/text, payload-length, route, probability, enabled-state,
or extended-path fields are stored in FPF8. Tables using only older fields
retain FPF7. Firmware without FPF8 support cannot load an FPF8 table; remove
the new fields before downgrading and keep a copy of the rules.
Older firmware cannot preserve an FPF7 table containing the packed `retry`
action, one-byte `hash:XX` matcher, or `mode=bridge`/`cross` flags; remove those
rows before downgrading. Old firmware rejects such a table and fails open,
@@ -439,7 +452,7 @@ of FPF7. Normal ordering applies, so a matching higher-priority `stop` row can
exempt selected traffic. The compact rule list marks the managed row with
`~data`.
FPF7 binds `in=region:<name>` and `region=<name>` to canonical region names,
FPF7 and FPF8 bind `in=region:<name>` and `region=<name>` to canonical region names,
not numeric region IDs. Removing, reordering, or reusing a region ID cannot
silently redirect a rule. If the saved name is missing, an input-region match
does not match and a target-region rewrite is skipped. Re-adding the same name
@@ -450,7 +463,9 @@ The extended form is:
```text
set flood.rule[.<slot>] type=<type> [mode=radio|bridge|cross|bridge,cross]
[hops=<range>] [channel=<channel>] [hashbytes=any|1|2|2+|3]
[prefix=<ID[,ID...]>] [in=<input-scope>] <action> [rate=<N>/min]
[prefix=<ID[,ID...]>|path=[^]ID>ID...[$]] [in=<input-scope>]
[sender=<pattern>] [text=<pattern>] [len=<range>] [route=flood|direct|any]
[prob=<1-100>] [enabled=on|off] <action> [rate=<N>/min]
[priority=<0-255>] [stop] [tx=fast|slow] [suspend=tempradio]
get flood.rule
get flood.rule.<slot>
@@ -510,8 +525,34 @@ Match fields:
- `prefix=` is a source-path prefix containing one to three comma-separated
pbyte IDs. Every ID must use the packet's pbyte width: 2, 4, or 6 hex
characters for 1-, 2-, or 3-byte paths. Order matters and matching begins at
the first received path entry. `path=<prefix>` is an alias;
`path=blacklist` retains its separate unordered-list behavior.
the first received path entry. Its short name is `p=`.
- `path=` or `pa=` matches an ordered chain of up to four IDs anywhere in a
flood path. Separate IDs with `>` or `,`; `^` anchors the first entry and
`$` the last. Thus `pa=A1>B2` finds adjacent entries anywhere,
`pa=A1$` selects the most recent relay, and `pa=^A1>B2$` requires exactly
those two entries. Each ID must use the packet's full 1-, 2-, or 3-byte
width. Direct paths and TRACE SNR bytes never match. `path=blacklist`
retains its separate unordered-list behavior.
Saved older prefix rules keep their start-anchored meaning. When retyping an
older `path=A1,B2` command, use `prefix=A1,B2` or `pa=^A1>B2` to retain it.
- `sender=` (`n=`) and `text=` (`x=`) match decrypted group text. An explicit
authenticated channel (`public`, `#name`, or a channel key) is required;
a raw channel hash is insufficient. See [content patterns](#content-patterns).
- `len=` (`l=`) matches payload bytes, using `N`, `N+`, or inclusive `N-M`.
It excludes the route/path envelope; encrypted padding is part of the payload.
- `route=` (`rt=`) selects `flood`, `direct`, or `any`; `f`, `d`, and `a` are
short values. Omitted radio rules remain flood-only. Direct/any rows may
drop, rate-limit, or stop; scope/region rewriting, retry, and TX timing
require flood-only radio rows.
- `prob=` (`pr=`) applies this rule to approximately that percentage of
otherwise-matching packets. Values are `1-100`; omitted means `100`.
A failed roll lets later rules run. This is sampling, not a delivery quota.
The decision stays the same for the same packet and row during one boot,
including repeated timing checks and scope rewrites.
- `enabled=off` (`e=off`) retains a row without evaluating it. `on`/`1`
enables and `off`/`0` disables. Change only this state with
`set fr.<slot> e=off` and `set fr.<slot> e=on`; the rest of the row stays saved.
- `in=any|none|scoped|allowed|unknown|scope:<name>|region:<name>` tests the
original incoming route before any rewrite. `none` means an unscoped flood;
`scoped` means any transport flood; `scope:name` compares the exact public
@@ -549,12 +590,52 @@ Actions:
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.
`allow`, `forward`, and `a` are aliases for the same stop action; they
do not bypass higher-priority drops, hard forwarding gates, or moderation.
When several rows use the same channel key, authentication is performed once
for that packet and reused by those rows. This cache lives only for the current
receive evaluation; it is not persisted and never stores plaintext or a
password. A `hash:XX` row deliberately skips authentication and uses only the
one visible byte.
### Content patterns
Sender/text patterns are case-sensitive byte matches. They search anywhere
unless anchored with `^` and `$`. Supported forms include `.`, `[A-Z]`,
`[^0-9]`, `*`, `+`, `?`, grouping, and `|` alternatives. Escape punctuation
with `\` to match it literally; use `[0-9]` for digits. Each pattern is at
most 63 bytes. Quote values containing spaces.
For `Alice: BEACON 123`, `n=^Alice$` matches the display name and
`x=^BEACON` matches the body. They can be combined in one row. The parser
uses the complete fields, including names longer than 31 bytes, and removes
AES zero padding before matching. A message without `:` has no sender field;
a text rule can still inspect its body. Wrong keys and non-text payloads do
not match content rules. Pattern matching shares a 131,072-operation budget
across the rules in one evaluation. Exhausting it rejects the packet rather
than granting an unverified allow, rewrite, or retry action. Keep patterns
short and specific.
```text
# Block BEACON messages on this channel.
set fr grp_txt c=#local x="^BEACON" d
# Exempt Alice from lower-order general rules, then block everyone else.
set fr grp_txt c=#local n="^Alice$" pri=200 a
set fr grp_txt c=#local d
# Temporarily disable an occupied row without deleting its settings.
set fr.4 e=off
set fr.4 e=on
# Block floods whose most recent relay has this 3-byte ID.
set fr any pa=A1B2C3$ d
```
The allow exception remains subject to separate moderation and normal
forwarding gates. A sender name is freely chosen message text, not a verified
public-key identity. Legacy `flood.moderation` keeps its existing exact,
case-insensitive name matching.
Rows using the same channel key reuse the initial authentication verdict.
Content patterns also decrypt into temporary scratch for matching. Neither
the verdict cache nor plaintext survives the receive evaluation. A `hash:XX`
row skips authentication and uses only the one visible byte.
The global retry controls remain hard gates. A `retry` row does not override a
zero `flood.retry.count`, path/type attempt caps, disabled forwarding,
@@ -696,6 +777,11 @@ set fr.3 any c=#wardriving m=bc d
| `mode=radio`, `bridge`, `cross`, `bridge,cross` | `m=r`, `m=b`, `m=c`, `m=bc` | Select the admission path. Omitted means radio; `cb`, `b,c`, and `c,b` also mean both transport paths. |
| `channel=...` | `c=...` | Same authenticated name/key or explicit `hash:XX` matcher. |
| `prefix=...` | `p=...` | Ordered path prefix. |
| `path=[^]ID>ID...[$]` | `pa=...` | Ordered chain anywhere, or anchored at either end. |
| `sender=...`, `text=...` | `n=...`, `x=...` | Case-sensitive patterns on an authenticated group-text channel. |
| `len=N`, `N+`, or `N-M` | `l=...` | Payload-byte range. |
| `route=flood`, `direct`, `any` | `rt=f`, `rt=d`, `rt=a` | Route class. |
| `prob=N`, `enabled=on/off` | `pr=N`, `e=1/0` | Sample matches or retain a disabled row. |
| `hashbytes=any`, `1`, `2`, `2+`, or `3` | `pb=*`, `1`, `2`, `2+`, or `3` | Encoded path-hash width, even with zero hops; `2+` matches 2 and 3. |
| `path=blacklist` | `p=bl` | Shared unordered blacklist; repeater only. |
| `in=any`, `none`, `scoped`, `allowed`, `unknown` | `i=*`, `i=n`, `i=s`, `i=a`, `i=u` | Incoming scope condition. |
@@ -705,6 +791,7 @@ set fr.3 any c=#wardriving m=bc d
| `rate=N/min` | `q=N` | Per-row forwarding allowance. |
| `priority=N` | `pri=N` | Higher values run first. |
| `stop`, `retry` | `s`, `r` | Stop later rules or allow the configured flood retry. |
| `allow`, `forward` | `a` | Same action as `stop`; other forwarding gates still apply. |
| `tx=slow`, `suspend=tempradio`, `retry` | `f=s`, `f=t`, `f=r` | Flags can combine, for example `f=str`. |
The `=` distinguishes `s=name` (scope) from `s` (stop), and `r=name`
@@ -752,9 +839,21 @@ the last rule holding that key, supply the original key instead. An unknown
or ambiguous reference is rejected; it never becomes an unauthenticated
channel-hash match.
The shorthand does not rename `flood.channel.scope`, `flood.moderation`, or
the blacklist-management commands. Companions and compact FPF6 profiles do
not expose `fr`.
Other repeater families have short names too, with identical arguments,
slot numbers, validation, persistence, and permissions:
| Full family | Short family | Commands |
| --- | --- | --- |
| `flood.moderation` | `fm` | `get`, `set`, `del` |
| `flood.channel.scope` | `fs` | `get`, `set`, `del` |
| `flood.channel.scope.require` | `fsr` | `get`, `set`, `del` |
| `flood.filter.blacklist` | `fb` | `get`, `set`, `del` |
| `flood.advert` | `fa` | `get`, `clear` |
For example, `get fa key 1` inspects a full advert key and `clear fa all`
clears the advert limiter history. Companions do not expose these repeater
families. Compact FPF6 profiles retain their compiled legacy commands and
their family aliases, but do not expose `fr` or the new generalized fields.
### Scope actions and legacy rules
@@ -791,7 +890,7 @@ radio transmission, CAD, and airtime-budget limits are unchanged.
On generalized repeaters, forward rules, `flood.channel.scope` rewrite rows,
the shared blacklist, and the `flood.channel.data` compatibility state are one
atomic FPF7 policy image. Existing `/flood_ch_scope`, `/flood_filter_bl`, FPF6,
atomic FPF7/FPF8 policy image. Existing `/flood_ch_scope`, `/flood_filter_bl`, FPF6,
and FCB2 data is imported once; the old files are removed only after the new
image verifies and commits. Compact FPF6 repeaters retain separate files.
Deleting the blacklist leaves `path=blacklist` rows in place but dormant until
@@ -975,6 +1074,11 @@ Available actions are:
- `path=H1[,H2,H3]` - require the first one to three path hashes to match
- `path=*` - match every path; this is the default
`fm` is the short family name. Its option aliases are `d` for `drop`,
`q=N` for `rate=N/min`, `h=N` for `hops=N`, and `p=...` for `path=...`.
`q=N/min` and `q=N/m` are also accepted; mixed duplicate rate/drop options
are rejected just as in the full form.
At least one of `drop`, `rate=X/min`, or `hops=N` is required. Rate and hop
limits can be combined. `rate=0/min` is equivalent to `drop`.
@@ -995,8 +1099,17 @@ set flood.moderation #local "Noisy User" rate=10/min
# Combine a rate limit with a maximum forwarding distance.
set flood.moderation public alice rate=4/min hops=5
# Same rule using short names.
set fm public alice q=4 h=5
```
The wildcard sender `*` works with a hop restriction, including for names
longer than 31 bytes. Exact configured names remain limited to 31 bytes.
Moderation settings retain the FGM1 format and use staged writes, readback,
and backup recovery. A failed save is reported as unconfirmed because a
failure after publication can leave the new settings available on reboot.
### Match the start of a path
Path matching accepts one-, two-, or three-byte hashes. Every hash in one rule
@@ -1056,7 +1169,8 @@ setperm <companion-public-key-hex> 5
A filter manager can read non-secret operational status and manage `repeat`,
`loop.detect`, `flood.max*`, `flood.channel.data*`,
`flood.filter*`, `flood.rule*`, `fr[.N]`, and `flood.moderation*`. Delegated `get` access uses an
`flood.filter*`, `flood.rule*`, `fr[.N]`, and `flood.moderation*`, including
the `fm`, `fb`, and `fa` aliases. Delegated `get` access uses an
explicit allowlist: it cannot retrieve guest, WiFi, MQTT, bridge, or other
credentials, and it cannot change regions, ACL entries, radio settings, or
unrelated administrator settings. Because `flood.filter scope=` derives a
@@ -1071,7 +1185,19 @@ ACL permission `4`, the region/scope-manager role, can read, add, replace, and
delete `flood.channel.scope` and `flood.channel.scope.require` rows and manage
regions. This lets the same delegate create target regions, assign forced
scopes from regions or direct hashtag names, and select the channels that
require valid incoming scopes.
require valid incoming scopes. The `fs` and `fsr` aliases retain this role.
Admin CLI sessions and these delegated roles use the same setters and
validation as the serial console. On enrolled builds, the aliases also work
inside [signed fleet commands](fleet_management.md), for example:
```text
fleet send 3 set fr.4 grp_txt c=public x="^BEACON" d
fleet send 3 set fr.4 e=off
```
Fleet enrollment, signature, expiry, replay, and command-size checks still
apply. An ordinary channel message cannot authorize a filter change.
## Security limitations
@@ -1092,7 +1218,7 @@ The `mode=` matcher selects a separate admission phase within the same
| Mode | Where the rule applies |
| --- | --- |
| `radio` | Ordinary LoRa flood forwarding. This is the default when `mode=` is omitted. |
| `radio` | Ordinary LoRa forwarding; flood-only by default, or opt into direct routes with `route=`. This is the default mode. |
| `bridge` | Packets entering or leaving an RS232 or ESP-NOW bridge, and MeshCore RX/TX packet publication through MQTT. |
| `cross` | Transmissions copied to the other radio profile, including a copy's retries. The same-profile transmission remains eligible. |
| `bridge,cross` | Both transport paths using one rule. `cross,bridge` is equivalent. |
+360 -151
View File
@@ -33,6 +33,7 @@
#include <new> // std::nothrow (heap-allocated flood rule table)
#include <stdlib.h> // for qsort()
#include <helpers/CLICommandUtils.h>
#include <helpers/FloodCommandAliases.h>
#if defined(NRF52_POWER_MANAGEMENT)
#include <helpers/NRF52VoltagePolicy.h>
#endif
@@ -192,6 +193,8 @@ static_assert(mesh::HostCliBridge::REMOTE_REPLY_MAX
#define FLOOD_CHANNEL_SCOPE_REQUIRE_FILE "/flood_ch_req"
#define FLOOD_CHANNEL_SCOPE_REQUIRE_TEMP_FILE "/flood_ch_req.tmp"
#define FLOOD_GROUP_MODERATION_FILE "/flood_grp_mod"
#define FLOOD_GROUP_MODERATION_TEMP_FILE "/flood_grp_mod.tmp"
#define FLOOD_GROUP_MODERATION_BACKUP_FILE "/flood_grp_mod.bak"
#define CLOCK_SYNC_PREFS_FILE "/clock_sync"
#define DEFAULT_WARDRIVING_CHANNEL "#wardriving"
static const char FLOOD_PACKET_FILTER_USAGE[] =
@@ -1070,8 +1073,21 @@ bool MyMesh::floodChannelDataHopApplies(const mesh::Packet* packet) const {
}
bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
if (!packet) return false;
if (_prefs.disable_fwd && !(_prefs.trace_when_repeat_off == 1 && packet
&& packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE)) return false;
#if !defined(PORTABLE_MQTT_OBSERVER) && MESH_ENABLE_FLOOD_RULE_ENGINE
uint64_t matches = recv_pkt_filter_match_mask;
if (packet->isRouteDirect()) {
const bool scoped = packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT;
RegionEntry* incoming_region = scoped
? region_map.findMatch(packet, REGION_DENY_FLOOD) : NULL;
const bool incoming_allowed = scoped ? incoming_region != NULL
: (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0;
matches = evaluateFloodPacketFilterMatches(packet, incoming_allowed, incoming_region);
if (shouldBlockFloodPacketForward(packet, matches)) return false;
}
#endif
if (packet->isRouteFlood()) {
if (mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max,
_prefs.flood_max_unscoped,
@@ -1123,7 +1139,11 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
#if MESH_ENABLE_FLOOD_GROUP_MODERATION
if (packet->isRouteFlood() && shouldBlockFloodGroupTextForward(packet)) return false;
#endif
#if MESH_ENABLE_FLOOD_RULE_ENGINE
commitFloodPacketFilterRates(packet, matches);
#else
if (packet->isRouteFlood()) commitFloodPacketFilterRates(packet, recv_pkt_filter_match_mask);
#endif
// Normal path mode accepts clock evidence only from packets this node would
// forward. Edge mode observes verified evidence on the receive path instead,
// so repeat off and other forwarding filters do not hide a single upstream
@@ -3812,6 +3832,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
// OTA mesh-integration (receive/begin/loop) is centralized in mesh::Mesh - no per-example wiring.
void MyMesh::begin(FILESYSTEM *fs) {
#if MESH_ENABLE_FLOOD_RULE_ENGINE
flood_filter_probability_seed = getRNG()->nextInt(0, UINT32_MAX);
#endif
mesh::hilStartupTrace("mesh_dispatcher_begin");
mesh::Mesh::begin(); // also starts OTA (ota_ctx().begin) for all roles
mesh::hilStartupTrace("mesh_dispatcher_ready");
@@ -6355,7 +6378,8 @@ bool MyMesh::loadFloodPacketFilters() {
uint8_t count = 0;
bool success = file.read(magic, sizeof(magic)) == sizeof(magic);
bool version_6 = success && memcmp(magic, "FPF6", sizeof(magic)) == 0;
bool version_7 = success && memcmp(magic, "FPF7", sizeof(magic)) == 0;
bool version_8 = success && memcmp(magic, "FPF8", sizeof(magic)) == 0;
bool version_7 = version_8 || (success && memcmp(magic, "FPF7", sizeof(magic)) == 0);
success = (version_6 || version_7)
&& file.read(&count, sizeof(count)) == sizeof(count);
// A wider firmware image can write inactive trailing slots. Decode them
@@ -6454,6 +6478,9 @@ bool MyMesh::loadFloodPacketFilters() {
success = success
&& file.read(&stop_on_match, sizeof(stop_on_match))
== sizeof(stop_on_match);
if (success && version_8) {
success = FloodRuleExtensions::readFields(file, entry.extra);
}
} else {
entry.incoming_scope_kind = scope_requires_region_match
? FloodFilterPolicy::RULE_IN_ALLOWED
@@ -6557,9 +6584,7 @@ bool MyMesh::loadFloodPacketFilters() {
|| entry.payload_type == PAYLOAD_TYPE_GRP_DATA;
}
bool path_valid = FloodFilterPolicy::pathMatcherValid(
entry.path_hash_size, entry.path_hops,
entry.match_blacklisted_path);
bool path_valid = FloodRuleExtensions::validRuleFields(entry);
bool action_valid = entry.drop_on_match || direct_target
|| region_target || entry.rate_limit_enabled
|| entry.stop_on_match || entry.retry_on_match;
@@ -6827,7 +6852,8 @@ bool MyMesh::isFloodChannelDataRule(
&& !entry.rate_limit_enabled
&& entry.priority == 0
&& !entry.stop_on_match
&& !entry.retry_on_match;
&& !entry.retry_on_match
&& !FloodRuleExtensions::hasFields(entry.extra);
}
int MyMesh::findFloodChannelDataRule() const {
@@ -7177,10 +7203,12 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
return true;
};
const uint8_t magic[4] = {'F', 'P', 'F', '7'};
const uint8_t count = FloodFilterPolicy::forwardPersistenceCount(
flood_packet_filters, flood_packet_filter_slots,
empty_forward_phase);
const bool version_8 = FloodRuleExtensions::tableUsesExtensions(
flood_packet_filters, count);
const uint8_t magic[4] = {'F', 'P', 'F', version_8 ? uint8_t('8') : uint8_t('7')};
bool success = writeExact(magic, sizeof(magic))
&& writeExact(&count, sizeof(count));
for (int i = 0; success && i < count; i++) {
@@ -7229,6 +7257,9 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
sizeof(entry.target_region_name))
&& writeExact(&entry.priority, sizeof(entry.priority))
&& writeExact(&stop_on_match, sizeof(stop_on_match));
if (success && version_8) {
success = FloodRuleExtensions::writeFields(writeExact, entry.extra);
}
}
static_assert(FLOOD_CHANNEL_SCOPE_SLOTS <= 255,
"FPF7 scope phase count is one byte");
@@ -7456,14 +7487,16 @@ bool MyMesh::floodPacketFilterFieldsMatch(
bool incoming_region_allowed,
const RegionEntry* incoming_region, uint8_t context) const {
if (!entry.active || !FloodFilterPolicy::ruleModeMatches(
entry.transport_modes, context, packet)) return false;
entry.transport_modes, context, packet, entry.extra.route)
|| !FloodRuleExtensions::matchesEnvelope(entry.extra, packet, context)) return false;
if (!FloodFilterPolicy::channelKeyLengthSupported(
entry.channel_key_len)) return false;
if (entry.suspend_on_temp_radio && isAnyTempRadioActive()) return false;
if (entry.match_blacklisted_path && !floodPacketFilterBlacklistMatches(packet)) {
return false;
}
if (!FloodFilterPolicy::pathStartsWith(
if (!FloodFilterPolicy::pathHashBytesMatch(entry.path_hash_size, packet->getPathHashSize())
|| !FloodRuleExtensions::pathMatches(entry.extra,
packet, entry.path_hash_size, entry.path_hops, entry.path)) {
return false;
}
@@ -7471,7 +7504,8 @@ bool MyMesh::floodPacketFilterFieldsMatch(
uint8_t type = packet->getPayloadType();
uint8_t hops = packet->getPathHashCount();
if ((entry.payload_type != FLOOD_PACKET_FILTER_ANY_TYPE && entry.payload_type != type)
|| hops < entry.min_hops || hops > entry.max_hops) {
|| (packet->isRouteFlood() ? hops < entry.min_hops || hops > entry.max_hops
: entry.min_hops != 0 || entry.max_hops != FLOOD_PACKET_FILTER_MAX_HOPS)) {
return false;
}
@@ -7527,7 +7561,7 @@ bool MyMesh::authenticateFloodPacketFilterChannel(
bool MyMesh::hasFloodPacketFilterRetryRules() const {
for (int i = 0; i < flood_packet_filter_slots; i++) {
if (flood_packet_filters[i].active
if (flood_packet_filters[i].active && !flood_packet_filters[i].extra.disabled
&& flood_packet_filters[i].retry_on_match) return true;
}
return false;
@@ -7598,9 +7632,9 @@ uint64_t MyMesh::applyFloodPacketFilterStop(uint64_t match_mask) {
uint64_t MyMesh::evaluateFloodPacketFilterMatches(
const mesh::Packet* packet, bool incoming_region_allowed,
const RegionEntry* incoming_region, uint8_t context) {
static_assert(FLOOD_PACKET_FILTER_SLOTS <= 64,
"flood filter match mask supports at most 64 slots");
if (packet == NULL || (context == FloodFilterPolicy::RULE_MODE_RADIO && !packet->isRouteFlood())) return 0;
static_assert(FLOOD_PACKET_FILTER_SLOTS < 64,
"flood filter mask reserves bit 63 for exhausted pattern work");
if (packet == NULL) return 0;
bool incoming_is_scoped =
packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD
|| packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT;
@@ -7608,6 +7642,7 @@ uint64_t MyMesh::evaluateFloodPacketFilterMatches(
? packet->transport_codes[0] : 0;
bool channel_auth_checked[FLOOD_PACKET_FILTER_SLOTS] = { false };
bool channel_auth_valid[FLOOD_PACKET_FILTER_SLOTS] = { false };
uint32_t pattern_work = 131072;
uint64_t matches = 0;
for (int i = 0; i < flood_packet_filter_slots; i++) {
const auto& entry = flood_packet_filters[i];
@@ -7636,7 +7671,18 @@ uint64_t MyMesh::evaluateFloodPacketFilterMatches(
channel_auth_checked[i] = true;
channel_auth_valid[i] = authenticated;
}
if (authenticated) matches |= (uint64_t)1U << i;
bool budget_exceeded = false;
const bool content_matched = authenticated
&& FloodRuleExtensions::matchesContent(entry.extra,
packet, entry.channel_secret, entry.channel_key_len, &pattern_work,
false, &budget_exceeded);
if (budget_exceeded) return uint64_t(1) << 63;
if (content_matched
&& (entry.extra.probability == 0 || entry.extra.probability == 100
|| FloodRuleExtensions::matchesProbability(
entry.extra, FloodRuleExtensions::probabilityRoll(
packet, flood_filter_probability_seed, i, context))))
matches |= (uint64_t)1U << i;
}
return applyFloodPacketFilterStop(matches);
}
@@ -7692,6 +7738,7 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet,
bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet,
uint64_t match_mask) const {
if (packet == NULL) return false;
if (match_mask & (uint64_t(1) << 63)) return true;
uint8_t type = packet->getPayloadType();
uint8_t hops = packet->getPathHashCount();
@@ -7852,7 +7899,7 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
return;
}
const auto& entry = flood_packet_filters[index];
if (compact) {
if (compact || FloodRuleExtensions::hasFields(entry.extra) || entry.path_hops > 3) {
FloodRuleCLI::formatCompact(reply, reply_len, index + 1, entry);
return;
}
@@ -7951,6 +7998,28 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac
const char* selector = skipFloodFilterSpaces(args);
if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1);
if (*selector != 0) {
const char* field = strchr(selector, ' ');
if (field) {
char slot_text[4];
const size_t length = size_t(field - selector);
uint8_t slot = 0;
if (!length || length >= sizeof(slot_text)) {
strcpy(reply, "Err - invalid rule slot"); return;
}
memcpy(slot_text, selector, length); slot_text[length] = 0;
field = skipFloodFilterSpaces(field);
if (!parseFloodFilterUnsigned(slot_text, flood_packet_filter_slots, slot)
|| !slot || !flood_packet_filters[slot - 1].active) {
strcpy(reply, "Err - rule slot is empty");
} else if (floodFilterAsciiEqual(field, "n") || floodFilterAsciiEqual(field, "sender")) {
snprintf(reply, 160, "> %s", flood_packet_filters[slot - 1].extra.sender);
} else if (floodFilterAsciiEqual(field, "x") || floodFilterAsciiEqual(field, "text")) {
snprintf(reply, 160, "> %s", flood_packet_filters[slot - 1].extra.text);
} else {
strcpy(reply, "Err - field is sender/n or text/x");
}
return;
}
uint8_t slot;
if (!parseFloodFilterUnsigned(selector, flood_packet_filter_slots, slot) || slot == 0) {
snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots);
@@ -7987,7 +8056,7 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac
snprintf(priority, sizeof(priority), "^%u",
(unsigned int)entry.priority);
}
snprintf(item, sizeof(item), " %d=%s~%s@%s%s%s%s%s%s%s%s%s%s",
snprintf(item, sizeof(item), " %d=%s~%s@%s%s%s%s%s%s%s%s%s%s%s",
i + 1, floodFilterPayloadTypeName(entry.payload_type),
FloodFilterPolicy::ruleModeName(entry.transport_modes), hops,
entry.match_blacklisted_path ? "?blacklist" : "",
@@ -7997,7 +8066,8 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac
entry.retry_on_match ? "~retry" : "",
entry.scope_uses_slow_timing ? "~slow" : "",
entry.suspend_on_temp_radio ? "~tempradio" : "",
i == findFloodChannelDataRule() ? "~data" : "");
i == findFloodChannelDataRule() ? "~data" : "",
entry.extra.disabled ? "~off" : "");
size_t item_len = strlen(item);
if (used + item_len >= 156) {
truncated = true;
@@ -8041,6 +8111,32 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
}
}
cursor = skipFloodFilterSpaces(cursor);
if (requested_slot >= 0) {
const char* state = cursor;
if (!strncmp(state, "enabled=", 8)) state += 8;
else if (!strncmp(state, "e=", 2)) state += 2;
if (state != cursor || !strcmp(state, "on") || !strcmp(state, "off")) {
uint8_t disabled;
if (!FloodRuleExtensions::enabledValue(state, disabled)) {
strcpy(reply, "Err - enabled is on or off"); return;
}
auto& entry = flood_packet_filters[requested_slot];
if (!entry.active) { strcpy(reply, "Err - empty filter slot"); return; }
const uint8_t previous = entry.extra.disabled;
const uint8_t previous_channel_data_slot = flood_channel_data_rule_slot;
entry.extra.disabled = disabled;
// Editing the compatibility row through fr gives it the same ordinary
// rule ownership as replacing that row through the full setter.
if (previous != disabled && requested_slot == flood_channel_data_rule_slot)
flood_channel_data_rule_slot = 0xFF;
if (previous != disabled && !saveFloodPacketFilters()) {
entry.extra.disabled = previous;
flood_channel_data_rule_slot = previous_channel_data_slot;
strcpy(reply, "Err - filter save unconfirmed; reboot may differ"); return;
}
strcpy(reply, disabled ? "OK - rule disabled" : "OK - rule enabled"); return;
}
}
char params[256];
if (strlen(cursor) >= sizeof(params)) {
strcpy(reply, "Err - rule parameters too long");
@@ -8048,22 +8144,9 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
}
strcpy(params, cursor);
char* tokens[18];
int token_count = 0;
char* token = params;
while (*token != 0) {
if (token_count >= 18) {
strcpy(reply, "Err - too many rule parameters");
return;
}
tokens[token_count++] = token;
char* separator = strchr(token, ' ');
if (separator == NULL) break;
*separator++ = 0;
while (*separator == ' ') separator++;
token = separator;
}
if (token_count == 0) {
char* tokens[26];
const int token_count = FloodRuleExtensions::tokenize(params, tokens, 26);
if (token_count <= 0) {
strcpy(reply, FLOOD_PACKET_FILTER_USAGE);
return;
}
@@ -8117,7 +8200,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
bool retry_on_match = false;
uint8_t transport_modes = FloodFilterPolicy::RULE_MODE_RADIO;
bool mode_set = false;
FloodRuleExtensions::Fields extra{};
uint8_t extra_seen = 0;
for (int i = 1; i < token_count; i++) {
const int extended = FloodRuleExtensions::parseToken(tokens[i], extra, extra_seen, reply);
if (extended < 0) return;
if (extended > 0) continue;
if (floodFilterAsciiStartsWith(tokens[i], "mode=")
|| floodFilterAsciiStartsWith(tokens[i], "m=")) {
if (mode_set) {
@@ -8312,6 +8400,15 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
StrHelper::strzcpy(target_region_name, region->name,
sizeof(target_region_name));
target_set = true;
} else if (floodFilterAsciiEqual(tokens[i], "allow")
|| floodFilterAsciiEqual(tokens[i], "forward")
|| floodFilterAsciiEqual(tokens[i], "a")
|| floodFilterAsciiEqual(tokens[i], "action=allow")
|| floodFilterAsciiEqual(tokens[i], "action=forward")) {
if (stop_on_match) {
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); return;
}
stop_on_match = true;
} else if (floodFilterAsciiEqual(tokens[i], "drop")
|| floodFilterAsciiEqual(tokens[i], "d")
|| floodFilterAsciiEqual(tokens[i], "action=drop")) {
@@ -8398,6 +8495,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
hashbytes_set = true;
} else if (floodFilterAsciiStartsWith(tokens[i], "prefix=")
|| floodFilterAsciiStartsWith(tokens[i], "p=")
|| floodFilterAsciiStartsWith(tokens[i], "pa=")
|| (floodFilterAsciiStartsWith(tokens[i], "path=")
&& !floodFilterAsciiEqual(tokens[i], "path=blacklist"))) {
if (path_set) {
@@ -8405,12 +8503,11 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
return;
}
const char* value = strchr(tokens[i], '=') + 1;
static_assert(FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX
== FLOOD_GROUP_MODERATION_PATH_BYTES_MAX,
"rule and moderation path buffers must agree");
if (!parseFloodModerationPath(value, path_hash_size, path_hops,
path)) {
strcpy(reply, "Err - prefix is * or 1-3 comma-separated 1/2/3-byte IDs");
const bool legacy_prefix = floodFilterAsciiStartsWith(tokens[i], "prefix=")
|| floodFilterAsciiStartsWith(tokens[i], "p=");
if (!FloodRuleExtensions::parsePath(value, path_hash_size, path_hops,
path, extra, legacy_prefix)) {
strcpy(reply, "Err - path is [^]ID>...[$], up to four 1/2/3-byte IDs");
return;
}
path_set = true;
@@ -8503,8 +8600,13 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
candidate.stop_on_match = stop_on_match;
candidate.retry_on_match = retry_on_match;
candidate.transport_modes = transport_modes;
candidate.extra = extra;
StrHelper::strzcpy(candidate.scope_name, scope_name,
sizeof(candidate.scope_name));
if (!FloodRuleExtensions::validRuleFields(candidate)) {
strcpy(reply, "Err - content needs a keyed group-text channel; scope/retry needs flood route");
return;
}
int slot = requested_slot;
if (slot < 0) {
@@ -8553,7 +8655,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
snprintf(reply, 160, "OK - rule %d saved (details exceed reply size)", slot + 1);
return;
}
snprintf(reply, 160, "OK - %s", detail[0] == '>' ? skipFloodFilterSpaces(detail + 1) : detail);
snprintf(reply, 160, "OK - %.154s", detail[0] == '>' ? skipFloodFilterSpaces(detail + 1) : detail);
}
#else
void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len, bool compact) const {
@@ -9517,28 +9619,34 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
while ((option_result =
takeFloodModerationToken(cursor, option, sizeof(option))) == 1) {
if (floodFilterAsciiEqual(option, "tx=slow")
|| floodFilterAsciiEqual(option, "tx=fast")) {
|| floodFilterAsciiEqual(option, "tx=fast")
|| floodFilterAsciiEqual(option, "tx=s")
|| floodFilterAsciiEqual(option, "tx=f")) {
if (timing_set) {
strcpy(reply, "Err - duplicate tx timing");
return;
}
timing_set = true;
slow_timing = floodFilterAsciiEqual(option, "tx=slow");
} else if (floodFilterAsciiEqual(option, "path=blacklist")) {
slow_timing = floodFilterAsciiEqual(option, "tx=slow")
|| floodFilterAsciiEqual(option, "tx=s");
} else if (floodFilterAsciiEqual(option, "path=blacklist")
|| floodFilterAsciiEqual(option, "p=bl")) {
if (path_set) {
strcpy(reply, "Err - duplicate path matcher");
return;
}
path_set = true;
path_selector = FloodFilterPolicy::SCOPE_PATH_BLACKLIST;
} else if (strncmp(option, "path=bucket:", 12) == 0) {
} else if (strncmp(option, "path=bucket:", 12) == 0
|| strncmp(option, "p=b", 3) == 0) {
if (path_set) {
strcpy(reply, "Err - duplicate path matcher");
return;
}
uint8_t bucket = 0;
if (!parseFloodFilterUnsigned(
option + 12, FLOOD_RETRY_BRIDGE_BUCKETS, bucket)
option + (option[0] == 'p' && option[1] == '=' ? 3 : 12),
FLOOD_RETRY_BRIDGE_BUCKETS, bucket)
|| bucket == 0) {
strcpy(reply, "Err - path bucket must be 1-6");
return;
@@ -9959,36 +10067,45 @@ static bool floodModerationPathMatches(const mesh::Packet* packet, uint8_t hash_
return memcmp(packet->path, path, path_hops * hash_size) == 0;
}
void MyMesh::loadFloodGroupModeration() {
memset(flood_group_moderation, 0, sizeof(flood_group_moderation));
if (_fs == NULL || !_fs->exists(FLOOD_GROUP_MODERATION_FILE)) return;
File file = openFloodSettingsRead(_fs, FLOOD_GROUP_MODERATION_FILE);
if (!file) return;
FloodGroupModerationEntry loaded[FLOOD_GROUP_MODERATION_SLOTS];
memset(loaded, 0, sizeof(loaded));
bool MyMesh::readFloodGroupModerationFile(
const char* path, FloodGroupModerationEntry* entries, bool* readable) const {
if (readable) *readable = false;
if (_fs == NULL) return false;
if (!_fs->exists(path)) {
if (readable) *readable = true;
return false;
}
File file = openFloodSettingsRead(_fs, path);
if (!file) return false;
if (entries) memset(entries, 0, sizeof(FloodGroupModerationEntry) * FLOOD_GROUP_MODERATION_SLOTS);
uint8_t magic[4];
uint8_t count = 0;
bool success = file.read(magic, sizeof(magic)) == sizeof(magic)
bool complete = file.size() < 5
|| (file.read(magic, sizeof(magic)) == sizeof(magic)
&& file.read(&count, sizeof(count)) == sizeof(count));
bool success = file.size() >= 5 && complete
&& memcmp(magic, "FGM1", sizeof(magic)) == 0
&& file.read(&count, sizeof(count)) == sizeof(count)
&& count <= FLOOD_GROUP_MODERATION_SLOTS;
&& count <= FLOOD_GROUP_MODERATION_SLOTS
&& file.size() == 5U + count * (2U + PUB_KEY_SIZE
+ 2U * FLOOD_GROUP_MODERATION_NAME_LEN + 3U
+ FLOOD_GROUP_MODERATION_PATH_BYTES_MAX + sizeof(uint16_t));
FloodGroupModerationEntry scratch;
for (int i = 0; success && i < count; i++) {
auto& entry = loaded[i];
auto& entry = entries ? entries[i] : scratch;
memset(&entry, 0, sizeof(entry));
uint8_t active = 0;
success = file.read(&active, sizeof(active)) == sizeof(active);
success = success && file.read(&entry.key_len, sizeof(entry.key_len)) == sizeof(entry.key_len);
success = success && file.read(entry.secret, sizeof(entry.secret)) == sizeof(entry.secret);
success = success && file.read((uint8_t*)entry.channel_name, sizeof(entry.channel_name)) == sizeof(entry.channel_name);
success = success && file.read((uint8_t*)entry.sender, sizeof(entry.sender)) == sizeof(entry.sender);
success = success && file.read(&entry.path_hash_size, sizeof(entry.path_hash_size)) == sizeof(entry.path_hash_size);
success = success && file.read(&entry.path_hops, sizeof(entry.path_hops)) == sizeof(entry.path_hops);
success = success && file.read(entry.path, sizeof(entry.path)) == sizeof(entry.path);
success = success && file.read(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops);
success = success && file.read((uint8_t*)&entry.rate_per_minute, sizeof(entry.rate_per_minute)) == sizeof(entry.rate_per_minute);
entry.channel_name[sizeof(entry.channel_name) - 1] = 0;
entry.sender[sizeof(entry.sender) - 1] = 0;
complete = file.read(&active, sizeof(active)) == sizeof(active)
&& file.read(&entry.key_len, sizeof(entry.key_len)) == sizeof(entry.key_len)
&& file.read(entry.secret, sizeof(entry.secret)) == sizeof(entry.secret)
&& file.read((uint8_t*)entry.channel_name, sizeof(entry.channel_name)) == sizeof(entry.channel_name)
&& file.read((uint8_t*)entry.sender, sizeof(entry.sender)) == sizeof(entry.sender)
&& file.read(&entry.path_hash_size, sizeof(entry.path_hash_size)) == sizeof(entry.path_hash_size)
&& file.read(&entry.path_hops, sizeof(entry.path_hops)) == sizeof(entry.path_hops)
&& file.read(entry.path, sizeof(entry.path)) == sizeof(entry.path)
&& file.read(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops)
&& file.read((uint8_t*)&entry.rate_per_minute, sizeof(entry.rate_per_minute)) == sizeof(entry.rate_per_minute);
entry.active = active != 0;
success = complete && active <= 1;
if (success && entry.active) {
bool path_valid = (entry.path_hops == 0 && entry.path_hash_size == 0)
|| (entry.path_hops >= 1 && entry.path_hops <= FLOOD_GROUP_MODERATION_PATH_HOPS_MAX
@@ -9996,6 +10113,8 @@ void MyMesh::loadFloodGroupModeration() {
bool hops_valid = entry.max_hops == FLOOD_GROUP_MODERATION_HOPS_ALL
|| entry.max_hops <= FLOOD_PACKET_FILTER_MAX_HOPS;
success = (entry.key_len == CIPHER_KEY_SIZE || entry.key_len == PUB_KEY_SIZE)
&& memchr(entry.channel_name, 0, sizeof(entry.channel_name)) != NULL
&& memchr(entry.sender, 0, sizeof(entry.sender)) != NULL
&& entry.channel_name[0] != 0 && floodModerationSenderNameValid(entry.sender)
&& path_valid && hops_valid;
if (success) {
@@ -10004,34 +10123,114 @@ void MyMesh::loadFloodGroupModeration() {
}
}
file.close();
// As with the general table, malformed persistence fails open.
if (success) memcpy(flood_group_moderation, loaded, sizeof(flood_group_moderation));
if (readable) *readable = complete;
return success;
}
bool MyMesh::recoverFloodGroupModeration() {
if (_fs == NULL) return false;
bool readable = false;
if (readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_FILE, NULL, &readable)) {
// A complete public image wins over uncommitted scratch and an old backup.
if (_fs->exists(FLOOD_GROUP_MODERATION_TEMP_FILE)
&& !_fs->remove(FLOOD_GROUP_MODERATION_TEMP_FILE)) return false;
if (_fs->exists(FLOOD_GROUP_MODERATION_BACKUP_FILE)
&& !_fs->remove(FLOOD_GROUP_MODERATION_BACKUP_FILE)) return false;
return true;
}
if (!readable) return false;
const char* candidate = NULL;
if (readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_BACKUP_FILE, NULL, &readable)) {
candidate = FLOOD_GROUP_MODERATION_BACKUP_FILE;
} else {
if (!readable) return false;
if (readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_TEMP_FILE, NULL, &readable))
candidate = FLOOD_GROUP_MODERATION_TEMP_FILE;
else if (!readable) return false;
}
if (candidate) {
if (_fs->exists(FLOOD_GROUP_MODERATION_FILE)
&& !_fs->remove(FLOOD_GROUP_MODERATION_FILE)) return false;
_fs->rename(candidate, FLOOD_GROUP_MODERATION_FILE);
if (!readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_FILE, NULL)) return false;
}
// Invalid files fail open, but must not prevent an administrator repairing
// the table. Never discard a complete candidate after failed promotion.
if (_fs->exists(FLOOD_GROUP_MODERATION_TEMP_FILE)
&& !_fs->remove(FLOOD_GROUP_MODERATION_TEMP_FILE)) return false;
if (_fs->exists(FLOOD_GROUP_MODERATION_BACKUP_FILE)
&& !_fs->remove(FLOOD_GROUP_MODERATION_BACKUP_FILE)) return false;
if (!candidate && _fs->exists(FLOOD_GROUP_MODERATION_FILE)
&& !_fs->remove(FLOOD_GROUP_MODERATION_FILE)) return false;
return true;
}
void MyMesh::loadFloodGroupModeration() {
memset(flood_group_moderation, 0, sizeof(flood_group_moderation));
if (_fs == NULL) return;
recoverFloodGroupModeration();
// A recovery failure can still leave a valid public or backup image. Use
// it for this boot without replacing the sole durable recovery source.
if (!readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_FILE, flood_group_moderation)
&& !readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_BACKUP_FILE, flood_group_moderation)) {
memset(flood_group_moderation, 0, sizeof(flood_group_moderation));
}
}
bool MyMesh::saveFloodGroupModeration() {
if (_fs == NULL) return false;
File file = openFloodSettingsWrite(_fs, FLOOD_GROUP_MODERATION_FILE);
if (!recoverFloodGroupModeration()) return false;
File file = openFloodSettingsWrite(_fs, FLOOD_GROUP_MODERATION_TEMP_FILE);
if (!file) return false;
size_t size = 0;
uint32_t hash = 2166136261UL;
auto writeExact = [&file, &size, &hash](const void* data, size_t length) {
if (file.write((const uint8_t*)data, length) != length) return false;
hash = updateFloodSettingsHash(hash, (const uint8_t*)data, length);
size += length;
return true;
};
const uint8_t magic[4] = {'F', 'G', 'M', '1'};
uint8_t count = FLOOD_GROUP_MODERATION_SLOTS;
bool success = file.write(magic, sizeof(magic)) == sizeof(magic)
&& file.write(&count, sizeof(count)) == sizeof(count);
bool success = writeExact(magic, sizeof(magic)) && writeExact(&count, sizeof(count));
for (int i = 0; success && i < FLOOD_GROUP_MODERATION_SLOTS; i++) {
const auto& entry = flood_group_moderation[i];
uint8_t active = entry.active ? 1 : 0;
success = file.write(&active, sizeof(active)) == sizeof(active);
success = success && file.write(&entry.key_len, sizeof(entry.key_len)) == sizeof(entry.key_len);
success = success && file.write(entry.secret, sizeof(entry.secret)) == sizeof(entry.secret);
success = success && file.write((const uint8_t*)entry.channel_name, sizeof(entry.channel_name)) == sizeof(entry.channel_name);
success = success && file.write((const uint8_t*)entry.sender, sizeof(entry.sender)) == sizeof(entry.sender);
success = success && file.write(&entry.path_hash_size, sizeof(entry.path_hash_size)) == sizeof(entry.path_hash_size);
success = success && file.write(&entry.path_hops, sizeof(entry.path_hops)) == sizeof(entry.path_hops);
success = success && file.write(entry.path, sizeof(entry.path)) == sizeof(entry.path);
success = success && file.write(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops);
success = success && file.write((const uint8_t*)&entry.rate_per_minute, sizeof(entry.rate_per_minute)) == sizeof(entry.rate_per_minute);
success = writeExact(&active, sizeof(active))
&& writeExact(&entry.key_len, sizeof(entry.key_len))
&& writeExact(entry.secret, sizeof(entry.secret))
&& writeExact(entry.channel_name, sizeof(entry.channel_name))
&& writeExact(entry.sender, sizeof(entry.sender))
&& writeExact(&entry.path_hash_size, sizeof(entry.path_hash_size))
&& writeExact(&entry.path_hops, sizeof(entry.path_hops))
&& writeExact(entry.path, sizeof(entry.path))
&& writeExact(&entry.max_hops, sizeof(entry.max_hops))
&& writeExact(&entry.rate_per_minute, sizeof(entry.rate_per_minute));
}
file.flush();
file.close();
return success;
if (!success || !verifyFloodSettingsWrite(_fs, FLOOD_GROUP_MODERATION_TEMP_FILE, size, hash)
|| !readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_TEMP_FILE, NULL)) {
_fs->remove(FLOOD_GROUP_MODERATION_TEMP_FILE);
return false;
}
if (_fs->exists(FLOOD_GROUP_MODERATION_FILE)) {
_fs->rename(FLOOD_GROUP_MODERATION_FILE, FLOOD_GROUP_MODERATION_BACKUP_FILE);
if (_fs->exists(FLOOD_GROUP_MODERATION_FILE)
|| !readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_BACKUP_FILE, NULL)) {
_fs->remove(FLOOD_GROUP_MODERATION_TEMP_FILE);
return false;
}
}
_fs->rename(FLOOD_GROUP_MODERATION_TEMP_FILE, FLOOD_GROUP_MODERATION_FILE);
if (!verifyFloodSettingsWrite(_fs, FLOOD_GROUP_MODERATION_FILE, size, hash)
|| !readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_FILE, NULL)) {
// Preserve both candidates when publication cannot be confirmed. Recovery
// selects the complete public image, otherwise the previous backup.
return false;
}
if (_fs->exists(FLOOD_GROUP_MODERATION_BACKUP_FILE))
_fs->remove(FLOOD_GROUP_MODERATION_BACKUP_FILE);
return true;
}
#endif
@@ -10039,35 +10238,36 @@ bool MyMesh::saveFloodGroupModeration() {
bool MyMesh::decodeFloodGroupPlainText(const mesh::Packet* packet, const uint8_t* secret, uint8_t key_len,
uint32_t& timestamp, char* sender, size_t sender_len) const {
if (sender != NULL && sender_len > 0) sender[0] = 0;
if (packet == NULL || secret == NULL || sender == NULL || sender_len < 2
if (sender == NULL || sender_len < 2) return false;
uint8_t data[MAX_PACKET_PAYLOAD];
mesh::FloodGroupPlainText::View view;
if (!decodeFloodGroupPlainTextSpan(packet, secret, key_len, data, sizeof(data), view)
|| !view.has_sender || view.sender.size >= sender_len) return false;
timestamp = view.timestamp;
memcpy(sender, view.sender.data, view.sender.size);
sender[view.sender.size] = 0;
return true;
}
bool MyMesh::decodeFloodGroupPlainTextSpan(
const mesh::Packet* packet, const uint8_t* secret, uint8_t key_len,
uint8_t* buffer, size_t capacity, mesh::FloodGroupPlainText::View& view) const {
view = mesh::FloodGroupPlainText::View{};
if (packet == NULL || secret == NULL || buffer == NULL
|| !packet->isRouteFlood() || packet->getPayloadType() != PAYLOAD_TYPE_GRP_TXT
|| (key_len != CIPHER_KEY_SIZE && key_len != PUB_KEY_SIZE)
|| packet->payload_len <= PATH_HASH_SIZE + CIPHER_MAC_SIZE) return false;
|| packet->payload_len < PATH_HASH_SIZE + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE
|| ((packet->payload_len - PATH_HASH_SIZE - CIPHER_MAC_SIZE) % CIPHER_BLOCK_SIZE) != 0
|| capacity < size_t(packet->payload_len - PATH_HASH_SIZE - CIPHER_MAC_SIZE)) return false;
uint8_t channel_hash = 0;
mesh::Utils::sha256(&channel_hash, sizeof(channel_hash), secret, key_len);
if (packet->payload[0] != channel_hash) return false;
uint8_t data[MAX_PACKET_PAYLOAD];
int len = mesh::Utils::MACThenDecrypt(secret, data, &packet->payload[PATH_HASH_SIZE],
int len = mesh::Utils::MACThenDecrypt(secret, buffer, &packet->payload[PATH_HASH_SIZE],
packet->payload_len - PATH_HASH_SIZE);
if (len <= 5 || (data[4] >> 2) != TXT_TYPE_PLAIN) return false;
memcpy(&timestamp, data, sizeof(timestamp));
const uint8_t* text = &data[5];
size_t text_len = (size_t)len - 5;
const uint8_t* colon = (const uint8_t*)memchr(text, ':', text_len);
if (colon == NULL) return false;
size_t parsed_len = (size_t)(colon - text);
while (parsed_len > 0 && text[parsed_len - 1] == ' ') parsed_len--;
if (parsed_len == 0 || parsed_len >= sender_len) return false;
for (size_t i = 0; i < parsed_len; i++) {
if (text[i] == '\r' || text[i] == '\n' || text[i] < 0x20) return false;
}
memcpy(sender, text, parsed_len);
sender[parsed_len] = 0;
return true;
return len > 0 && size_t(len) <= capacity
&& mesh::FloodGroupPlainText::parse(buffer, size_t(len), TXT_TYPE_PLAIN, view);
}
#if MESH_ENABLE_FLOOD_GROUP_MODERATION
@@ -10080,8 +10280,8 @@ bool MyMesh::shouldBlockFloodGroupTextForward(const mesh::Packet* packet) {
uint8_t cached_key_len = 0;
bool cache_present = false;
bool cache_valid = false;
char decoded_sender[FLOOD_GROUP_MODERATION_NAME_LEN];
decoded_sender[0] = 0;
uint8_t decrypted[MAX_PACKET_PAYLOAD];
mesh::FloodGroupPlainText::View decoded;
uint8_t hops = packet->getPathHashCount();
uint8_t rate_slots[FLOOD_GROUP_MODERATION_SLOTS];
uint8_t rate_slot_count = 0;
@@ -10099,15 +10299,12 @@ bool MyMesh::shouldBlockFloodGroupTextForward(const mesh::Packet* packet) {
cached_key_len = entry.key_len;
memcpy(cached_secret, entry.secret, entry.key_len);
cache_valid = false;
decoded_sender[0] = 0;
uint32_t ignored_timestamp = 0;
cache_valid = decodeFloodGroupPlainText(packet, entry.secret, entry.key_len,
ignored_timestamp, decoded_sender,
sizeof(decoded_sender));
cache_valid = decodeFloodGroupPlainTextSpan(packet, entry.secret, entry.key_len,
decrypted, sizeof(decrypted), decoded);
}
if (!cache_valid
|| (strcmp(entry.sender, "*") != 0 && !floodFilterAsciiEqual(entry.sender, decoded_sender))) continue;
if (!cache_valid || !decoded.has_sender
|| (strcmp(entry.sender, "*") != 0
&& !mesh::FloodGroupPlainText::senderEquals(decoded, entry.sender))) continue;
bool rule_blocked = entry.max_hops != FLOOD_GROUP_MODERATION_HOPS_ALL && hops >= entry.max_hops;
if (entry.rate_per_minute != FLOOD_GROUP_MODERATION_RATE_UNLIMITED) {
@@ -10119,8 +10316,9 @@ bool MyMesh::shouldBlockFloodGroupTextForward(const mesh::Packet* packet) {
}
}
if (rule_blocked) {
MESH_DEBUG_PRINTLN("allowPacketForward: flood.moderation slot=%d channel=%s sender=%s hops=%d rate=%d",
i + 1, entry.channel_name, decoded_sender, hops, entry.rate_per_minute);
MESH_DEBUG_PRINTLN("allowPacketForward: flood.moderation slot=%d channel=%s sender=%.*s hops=%d rate=%d",
i + 1, entry.channel_name, int(decoded.sender.size),
(const char*)decoded.sender.data, hops, entry.rate_per_minute);
return true;
}
if (entry.rate_per_minute != FLOOD_GROUP_MODERATION_RATE_UNLIMITED) {
@@ -10253,7 +10451,7 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) {
bool rate_set = false;
char option[48];
while ((token_result = takeFloodModerationToken(cursor, option, sizeof(option))) == 1) {
if (strcmp(option, "drop") == 0) {
if (strcmp(option, "drop") == 0 || strcmp(option, "d") == 0) {
if (rate_set) {
strcpy(reply, "Err - use only one rate/drop option");
return;
@@ -10261,23 +10459,26 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) {
rate_per_minute = 0;
rate_set = true;
action_set = true;
} else if (strncmp(option, "rate=", 5) == 0) {
} else if (strncmp(option, "rate=", 5) == 0 || strncmp(option, "q=", 2) == 0) {
if (rate_set) {
strcpy(reply, "Err - use only one rate/drop option");
return;
}
char rate_text[24];
if (strlen(option + 5) >= sizeof(rate_text)) {
const bool compact_rate = option[0] == 'q';
const char* value = option + (compact_rate ? 2 : 5);
if (strlen(value) >= sizeof(rate_text)) {
strcpy(reply, "Err - rate value too long");
return;
}
StrHelper::strncpy(rate_text, option + 5, sizeof(rate_text));
StrHelper::strncpy(rate_text, value, sizeof(rate_text));
char* slash = strchr(rate_text, '/');
if (slash == NULL || !(strcmp(slash, "/min") == 0 || strcmp(slash, "/m") == 0)) {
if ((!slash && !compact_rate)
|| (slash && !(strcmp(slash, "/min") == 0 || strcmp(slash, "/m") == 0))) {
strcpy(reply, "Err - rate format is X/min");
return;
}
*slash = 0;
if (slash) *slash = 0;
uint32_t parsed;
if (!parseFloodModerationUnsigned(rate_text, FLOOD_GROUP_MODERATION_RATE_UNLIMITED - 1, parsed)) {
strcpy(reply, "Err - rate must be 0-65534/min");
@@ -10286,20 +10487,22 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) {
rate_per_minute = (uint16_t)parsed;
rate_set = true;
action_set = true;
} else if (strncmp(option, "hops=", 5) == 0) {
if (strcmp(option + 5, "all") == 0) {
} else if (strncmp(option, "hops=", 5) == 0 || strncmp(option, "h=", 2) == 0) {
const char* value = option + (option[1] == '=' ? 2 : 5);
if (strcmp(value, "all") == 0) {
max_hops = FLOOD_GROUP_MODERATION_HOPS_ALL;
} else {
uint32_t parsed;
if (!parseFloodModerationUnsigned(option + 5, FLOOD_PACKET_FILTER_MAX_HOPS, parsed)) {
if (!parseFloodModerationUnsigned(value, FLOOD_PACKET_FILTER_MAX_HOPS, parsed)) {
strcpy(reply, "Err - hops must be all or 0-63");
return;
}
max_hops = (uint8_t)parsed;
action_set = true;
}
} else if (strncmp(option, "path=", 5) == 0) {
if (!parseFloodModerationPath(option + 5, path_hash_size, path_hops, path)) {
} else if (strncmp(option, "path=", 5) == 0 || strncmp(option, "p=", 2) == 0) {
if (!parseFloodModerationPath(option + (option[1] == '=' ? 2 : 5),
path_hash_size, path_hops, path)) {
strcpy(reply, "Err - path is * or 1-3 comma-separated 1/2/3-byte hashes");
return;
}
@@ -10373,7 +10576,7 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) {
entry.rate_per_minute = rate_per_minute;
if (!saveFloodGroupModeration()) {
entry = previous;
strcpy(reply, "Err - unable to save moderation rule");
strcpy(reply, "Err - moderation save unconfirmed; reboot may differ");
return;
}
char detail[160];
@@ -10390,7 +10593,7 @@ void MyMesh::deleteFloodGroupModeration(const char* args, char* reply) {
memset(flood_group_moderation, 0, sizeof(flood_group_moderation));
if (!saveFloodGroupModeration()) {
memcpy(flood_group_moderation, previous, sizeof(flood_group_moderation));
strcpy(reply, "Err - unable to save moderation rules");
strcpy(reply, "Err - moderation save unconfirmed; reboot may differ");
} else {
strcpy(reply, "OK - all moderation rules removed");
}
@@ -10410,7 +10613,7 @@ void MyMesh::deleteFloodGroupModeration(const char* args, char* reply) {
memset(&flood_group_moderation[index], 0, sizeof(flood_group_moderation[index]));
if (!saveFloodGroupModeration()) {
flood_group_moderation[index] = previous;
strcpy(reply, "Err - unable to save moderation rules");
strcpy(reply, "Err - moderation save unconfirmed; reboot may differ");
} else {
strcpy(reply, "OK");
}
@@ -11716,9 +11919,7 @@ bool MyMesh::handleClientPathCommand(ClientInfo* sender, char* command, char* re
}
static bool commandFamilyMatches(const char* command, const char* family) {
size_t len = strlen(family);
if (strncmp(command, family, len) != 0) return false;
return command[len] == 0 || command[len] == ' ' || command[len] == '.';
return mesh::cli::floodCommandArguments(command, family) != nullptr;
}
static bool isCommonManagerReadOnlyAllowed(const char* cmd) {
@@ -11749,6 +11950,9 @@ static bool isRegionMgrAllowed(const char* cmd) {
|| commandFamilyMatches(cmd, "get flood.channel.scope")
|| commandFamilyMatches(cmd, "set flood.channel.scope")
|| commandFamilyMatches(cmd, "del flood.channel.scope")
|| commandFamilyMatches(cmd, "get flood.channel.scope.require")
|| commandFamilyMatches(cmd, "set flood.channel.scope.require")
|| commandFamilyMatches(cmd, "del flood.channel.scope.require")
|| isCommonManagerReadOnlyAllowed(cmd);
}
@@ -11759,7 +11963,9 @@ static bool isFilterMgrAllowed(const char* cmd) {
|| commandFamilyMatches(cmd, "get loop.detect")
|| commandFamilyMatches(cmd, "get flood.max")
|| commandFamilyMatches(cmd, "get flood.channel.data")
|| commandFamilyMatches(cmd, "get flood.advert")
|| commandFamilyMatches(cmd, "get flood.filter")
|| commandFamilyMatches(cmd, "get flood.filter.blacklist")
#if MESH_ENABLE_FLOOD_RULE_ENGINE
|| commandFamilyMatches(cmd, "get flood.rule")
|| commandFamilyMatches(cmd, "get fr")
@@ -11768,6 +11974,8 @@ static bool isFilterMgrAllowed(const char* cmd) {
// General payload/hop filters plus the remaining flood-hop gates.
return commandFamilyMatches(cmd, "set flood.filter")
|| commandFamilyMatches(cmd, "del flood.filter")
|| commandFamilyMatches(cmd, "set flood.filter.blacklist")
|| commandFamilyMatches(cmd, "del flood.filter.blacklist")
#if MESH_ENABLE_FLOOD_RULE_ENGINE
|| commandFamilyMatches(cmd, "set flood.rule")
|| commandFamilyMatches(cmd, "set fr")
@@ -11776,6 +11984,7 @@ static bool isFilterMgrAllowed(const char* cmd) {
#endif
|| commandFamilyMatches(cmd, "set flood.moderation")
|| commandFamilyMatches(cmd, "del flood.moderation")
|| commandFamilyMatches(cmd, "clear flood.advert")
|| commandFamilyMatches(cmd, "set flood.channel.data")
|| commandFamilyMatches(cmd, "set flood.max")
|| commandFamilyMatches(cmd, "set loop.detect")
@@ -12419,28 +12628,28 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
#endif
if (commandFamilyMatches(command, "get flood.channel.scope.require")) {
formatFloodChannelScopeRequirements(
command + strlen("get flood.channel.scope.require"), reply);
mesh::cli::floodCommandArguments(command, "get flood.channel.scope.require"), reply);
} else if (commandFamilyMatches(command, "set flood.channel.scope.require")) {
setFloodChannelScopeRequirement(
command + strlen("set flood.channel.scope.require"), reply);
mesh::cli::floodCommandArguments(command, "set flood.channel.scope.require"), reply);
} else if (commandFamilyMatches(command, "del flood.channel.scope.require")) {
deleteFloodChannelScopeRequirement(
command + strlen("del flood.channel.scope.require"), reply);
mesh::cli::floodCommandArguments(command, "del flood.channel.scope.require"), reply);
} else if (commandFamilyMatches(command, "get flood.channel.scope")) {
formatFloodChannelScopes(command + strlen("get flood.channel.scope"), reply);
formatFloodChannelScopes(mesh::cli::floodCommandArguments(command, "get flood.channel.scope"), reply);
} else if (commandFamilyMatches(command, "set flood.channel.scope")) {
setFloodChannelScope(command + strlen("set flood.channel.scope"), reply);
setFloodChannelScope(mesh::cli::floodCommandArguments(command, "set flood.channel.scope"), reply);
} else if (commandFamilyMatches(command, "del flood.channel.scope")) {
deleteFloodChannelScope(command + strlen("del flood.channel.scope"), reply);
deleteFloodChannelScope(mesh::cli::floodCommandArguments(command, "del flood.channel.scope"), reply);
} else if (commandFamilyMatches(command, "get flood.filter.blacklist")) {
formatFloodPacketFilterBlacklist(
command + strlen("get flood.filter.blacklist"), reply);
mesh::cli::floodCommandArguments(command, "get flood.filter.blacklist"), reply);
} else if (commandFamilyMatches(command, "set flood.filter.blacklist")) {
setFloodPacketFilterBlacklist(
command + strlen("set flood.filter.blacklist"), reply);
mesh::cli::floodCommandArguments(command, "set flood.filter.blacklist"), reply);
} else if (commandFamilyMatches(command, "del flood.filter.blacklist")) {
deleteFloodPacketFilterBlacklist(
command + strlen("del flood.filter.blacklist"), reply);
mesh::cli::floodCommandArguments(command, "del flood.filter.blacklist"), reply);
} else if (commandFamilyMatches(command, "get flood.filter")) {
formatFloodPacketFilters(command + strlen("get flood.filter"), reply);
} else if (commandFamilyMatches(command, "set flood.filter")) {
@@ -12455,20 +12664,20 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
} else if (commandFamilyMatches(command, "del fr")) {
deleteFloodPacketFilter(command + strlen("del fr"), reply);
} else if (commandFamilyMatches(command, "get flood.rule")) {
formatFloodPacketFilters(command + strlen("get flood.rule"), reply);
formatFloodPacketFilters(mesh::cli::floodCommandArguments(command, "get flood.rule"), reply);
} else if (commandFamilyMatches(command, "set flood.rule")) {
setFloodPacketFilter(command + strlen("set flood.rule"), reply, true);
setFloodPacketFilter(mesh::cli::floodCommandArguments(command, "set flood.rule"), reply, true);
} else if (commandFamilyMatches(command, "del flood.rule")) {
deleteFloodPacketFilter(command + strlen("del flood.rule"), reply);
deleteFloodPacketFilter(mesh::cli::floodCommandArguments(command, "del flood.rule"), reply);
#endif
}
#if MESH_ENABLE_FLOOD_GROUP_MODERATION
else if (commandFamilyMatches(command, "get flood.moderation")) {
formatFloodGroupModeration(command + strlen("get flood.moderation"), reply);
formatFloodGroupModeration(mesh::cli::floodCommandArguments(command, "get flood.moderation"), reply);
} else if (commandFamilyMatches(command, "set flood.moderation")) {
setFloodGroupModeration(command + strlen("set flood.moderation"), reply);
setFloodGroupModeration(mesh::cli::floodCommandArguments(command, "set flood.moderation"), reply);
} else if (commandFamilyMatches(command, "del flood.moderation")) {
deleteFloodGroupModeration(command + strlen("del flood.moderation"), reply);
deleteFloodGroupModeration(mesh::cli::floodCommandArguments(command, "del flood.moderation"), reply);
}
#endif
#if MESH_ENABLE_CLOCK_SYNC
+13 -1
View File
@@ -5,6 +5,8 @@
#include <Mesh.h>
#include <helpers/PacketFileLoggingConfig.h>
#include <helpers/PowerSavingDiagnostics.h>
#include <helpers/FloodGroupPlainText.h>
#include <helpers/FloodRuleExtensions.h>
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#endif
@@ -443,6 +445,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
char target_region_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
uint8_t priority;
uint8_t transport_modes;
FloodRuleExtensions::Fields extra;
#else
bool active;
uint8_t payload_type;
@@ -496,7 +499,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
};
mutable FloodRetryBridgeState flood_retry_bridge_states[TOTAL_FLOOD_RETRY_SLOTS];
FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1];
static_assert(sizeof(FloodPacketFilterEntry) <= (MESH_ENABLE_FLOOD_RULE_ENGINE ? 192 : 40),
static_assert(sizeof(FloodPacketFilterEntry) <= (MESH_ENABLE_FLOOD_RULE_ENGINE ? 336 : 40),
"Update the flood-table runtime RAM budget in check_firmware_ram.py");
// The rule table is the single largest member of this object. It is heap
// allocated in the constructor (before setup(), while the heap is still
@@ -506,6 +509,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
// zero-capacity node simply forwards without rule filtering.
FloodPacketFilterEntry* flood_packet_filters;
uint8_t flood_packet_filter_slots;
#if MESH_ENABLE_FLOOD_RULE_ENGINE
uint32_t flood_filter_probability_seed;
#endif
uint8_t flood_packet_filter_blacklist_count;
uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX]
[FLOOD_PACKET_FILTER_PATH_ID_SIZE];
@@ -917,6 +923,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
void deleteFloodChannelScopeRequirement(const char* args, char* reply);
void loadFloodGroupModeration();
bool saveFloodGroupModeration();
bool readFloodGroupModerationFile(const char* path, FloodGroupModerationEntry* entries,
bool* readable = nullptr) const;
bool recoverFloodGroupModeration();
bool shouldBlockFloodGroupTextForward(const mesh::Packet* packet);
void formatFloodGroupModeration(const char* args, char* reply) const;
void formatFloodGroupModerationDetail(int index, char* reply, size_t reply_len) const;
@@ -924,6 +933,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
void deleteFloodGroupModeration(const char* args, char* reply);
bool decodeFloodGroupPlainText(const mesh::Packet* packet, const uint8_t* secret, uint8_t key_len,
uint32_t& timestamp, char* sender, size_t sender_len) const;
bool decodeFloodGroupPlainTextSpan(const mesh::Packet* packet, const uint8_t* secret,
uint8_t key_len, uint8_t* buffer, size_t capacity,
mesh::FloodGroupPlainText::View& view) const;
void loadClockSyncPrefs();
bool saveClockSyncPrefs();
void resetClockSyncAttempt();
+129 -85
View File
@@ -8,6 +8,7 @@
#include <helpers/FloodFilterPolicy.h>
#include <helpers/FloodRuleCLI.h>
#include <helpers/FloodCommandAliases.h>
#include <helpers/FileRead.h>
#include <helpers/RegionNameUtils.h>
@@ -282,48 +283,6 @@ static bool parseChannel(const char* text, uint8_t secret[PUB_KEY_SIZE],
return true;
}
static bool parsePathPrefix(const char* text, uint8_t& hash_size,
uint8_t& path_hops,
uint8_t path[FloodRuleEngine::PATH_PREFIX_BYTES_MAX]) {
memset(path, 0, FloodRuleEngine::PATH_PREFIX_BYTES_MAX);
if (text == NULL || *text == 0 || strcmp(text, "*") == 0) {
hash_size = 0;
path_hops = 0;
return text != NULL && *text != 0;
}
if (strlen(text) >= 32) return false;
char input[32];
copyString(input, text, sizeof(input));
char* token = input;
uint8_t parsed_size = 0;
uint8_t count = 0;
while (token != NULL) {
char* comma = strchr(token, ',');
if (comma != NULL) *comma = 0;
size_t hex_len = strlen(token);
if (hex_len != 2 && hex_len != 4 && hex_len != 6) return false;
uint8_t token_size = (uint8_t)(hex_len / 2);
if ((parsed_size != 0 && parsed_size != token_size)
|| count >= FloodRuleEngine::PATH_PREFIX_HOPS_MAX) {
return false;
}
for (size_t i = 0; i < hex_len; i++) {
if (!mesh::Utils::isHexChar(token[i])) return false;
}
parsed_size = token_size;
if (!mesh::Utils::fromHex(&path[count * parsed_size], parsed_size,
token)) {
return false;
}
count++;
token = comma == NULL ? NULL : comma + 1;
}
if (count == 0) return false;
hash_size = parsed_size;
path_hops = count;
return true;
}
static void formatPathPrefix(
char* dest, size_t dest_len, uint8_t hash_size, uint8_t path_hops,
const uint8_t path[FloodRuleEngine::PATH_PREFIX_BYTES_MAX]) {
@@ -347,9 +306,7 @@ static void formatPathPrefix(
}
static bool commandMatches(const char* command, const char* base) {
size_t len = strlen(base);
return strncmp(command, base, len) == 0
&& (command[len] == 0 || command[len] == '.' || command[len] == ' ');
return mesh::cli::floodCommandArguments(command, base) != nullptr;
}
static File openRead(FILESYSTEM* fs, const char* path) {
@@ -397,16 +354,17 @@ static bool verifyWrittenFile(FILESYSTEM* fs, const char* path,
FloodRuleEngine::FloodRuleEngine()
: _fs(NULL), _regions(NULL),
_entries(new (std::nothrow) Entry[RULE_SLOTS]) {
_entries(new (std::nothrow) Entry[RULE_SLOTS]), _probability_seed(0) {
// MyMesh is constructed before setup(), while the internal heap is
// unfragmented. The runtime RAM gate budgets this exact allocation.
if (_entries == NULL) abort();
memset(_entries, 0, sizeof(Entry) * RULE_SLOTS);
}
void FloodRuleEngine::begin(FILESYSTEM* fs, RegionMap* regions) {
void FloodRuleEngine::begin(FILESYSTEM* fs, RegionMap* regions, mesh::RNG* rng) {
_fs = fs;
_regions = regions;
if (rng) rng->random(reinterpret_cast<uint8_t*>(&_probability_seed), sizeof(_probability_seed));
load();
}
@@ -443,7 +401,8 @@ void FloodRuleEngine::load() {
uint8_t count = 0;
bool success = readExact(magic, sizeof(magic));
bool version_6 = success && memcmp(magic, "FPF6", 4) == 0;
bool version_7 = success && memcmp(magic, "FPF7", 4) == 0;
bool version_8 = success && memcmp(magic, "FPF8", 4) == 0;
bool version_7 = version_8 || (success && memcmp(magic, "FPF7", 4) == 0);
success = (version_6 || version_7)
&& readExact(&count, sizeof(count)) && count <= RULE_SLOTS;
@@ -502,6 +461,9 @@ void FloodRuleEngine::load() {
: FloodFilterPolicy::RULE_IN_ANY;
drop_on_match = loaded[i].scope_name[0] == 0 ? 1 : 0;
}
if (success && version_8) {
success = FloodRuleExtensions::readFields(file, loaded[i].extra);
}
if (success && version_7) {
success = FloodFilterPolicy::decodeStoredRuleChannel(
stored_rule_channel, loaded[i].channel_key_len,
@@ -603,9 +565,12 @@ void FloodRuleEngine::load() {
|| loaded[i].payload_type == PAYLOAD_TYPE_GRP_DATA;
}
bool path_valid = FloodFilterPolicy::pathMatcherValid(
loaded[i].path_hash_size, loaded[i].path_hops,
loaded[i].match_blacklisted_path);
bool path_valid = loaded[i].path_hops != 0
? (loaded[i].path_hash_size >= 1 && loaded[i].path_hash_size <= 3
&& loaded[i].path_hops <= 4 && !loaded[i].match_blacklisted_path)
: FloodFilterPolicy::pathMatcherValid(
loaded[i].path_hash_size, 0, loaded[i].match_blacklisted_path);
bool extra_valid = FloodRuleExtensions::validRuleFields(loaded[i]);
bool action_valid = loaded[i].drop_on_match || direct_target
|| region_target || loaded[i].rate_limit_enabled
|| loaded[i].stop_on_match || loaded[i].retry_on_match;
@@ -630,7 +595,7 @@ void FloodRuleEngine::load() {
|| loaded[i].rate_per_minute < RATE_UNLIMITED)
&& (!loaded[i].scope_uses_slow_timing
|| direct_target || region_target)
&& incoming_valid && channel_valid && path_valid
&& incoming_valid && channel_valid && path_valid && extra_valid
&& action_valid)) {
success = false;
}
@@ -724,7 +689,8 @@ bool FloodRuleEngine::save() {
return true;
};
const uint8_t magic[4] = {'F', 'P', 'F', '7'};
const bool extended = FloodRuleExtensions::tableUsesExtensions(_entries, RULE_SLOTS);
const uint8_t magic[4] = {'F', 'P', 'F', uint8_t(extended ? '8' : '7')};
uint8_t count = RULE_SLOTS;
bool success = writeExact(magic, sizeof(magic))
&& writeExact(&count, sizeof(count));
@@ -775,6 +741,8 @@ bool FloodRuleEngine::save() {
sizeof(entry.target_region_name))
&& writeExact(&entry.priority, sizeof(entry.priority))
&& writeExact(&stop_on_match, sizeof(stop_on_match));
if (success && extended)
success = FloodRuleExtensions::writeFields(writeExact, entry.extra);
}
const uint8_t section_magic[4] = {'F', 'P', 'S', '1'};
const uint8_t no_channel_data_slot = 0xFF;
@@ -813,22 +781,25 @@ bool FloodRuleEngine::fieldsMatch(
uint16_t incoming_transport_code, bool incoming_region_allowed,
const RegionEntry* incoming_region, uint8_t context) const {
if (!entry.active || !FloodFilterPolicy::ruleModeMatches(
entry.transport_modes, context, packet)) return false;
entry.transport_modes, context, packet, entry.extra.route)
|| !FloodRuleExtensions::matchesEnvelope(entry.extra, packet, context)) return false;
if (!FloodFilterPolicy::channelKeyLengthSupported(
entry.channel_key_len)) return false;
if (entry.suspend_on_temp_radio && temp_radio_active) return false;
// FULL room servers do not carry the repeater's separate passive blacklist.
// Legacy blacklist-qualified rows stay inert instead of widening their match.
if (entry.match_blacklisted_path) return false;
if (!FloodFilterPolicy::pathStartsWith(
packet, entry.path_hash_size, entry.path_hops, entry.path)) {
if (!FloodFilterPolicy::pathHashBytesMatch(entry.path_hash_size, packet->getPathHashSize())
|| !FloodRuleExtensions::pathMatches(
entry.extra, packet, entry.path_hash_size, entry.path_hops, entry.path)) {
return false;
}
uint8_t type = packet->getPayloadType();
uint8_t hops = packet->getPathHashCount();
if ((entry.payload_type != ANY_TYPE && entry.payload_type != type)
|| hops < entry.min_hops || hops > entry.max_hops) {
|| (packet->isRouteFlood() ? hops < entry.min_hops || hops > entry.max_hops
: entry.min_hops != 0 || entry.max_hops != MAX_HOPS)) {
return false;
}
@@ -938,7 +909,7 @@ uint32_t FloodRuleEngine::evaluate(
const RegionEntry* incoming_region, uint8_t context) {
static_assert(RULE_SLOTS <= 32,
"flood rule match mask supports at most 32 slots");
if (packet == NULL || (context == FloodFilterPolicy::RULE_MODE_RADIO && !packet->isRouteFlood())) return 0;
if (packet == NULL) return 0;
bool incoming_is_scoped =
packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD
|| packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT;
@@ -947,6 +918,7 @@ uint32_t FloodRuleEngine::evaluate(
bool channel_auth_checked[RULE_SLOTS] = { false };
bool channel_auth_valid[RULE_SLOTS] = { false };
uint32_t result = 0;
uint32_t pattern_work = 131072;
for (int i = 0; i < RULE_SLOTS; i++) {
const Entry& entry = _entries[i];
if (!fieldsMatch(entry, packet, temp_radio_active, incoming_is_scoped,
@@ -973,14 +945,23 @@ uint32_t FloodRuleEngine::evaluate(
channel_auth_checked[i] = true;
channel_auth_valid[i] = authenticated;
}
if (authenticated) result |= (uint32_t)1U << i;
bool budget_exceeded = false;
const bool content_matches = authenticated && FloodRuleExtensions::matchesContent(
entry.extra, packet, entry.channel_secret, entry.channel_key_len,
&pattern_work, false, &budget_exceeded);
if (budget_exceeded) return uint32_t(1) << 31;
if (!content_matches) continue;
if (entry.extra.probability && entry.extra.probability < 100
&& !FloodRuleExtensions::matchesProbability(entry.extra,
FloodRuleExtensions::probabilityRoll(packet, _probability_seed, unsigned(i), context))) continue;
result |= (uint32_t)1U << i;
}
return applyStop(result);
}
bool FloodRuleEngine::hasRetryRules() const {
for (int i = 0; i < RULE_SLOTS; i++) {
if (_entries[i].active && _entries[i].retry_on_match) return true;
if (_entries[i].active && !_entries[i].extra.disabled && _entries[i].retry_on_match) return true;
}
return false;
}
@@ -1042,6 +1023,7 @@ bool FloodRuleEngine::shouldBlock(const mesh::Packet* packet,
uint32_t match_mask,
uint32_t now_millis) const {
if (packet == NULL) return false;
if (match_mask & (uint32_t(1) << 31)) return true;
uint8_t type = packet->getPayloadType();
uint8_t hops = packet->getPathHashCount();
@@ -1096,7 +1078,7 @@ void FloodRuleEngine::formatDetail(int index, char* reply,
return;
}
const Entry& entry = _entries[index];
if (compact) {
if (compact || FloodRuleExtensions::hasFields(entry.extra) || entry.path_hops > 3) {
FloodRuleCLI::formatCompact(reply, reply_len, index + 1, entry);
return;
}
@@ -1187,6 +1169,28 @@ void FloodRuleEngine::format(const char* args, char* reply, bool compact) const
const char* selector = skipSpaces(args);
if (*selector == '.') selector = skipSpaces(selector + 1);
if (*selector != 0) {
const char* field = strchr(selector, ' ');
if (field) {
char slot_text[8];
const size_t length = size_t(field - selector);
uint32_t slot = 0;
if (!length || length >= sizeof(slot_text)) {
copyString(reply, "Err - bad filter slot", 160);
return;
}
memcpy(slot_text, selector, length); slot_text[length] = 0;
field = skipSpaces(field);
if (!parseUnsigned(slot_text, RULE_SLOTS, slot) || !slot || !_entries[slot - 1].active) {
copyString(reply, "Err - empty or invalid filter slot", 160);
} else if (asciiEqual(field, "n") || asciiEqual(field, "sender")) {
snprintf(reply, 160, "> %s", _entries[slot - 1].extra.sender);
} else if (asciiEqual(field, "x") || asciiEqual(field, "text")) {
snprintf(reply, 160, "> %s", _entries[slot - 1].extra.text);
} else {
copyString(reply, "Err - field is sender/n or text/x", 160);
}
return;
}
uint32_t slot = 0;
if (!parseUnsigned(selector, RULE_SLOTS, slot) || slot == 0) {
snprintf(reply, 160, "Err - filter slot must be 1-%u",
@@ -1224,7 +1228,7 @@ void FloodRuleEngine::format(const char* args, char* reply, bool compact) const
snprintf(priority, sizeof(priority), "^%u",
(unsigned int)entry.priority);
}
snprintf(item, sizeof(item), " %d=%s~%s@%s%s%s%s%s%s%s%s%s",
snprintf(item, sizeof(item), " %d=%s~%s@%s%s%s%s%s%s%s%s%s%s",
i + 1, payloadTypeName(entry.payload_type),
FloodFilterPolicy::ruleModeName(entry.transport_modes), hops,
entry.match_blacklisted_path ? "?blacklist" : "", target,
@@ -1232,7 +1236,8 @@ void FloodRuleEngine::format(const char* args, char* reply, bool compact) const
entry.rate_limit_enabled ? "~rate" : "",
entry.retry_on_match ? "~retry" : "",
entry.scope_uses_slow_timing ? "~slow" : "",
entry.suspend_on_temp_radio ? "~tempradio" : "");
entry.suspend_on_temp_radio ? "~tempradio" : "",
entry.extra.disabled ? "~off" : "");
size_t item_len = strlen(item);
if (used + item_len >= 156) {
truncated = true;
@@ -1275,6 +1280,27 @@ void FloodRuleEngine::set(const char* args, char* reply,
}
}
cursor = skipSpaces(cursor);
const char* enabled = asciiStartsWith(cursor, "enabled=") ? cursor + 8
: asciiStartsWith(cursor, "e=") ? cursor + 2
: (asciiEqual(cursor, "on") || asciiEqual(cursor, "off")) ? cursor : nullptr;
if (enabled) {
uint8_t disabled;
if (requested_slot < 0 || !_entries[requested_slot].active) {
copyString(reply, "Err - select an occupied filter slot", 160);
} else if (!FloodRuleExtensions::enabledValue(enabled, disabled)) {
copyString(reply, "Err - enabled is on or off", 160);
} else {
const uint8_t previous = _entries[requested_slot].extra.disabled;
_entries[requested_slot].extra.disabled = disabled;
if (previous != disabled && !save()) {
_entries[requested_slot].extra.disabled = previous;
copyString(reply, "Err - unable to save flood filter", 160);
} else {
copyString(reply, "OK", 160);
}
}
return;
}
char params[256];
if (strlen(cursor) >= sizeof(params)) {
copyString(reply, "Err - rule parameters too long", 160);
@@ -1282,20 +1308,11 @@ void FloodRuleEngine::set(const char* args, char* reply,
}
copyString(params, cursor, sizeof(params));
char* tokens[18];
int token_count = 0;
char* token = params;
while (*token != 0) {
if (token_count >= 18) {
copyString(reply, "Err - too many rule parameters", 160);
return;
}
tokens[token_count++] = token;
char* separator = strchr(token, ' ');
if (separator == NULL) break;
*separator++ = 0;
while (*separator == ' ') separator++;
token = separator;
char* tokens[32];
int token_count = FloodRuleExtensions::tokenize(params, tokens, 32);
if (token_count < 0) {
copyString(reply, "Err - invalid quotes or too many rule parameters", 160);
return;
}
if (token_count == 0) {
copyString(reply, RULE_USAGE, 160);
@@ -1352,8 +1369,13 @@ void FloodRuleEngine::set(const char* args, char* reply,
bool retry_on_match = false;
uint8_t transport_modes = FloodFilterPolicy::RULE_MODE_RADIO;
bool mode_set = false;
FloodRuleExtensions::Fields extra{};
uint8_t extra_seen = 0;
for (int i = 1; i < token_count; i++) {
const int parsed_extra = FloodRuleExtensions::parseToken(tokens[i], extra, extra_seen, reply);
if (parsed_extra < 0) return;
if (parsed_extra > 0) continue;
if (asciiStartsWith(tokens[i], "mode=")
|| asciiStartsWith(tokens[i], "m=")) {
if (mode_set) {
@@ -1486,6 +1508,9 @@ void FloodRuleEngine::set(const char* args, char* reply,
priority_set = true;
} else if (asciiEqual(tokens[i], "stop")
|| asciiEqual(tokens[i], "s")
|| asciiEqual(tokens[i], "allow") || asciiEqual(tokens[i], "forward")
|| asciiEqual(tokens[i], "a") || asciiEqual(tokens[i], "action=allow")
|| asciiEqual(tokens[i], "action=forward")
|| asciiEqual(tokens[i], "action=stop")) {
if (stop_on_match) {
copyString(reply, DUPLICATE_OPTION, 160);
@@ -1625,15 +1650,18 @@ void FloodRuleEngine::set(const char* args, char* reply,
hashbytes_set = true;
} else if (asciiStartsWith(tokens[i], "prefix=")
|| asciiStartsWith(tokens[i], "p=")
|| asciiStartsWith(tokens[i], "pa=")
|| asciiStartsWith(tokens[i], "path=")) {
if (path_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
const char* value = strchr(tokens[i], '=') + 1;
if (!parsePathPrefix(value, path_hash_size, path_hops, path)) {
const bool prefix_legacy = asciiStartsWith(tokens[i], "prefix=")
|| asciiStartsWith(tokens[i], "p=");
if (!FloodRuleExtensions::parsePath(value, path_hash_size, path_hops, path, extra, prefix_legacy)) {
copyString(reply,
"Err - prefix is * or 1-3 comma-separated 1/2/3-byte IDs",
"Err - path is [^]ID>ID[>ID>IDs][$] using 1/2/3-byte IDs",
160);
return;
}
@@ -1685,6 +1713,12 @@ void FloodRuleEngine::set(const char* args, char* reply,
160);
return;
}
if (FloodRuleExtensions::contentRequested(extra)
&& (!FloodFilterPolicy::channelRequiresAuthentication(channel_key_len)
|| (payload_type != ANY_TYPE && payload_type != PAYLOAD_TYPE_GRP_TXT))) {
copyString(reply, "Err - sender/text requires an authenticated group-text channel", 160);
return;
}
if (!FloodFilterPolicy::transportActionsSupported(
transport_modes, target_set, retry_on_match, scope_uses_slow_timing)) {
strcpy(reply, "Err - bridge/cross supports drop, rate=, priority=, stop only");
@@ -1730,6 +1764,11 @@ void FloodRuleEngine::set(const char* args, char* reply,
candidate.stop_on_match = stop_on_match;
candidate.retry_on_match = retry_on_match;
candidate.transport_modes = transport_modes;
candidate.extra = extra;
if (!FloodRuleExtensions::validRuleFields(candidate)) {
copyString(reply, "Err - invalid rule fields or flood-only action on a direct route", 160);
return;
}
int slot = requested_slot;
if (slot < 0) {
@@ -1775,15 +1814,20 @@ void FloodRuleEngine::remove(const char* args, char* reply) {
const char* selector = skipSpaces(args);
if (*selector == '.') selector = skipSpaces(selector + 1);
if (asciiEqual(selector, "all")) {
Entry previous[RULE_SLOTS];
memcpy(previous, _entries, sizeof(previous));
Entry* previous = new (std::nothrow) Entry[RULE_SLOTS];
if (!previous) {
copyString(reply, "Err - unable to stage filter deletion", 160);
return;
}
memcpy(previous, _entries, sizeof(Entry) * RULE_SLOTS);
memset(_entries, 0, sizeof(Entry) * RULE_SLOTS);
if (!save()) {
memcpy(_entries, previous, sizeof(_entries));
memcpy(_entries, previous, sizeof(Entry) * RULE_SLOTS);
copyString(reply, "Err - unable to save flood filter", 160);
} else {
copyString(reply, "OK - all flood filters removed", 160);
}
delete[] previous;
return;
}
@@ -1831,15 +1875,15 @@ bool FloodRuleEngine::handleCommand(const char* command, char* reply) {
return true;
}
if (commandMatches(command, "get flood.rule")) {
format(command + strlen("get flood.rule"), reply);
format(mesh::cli::floodCommandArguments(command, "get flood.rule"), reply);
return true;
}
if (commandMatches(command, "set flood.rule")) {
set(command + strlen("set flood.rule"), reply, true);
set(mesh::cli::floodCommandArguments(command, "set flood.rule"), reply, true);
return true;
}
if (commandMatches(command, "del flood.rule")) {
remove(command + strlen("del flood.rule"), reply);
remove(mesh::cli::floodCommandArguments(command, "del flood.rule"), reply);
return true;
}
if (commandMatches(command, "get flood.filter")) {
@@ -10,6 +10,7 @@
#include <helpers/IdentityStore.h>
#include <helpers/RegionMap.h>
#include <helpers/FloodRuleExtensions.h>
class FloodRuleEngine {
public:
@@ -23,7 +24,7 @@ public:
FloodRuleEngine();
void begin(FILESYSTEM* fs, RegionMap* regions);
void begin(FILESYSTEM* fs, RegionMap* regions, mesh::RNG* rng = nullptr);
uint32_t evaluate(const mesh::Packet* packet, bool temp_radio_active,
bool incoming_region_allowed,
@@ -73,18 +74,20 @@ private:
char target_region_name[NAME_LEN];
uint8_t priority;
uint8_t transport_modes;
FloodRuleExtensions::Fields extra;
};
// This table is at most 5,952 bytes. Keep it in the constructor-time heap rather
// This table is at most 10,416 bytes. Keep it in the constructor-time heap rather
// than inside the global MyMesh object: classic ESP32 has a small static
// DRAM window but substantially more internal heap at this point in boot.
// Allocation is deliberately all-or-nothing. A node must never fall back
// to silently running without its configured flood rules.
static_assert(sizeof(Entry) <= 192,
static_assert(sizeof(Entry) <= 336,
"Update the flood-rule runtime RAM budget in check_firmware_ram.py");
FILESYSTEM* _fs;
RegionMap* _regions;
Entry* _entries;
uint32_t _probability_seed;
void seedDefaults();
void load();
+11 -13
View File
@@ -1738,14 +1738,10 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
_prefs.flood_max_advert)) {
return false;
}
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
if (flood_rules.shouldBlock(packet, recv_pkt_rule_match_mask,
_ms->getMillis())) {
return false;
}
#endif
}
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
if (flood_rules.shouldBlock(packet, recv_pkt_rule_match_mask,
_ms->getMillis())) return false;
if (packet->isRouteFlood()) {
if (recv_pkt_region == NULL && !recv_pkt_regionless_scope_set) {
MESH_DEBUG_PRINTLN(
@@ -1763,9 +1759,9 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
return false;
}
}
flood_rules.commitRates(packet, recv_pkt_rule_match_mask,
_ms->getMillis());
}
flood_rules.commitRates(packet, recv_pkt_rule_match_mask,
_ms->getMillis());
#endif
_clock_sync.observeAcceptedFlood(packet);
return true;
@@ -1778,19 +1774,21 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
recv_pkt_regionless_scope_set = false;
recv_pkt_rule_match_mask = 0;
pkt->flood_retry_policy = mesh::FLOOD_RETRY_POLICY_DEFAULT;
if (pkt->isRouteFlood()) {
{
bool incoming_region_allowed = false;
RegionEntry* incoming_region = NULL;
if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD
|| pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT) {
incoming_region = region_map.findMatch(pkt, REGION_DENY_FLOOD);
incoming_region_allowed = incoming_region != NULL;
} else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) {
} else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD
|| pkt->getRouteType() == ROUTE_TYPE_DIRECT) {
incoming_region_allowed =
(region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0;
}
recv_pkt_rule_match_mask = flood_rules.evaluate(
pkt, isAnyTempRadioActive(), incoming_region_allowed, incoming_region);
if (flood_rules.hasRetryRules()) {
if (pkt->isRouteFlood() && flood_rules.hasRetryRules()) {
pkt->flood_retry_policy = flood_rules.allowsRetry(
recv_pkt_rule_match_mask)
? mesh::FLOOD_RETRY_POLICY_ALLOW
@@ -2648,7 +2646,7 @@ void MyMesh::begin(FILESYSTEM *fs) {
MESH_DEBUG_PRINTLN("Room topic unavailable; stored image preserved");
}
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
flood_rules.begin(_fs, &region_map);
flood_rules.begin(_fs, &region_map, getRNG());
#endif
// establish default-scope
+3 -3
View File
@@ -289,12 +289,12 @@ def requirements(platform, defines, target):
if repeater:
engine = integer(defines, "MESH_ENABLE_FLOOD_RULE_ENGINE", int(platform != "STM32_PLATFORM"))
slots = integer(defines, "FLOOD_PACKET_FILTER_SLOTS", 63 if engine else 16)
parts["flood_filter_table"] = slots * (192 if engine else 40) + 16
parts["flood_filter_table"] = slots * (336 if engine else 40) + 16
if "room_server" in target.lower() and integer(
defines, "MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE", 0):
# FloodRuleEngine keeps all 31 persisted rules in one startup heap
# allocation. Its C++ assertion pins Entry to the budgeted 192 bytes.
parts["room_flood_rule_table"] = 31 * 192 + 16
# allocation. Its C++ assertion pins Entry to the budgeted 336 bytes.
parts["room_flood_rule_table"] = 31 * 336 + 16
if "ENABLE_OTA" in defines and "OTA_HEAP_CONTEXT" in defines:
parts["ota_context"] = 16384 + 16
if "ENABLE_OTA" in defines:
+76 -8
View File
@@ -230,6 +230,42 @@ bool rowKey(const char* key, size_t length, const char* family) {
return true;
}
// Regex punctuation is data only in the two content-pattern fields. Keep the
// existing management-command character policy for every other argument.
bool filterPatternCharactersAllowed(const char* text) {
while (*text) {
while (*text == ' ') ++text;
if (!*text) break;
const char* key = text;
while (*text && *text != '=' && *text != ' ') {
if (*text == '|' || *text == '\\') return false;
++text;
}
const size_t key_length = size_t(text - key);
const bool pattern = keyEquals(key, key_length, "sender")
|| keyEquals(key, key_length, "n") || keyEquals(key, key_length, "text")
|| keyEquals(key, key_length, "x");
if (*text == '=') ++text;
const char quote = *text == '"' || *text == '\'' ? *text++ : 0;
bool closed = quote == 0;
while (*text) {
if (quote ? *text == quote : *text == ' ') {
if (quote) { ++text; closed = true; }
break;
}
if (*text == '|' && !pattern) return false;
if (*text == '\\') {
if (!pattern || !text[1]) return false;
text += 2;
} else {
++text;
}
}
if (!closed || (*text && *text != ' ')) return false;
}
return true;
}
bool decimalToken(const char* text, size_t length) {
if (!length) return false;
bool dot = false, digit = false;
@@ -458,11 +494,12 @@ bool FleetCommand::withinRadius(int32_t center_latitude_e6, int32_t center_longi
bool FleetCommand::commandAllowed(const char* command) {
if (!command) return false;
size_t length = 0;
bool pattern_characters = false;
while (command[length]) {
const unsigned char byte = command[length];
if (++length > MaxCommandLength || byte < 0x20 || byte > 0x7e
|| byte == ';' || byte == '|' || byte == '&' || byte == '`'
|| byte == '\\') return false;
|| byte == ';' || byte == '&' || byte == '`') return false;
pattern_characters = pattern_characters || byte == '|' || byte == '\\';
}
if (!length || command[0] == ' ' || command[length - 1] == ' ') return false;
if (!strcmp(command, "clock") || !strcmp(command, "clock sync")) return true;
@@ -471,17 +508,40 @@ bool FleetCommand::commandAllowed(const char* command) {
return boundedIntegerToken(command + 5, length - 5, UINT32_MAX, value)
&& value >= MinEpoch;
}
bool get = false, set = false, del = false;
bool get = false, set = false, del = false, clear = false;
if (!strncmp(command, "get ", 4)) get = true;
else if (!strncmp(command, "set ", 4)) set = true;
else if (!strncmp(command, "del ", 4)) del = true;
else if (!strncmp(command, "clear ", 6)) clear = true;
else return false;
const char* key = command + 4;
const char* key = command + (clear ? 6 : 4);
const char* separator = strchr(key, ' ');
const size_t key_length = separator ? size_t(separator - key) : strlen(key);
const char* arguments = separator ? separator + 1 : "";
if (!key_length || (separator && (!*arguments || *arguments == ' '))) return false;
const bool filter_rows = rowKey(key, key_length, "flood.filter")
|| rowKey(key, key_length, "flood.rule") || rowKey(key, key_length, "fr");
if (pattern_characters && (!set || !filter_rows
|| !filterPatternCharactersAllowed(arguments))) return false;
if (keyEquals(key, key_length, "flood.advert") || keyEquals(key, key_length, "fa")) {
if (get) {
if (!*arguments) return true;
const bool detail = !strncmp(arguments, "key ", 4);
const char* number = arguments + (detail ? 4 : 0);
uint32_t value;
return *number >= '1' && *number <= '9'
&& boundedIntegerToken(number, strlen(number), 65535, value);
}
if (!clear) return false;
if (!strcmp(arguments, "all")) return true;
if (strlen(arguments) != PUB_KEY_SIZE * 2) return false;
for (size_t i = 0; arguments[i]; ++i) if (hexDigit(arguments[i]) < 0) return false;
return true;
}
if (clear) return false;
const bool scheduled_primary = keyEquals(key, key_length, "radioat")
|| keyEquals(key, key_length, "tempradioat");
const bool scheduled_secondary = keyEquals(key, key_length, "radioat2")
@@ -500,13 +560,21 @@ bool FleetCommand::commandAllowed(const char* command) {
return get ? !*arguments : set && (!strcmp(arguments, "on")
|| !strcmp(arguments, "off") || !strcmp(arguments, "auto"));
}
const bool rows = rowKey(key, key_length, "flood.filter")
const bool rows = filter_rows
|| rowKey(key, key_length, "flood.filter.blacklist")
|| rowKey(key, key_length, "flood.rule")
|| rowKey(key, key_length, "fb")
|| rowKey(key, key_length, "flood.moderation")
|| rowKey(key, key_length, "fm")
|| rowKey(key, key_length, "flood.channel.scope")
|| rowKey(key, key_length, "flood.channel.scope.require");
if (rows) return get ? !*arguments
|| rowKey(key, key_length, "fs")
|| rowKey(key, key_length, "flood.channel.scope.require")
|| rowKey(key, key_length, "fsr");
if (rows) return get ? (!*arguments || (filter_rows
&& !keyEquals(key, key_length, "flood.filter")
&& !keyEquals(key, key_length, "flood.rule")
&& !keyEquals(key, key_length, "fr")
&& (!strcmp(arguments, "sender") || !strcmp(arguments, "n")
|| !strcmp(arguments, "text") || !strcmp(arguments, "x"))))
: set ? *arguments != 0 : del && (!*arguments || !strcmp(arguments, "all"));
const bool settings = keyEquals(key, key_length, "flood.channel.data")
|| keyEquals(key, key_length, "flood.channel.data.hops")
+5 -8
View File
@@ -1,6 +1,7 @@
#pragma once
#include "FloodAdvertLimiter.h"
#include "FloodCommandAliases.h"
#include <Utils.h>
#include <stdio.h>
@@ -17,10 +18,8 @@ inline const char* floodAdvertLimitReason(uint8_t reasons) {
// Row numbers refer to the current filtered list, not permanent table slots.
inline bool handleFloodAdvertGet(FloodAdvertLimiter* limiter, const char* command,
char* reply, uint32_t now) {
static const char prefix[] = "get flood.advert";
const size_t prefix_len = sizeof(prefix) - 1;
if (strncmp(command, prefix, prefix_len) != 0) return false;
const char* arg = command + prefix_len;
const char* arg = floodCommandArguments(command, "get flood.advert");
if (!arg) return false;
if (*arg && *arg != ' ' && *arg != '\t' && *arg != '\r' && *arg != '\n') return false;
while (*arg == ' ' || *arg == '\t') ++arg;
const bool detail = strncmp(arg, "key", 3) == 0 && (arg[3] == ' ' || arg[3] == '\t');
@@ -81,10 +80,8 @@ inline bool handleFloodAdvertGet(FloodAdvertLimiter* limiter, const char* comman
// Dispatch only after the role's ordinary CLI authorization checks. Requiring
// an explicit "all" avoids treating missing/malformed keys as a global reset.
inline bool handleFloodAdvertClear(FloodAdvertLimiter* limiter, const char* command, char* reply) {
static const char prefix[] = "clear flood.advert";
const size_t prefix_len = sizeof(prefix) - 1;
if (strncmp(command, prefix, prefix_len) != 0) return false;
const char* arg = command + prefix_len;
const char* arg = floodCommandArguments(command, "clear flood.advert");
if (!arg) return false;
if (*arg && *arg != ' ' && *arg != '\t') return false;
while (*arg == ' ' || *arg == '\t') ++arg;
const char* end = arg;
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <stddef.h>
#include <string.h>
namespace mesh {
namespace cli {
// Return the untouched argument suffix. Aliases do not expand the caller's
// bounded command buffer or change the bytes covered by a fleet signature.
inline const char* floodCommandArguments(const char* command, const char* family) {
if (!command || !family) return nullptr;
const size_t length = strlen(family);
if (strncmp(command, family, length) == 0
&& (command[length] == 0 || command[length] == ' ' || command[length] == '.'))
return command + length;
const char* name = strchr(family, ' ');
if (!name) return nullptr;
const size_t verb_length = size_t(++name - family);
if (strncmp(command, family, verb_length) != 0) return nullptr;
const char* alias = nullptr;
if (strcmp(name, "flood.rule") == 0) alias = "fr";
else if (strcmp(name, "flood.moderation") == 0) alias = "fm";
else if (strcmp(name, "flood.channel.scope") == 0) alias = "fs";
else if (strcmp(name, "flood.channel.scope.require") == 0) alias = "fsr";
else if (strcmp(name, "flood.filter.blacklist") == 0) alias = "fb";
else if (strcmp(name, "flood.advert") == 0) alias = "fa";
if (!alias) return nullptr;
const size_t alias_length = strlen(alias);
const char* key = command + verb_length;
if (strncmp(key, alias, alias_length) != 0) return nullptr;
const char* suffix = key + alias_length;
return *suffix == 0 || *suffix == ' ' || *suffix == '.' ? suffix : nullptr;
}
} // namespace cli
} // namespace mesh
+13 -3
View File
@@ -5,6 +5,7 @@
#include <string.h>
#include <Packet.h>
#include "FloodRuleExtensions.h"
namespace FloodFilterPolicy {
@@ -52,6 +53,14 @@ enum ChannelScopeGate {
// Rules can be reordered/compacted in RAM without changing their identity.
// Compare configured fields explicitly: padding and live rate-window state
// must never decide whether an unnumbered setter reuses an existing slot.
template<typename Entry>
auto sameRuleExtras(const Entry& left, const Entry& right, int)
-> decltype(left.extra, right.extra, bool()) {
return FloodRuleExtensions::sameFields(left.extra, right.extra);
}
template<typename Entry>
bool sameRuleExtras(const Entry&, const Entry&, long) { return true; }
template<typename Entry>
bool sameRuleConfiguration(const Entry& left, const Entry& right) {
return left.active == right.active
@@ -81,7 +90,8 @@ bool sameRuleConfiguration(const Entry& left, const Entry& right) {
&& left.priority == right.priority
&& left.stop_on_match == right.stop_on_match
&& left.retry_on_match == right.retry_on_match
&& left.transport_modes == right.transport_modes;
&& left.transport_modes == right.transport_modes
&& sameRuleExtras(left, right, 0);
}
inline bool channelKeyLengthSupported(uint8_t key_len) {
@@ -184,10 +194,10 @@ inline bool parseRuleModes(const char* text, uint8_t& modes) {
// These are separate admission phases. A transport rule cannot suppress
// ordinary LoRa forwarding, nor can a radio stop rule bypass a transport deny.
inline bool ruleModeMatches(uint8_t modes, uint8_t context,
const mesh::Packet* packet) {
const mesh::Packet* packet, uint8_t route = 0) {
return packet && (modes == RULE_MODE_RADIO ? context == RULE_MODE_RADIO
: (modes & context) != 0)
&& (context != RULE_MODE_RADIO || packet->isRouteFlood());
&& (route != 0 || context != RULE_MODE_RADIO || packet->isRouteFlood());
}
inline bool transportActionsSupported(uint8_t modes, bool scope_action,
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh { namespace FloodGroupPlainText {
struct Span {
const uint8_t* data;
size_t size;
Span(const uint8_t* bytes = nullptr, size_t length = 0)
: data(bytes), size(length) {}
};
struct View {
uint32_t timestamp = 0;
Span sender;
Span text;
bool has_sender = false;
};
// Views borrow the decrypted buffer. Never truncate fields before matching:
// a long display name still needs to match wildcard or content rules.
inline bool parse(const uint8_t* data, size_t size, uint8_t plain_type,
View& view) {
view = View{};
if (!data || size <= 5 || (data[4] >> 2) != plain_type) return false;
memcpy(&view.timestamp, data, sizeof(view.timestamp));
view.text = {data + 5, size - 5};
// MACThenDecrypt returns complete AES blocks, including zero padding.
const uint8_t* end = static_cast<const uint8_t*>(
memchr(view.text.data, 0, view.text.size));
if (!end) end = data + size;
view.text.size = size_t(end - view.text.data);
const uint8_t* colon = static_cast<const uint8_t*>(
memchr(view.text.data, ':', view.text.size));
if (!colon) return true;
size_t sender_size = size_t(colon - view.text.data);
while (sender_size && view.text.data[sender_size - 1] == ' ') --sender_size;
bool valid_sender = sender_size != 0;
for (size_t i = 0; i < sender_size; ++i) {
if (view.text.data[i] < 0x20) valid_sender = false;
}
if (valid_sender) {
view.sender = {view.text.data, sender_size};
view.has_sender = true;
}
const uint8_t* body = colon + 1;
if (body < end && *body == ' ') ++body;
view.text = {body, size_t(end - body)};
return true;
}
inline bool senderEquals(const View& view, const char* name) {
if (!view.has_sender || !name) return false;
const size_t size = strlen(name);
if (size != view.sender.size) return false;
for (size_t i = 0; i < size; ++i) {
uint8_t left = view.sender.data[i];
uint8_t right = uint8_t(name[i]);
if (left >= 'A' && left <= 'Z') left += 'a' - 'A';
if (right >= 'A' && right <= 'Z') right += 'a' - 'A';
if (left != right) return false;
}
return true;
}
} } // namespace mesh::FloodGroupPlainText
+23 -8
View File
@@ -66,6 +66,27 @@ public:
// Generalized repeater and room-server rows have the same named fields.
// Defaults are omitted only when the setter reconstructs exactly that value.
template<typename Entry>
auto appendPath(CommandWriter& text, const Entry& entry, int)
-> decltype(entry.extra, void()) {
FloodRuleExtensions::appendPath(text, entry.extra, entry.path_hash_size,
entry.path_hops, entry.path, true);
}
template<typename Entry>
void appendPath(CommandWriter& text, const Entry& entry, long) {
text.append(" p=");
for (unsigned hop = 0; hop < entry.path_hops; ++hop) {
if (hop) text.append(",");
for (unsigned byte = 0; byte < entry.path_hash_size; ++byte)
text.append("%02X", entry.path[hop * entry.path_hash_size + byte]);
}
}
template<typename Entry>
auto appendExtras(CommandWriter& text, const Entry& entry, int)
-> decltype(entry.extra, void()) { FloodRuleExtensions::appendFields(text, entry.extra, true); }
template<typename Entry>
void appendExtras(CommandWriter&, const Entry&, long) {}
template<typename Entry>
bool formatCompact(char* out, size_t capacity, unsigned slot, const Entry& entry) {
using namespace FloodFilterPolicy;
@@ -84,14 +105,8 @@ bool formatCompact(char* out, size_t capacity, unsigned slot, const Entry& entry
if (entry.path_hops == 0 && entry.path_hash_size != 0)
text.append(" pb=%s", pathHashBytesName(entry.path_hash_size));
if (entry.match_blacklisted_path) text.append(" p=bl");
else if (entry.path_hops) {
text.append(" p=");
for (unsigned hop = 0; hop < entry.path_hops; hop++) {
if (hop) text.append(",");
for (unsigned byte = 0; byte < entry.path_hash_size; byte++)
text.append("%02X", entry.path[hop * entry.path_hash_size + byte]);
}
}
else if (entry.path_hops) appendPath(text, entry, 0);
appendExtras(text, entry, 0);
switch (entry.incoming_scope_kind) {
case RULE_IN_NONE: text.append(" i=n"); break;
case RULE_IN_SCOPED: text.append(" i=s"); break;
+417
View File
@@ -0,0 +1,417 @@
#pragma once
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <Packet.h>
#include "TxtDataHelpers.h"
#include "FloodTextPattern.h"
#include "FloodGroupPlainText.h"
namespace mesh { class Utils; }
namespace FloodRuleExtensions {
enum Route : uint8_t { LegacyRoute, FloodRoute, DirectRoute, AnyRoute };
enum PathPosition : uint8_t { Prefix, Anywhere, Suffix, Exact };
// Zero initialization retains every FPF7 default. Keep the old path's first
// nine bytes in its established field; only a fourth 3-byte ID needs a tail.
struct Fields {
uint16_t min_payload;
uint16_t max_payload;
uint8_t route;
uint8_t probability;
uint8_t path_position;
uint8_t disabled;
uint8_t payload_set;
uint8_t path_tail[3];
char sender[64];
char text[64];
};
static_assert(sizeof(Fields) <= 140, "Update the extended filter RAM budget");
inline bool hasFields(const Fields& fields) {
return fields.min_payload || fields.max_payload || fields.route
|| fields.probability || fields.path_position || fields.disabled || fields.payload_set
|| fields.path_tail[0] || fields.path_tail[1] || fields.path_tail[2]
|| fields.sender[0] || fields.text[0];
}
inline bool sameFields(const Fields& left, const Fields& right) {
return left.min_payload == right.min_payload && left.max_payload == right.max_payload
&& left.route == right.route && left.probability == right.probability
&& left.path_position == right.path_position && left.disabled == right.disabled
&& left.payload_set == right.payload_set
&& !memcmp(left.path_tail, right.path_tail, sizeof(left.path_tail))
&& !strcmp(left.sender, right.sender) && !strcmp(left.text, right.text);
}
template<typename Entry>
inline bool tableUsesExtensions(const Entry* entries, unsigned count) {
for (unsigned i = 0; i < count; ++i)
if (entries[i].active && (hasFields(entries[i].extra) || entries[i].path_hops > 3)) return true;
return false;
}
inline bool contentRequested(const Fields& fields) {
return fields.sender[0] || fields.text[0];
}
inline bool validFields(const Fields& fields) {
return fields.route <= AnyRoute && fields.probability <= 100
&& fields.path_position <= Exact && fields.disabled <= 1 && fields.payload_set <= 1
&& fields.min_payload <= MAX_PACKET_PAYLOAD
&& fields.max_payload <= MAX_PACKET_PAYLOAD
&& (!fields.payload_set || fields.min_payload <= fields.max_payload)
&& memchr(fields.sender, 0, sizeof(fields.sender))
&& memchr(fields.text, 0, sizeof(fields.text))
&& mesh::flood::textPatternValid(fields.sender, strlen(fields.sender))
&& mesh::flood::textPatternValid(fields.text, strlen(fields.text));
}
template<typename Entry>
inline bool validRuleFields(const Entry& entry) {
if (!validFields(entry.extra)) return false;
const bool path_valid = entry.path_hops == 0
? entry.path_hash_size <= 3 || entry.path_hash_size == 0x82
: entry.path_hash_size >= 1 && entry.path_hash_size <= 3
&& entry.path_hops <= 4 && !entry.match_blacklisted_path;
const bool content_valid = !contentRequested(entry.extra)
|| ((entry.channel_key_len == 16 || entry.channel_key_len == 32)
&& (entry.payload_type == 0xFF || entry.payload_type == PAYLOAD_TYPE_GRP_TXT));
const bool flood_actions = entry.scope_name[0] || entry.target_region_name[0]
|| entry.retry_on_match || entry.scope_uses_slow_timing;
return path_valid && content_valid && (!flood_actions
|| entry.extra.route == LegacyRoute || entry.extra.route == FloodRoute);
}
// Strip grouping quotes, preserving regex escapes and all quoted whitespace.
// Token pointers borrow this buffer; malformed input never reaches a setter.
inline int tokenize(char* buffer, char* tokens[], unsigned capacity) {
char* read = buffer;
char* write = buffer;
unsigned count = 0;
while (*read) {
while (*read == ' ' || *read == '\t') ++read;
if (!*read) break;
if (count == capacity) return -1;
tokens[count++] = write;
char quote = 0;
while (*read) {
char c = *read++;
if (c == '\\' && *read) {
if (quote && (*read == quote || *read == '\\')) *write++ = *read++;
else { *write++ = c; *write++ = *read++; }
} else if (quote && c == quote) {
quote = 0;
} else if (!quote && (c == '"' || c == '\'')) {
quote = c;
} else if (!quote && (c == ' ' || c == '\t')) {
break;
} else {
*write++ = c;
}
}
if (quote) return -1;
*write++ = 0;
}
return int(count);
}
inline bool unsignedValue(const char* text, uint16_t maximum, uint16_t& value) {
if (!text || !*text) return false;
uint32_t parsed = 0;
do {
if (*text < '0' || *text > '9') return false;
parsed = parsed * 10U + uint8_t(*text++ - '0');
if (parsed > maximum) return false;
} while (*text);
value = uint16_t(parsed);
return true;
}
inline bool enabledValue(const char* value, uint8_t& disabled) {
if (!strcmp(value, "on") || !strcmp(value, "1")) disabled = 0;
else if (!strcmp(value, "off") || !strcmp(value, "0")) disabled = 1;
else return false;
return true;
}
// Return 0 for an existing-parser token, 1 when handled, -1 on invalid input.
inline int parseToken(const char* token, Fields& fields, uint8_t& seen, char* reply) {
const char* value = strchr(token, '=');
if (!value) return 0;
const size_t length = size_t(value++ - token);
unsigned bit;
if ((length == 6 && !strncmp(token, "sender", 6)) || (length == 1 && *token == 'n')) bit = 0;
else if ((length == 4 && !strncmp(token, "text", 4)) || (length == 1 && *token == 'x')) bit = 1;
else if ((length == 3 && !strncmp(token, "len", 3)) || (length == 1 && *token == 'l')) bit = 2;
else if ((length == 5 && !strncmp(token, "route", 5)) || (length == 2 && !strncmp(token, "rt", 2))) bit = 3;
else if ((length == 4 && !strncmp(token, "prob", 4)) || (length == 2 && !strncmp(token, "pr", 2))) bit = 4;
else if ((length == 7 && !strncmp(token, "enabled", 7)) || (length == 1 && *token == 'e')) bit = 5;
else return 0;
if (seen & (1U << bit)) {
strcpy(reply, "Err - duplicate rule parameter");
return -1;
}
seen |= uint8_t(1U << bit);
bool valid = true;
if (bit <= 1) {
valid = *value && strlen(value) < sizeof(fields.sender)
&& mesh::flood::textPatternValid(value, strlen(value));
if (valid) strcpy(bit == 0 ? fields.sender : fields.text, value);
else strcpy(reply, "Err - invalid pattern (maximum 63 bytes)");
} else if (bit == 2) {
char range[20];
valid = strlen(value) < sizeof(range);
if (valid) {
strcpy(range, value);
char* separator = strchr(range, '-');
char* plus = strchr(range, '+');
if (separator) *separator++ = 0;
if (plus) *plus = 0;
valid = unsignedValue(range, MAX_PACKET_PAYLOAD, fields.min_payload);
if (valid && separator) valid = !plus
&& unsignedValue(separator, MAX_PACKET_PAYLOAD, fields.max_payload)
&& fields.min_payload <= fields.max_payload;
else if (valid) {
valid = !plus || plus[1] == 0;
fields.max_payload = plus ? MAX_PACKET_PAYLOAD : fields.min_payload;
}
}
if (valid) fields.payload_set = 1;
if (!valid) strcpy(reply, "Err - len is N, N+, or N-M payload bytes");
} else if (bit == 3) {
if (!strcmp(value, "flood") || !strcmp(value, "f")) fields.route = FloodRoute;
else if (!strcmp(value, "direct") || !strcmp(value, "d")) fields.route = DirectRoute;
else if (!strcmp(value, "any") || !strcmp(value, "a")) fields.route = AnyRoute;
else { valid = false; strcpy(reply, "Err - route is flood, direct, or any"); }
} else if (bit == 4) {
uint16_t probability;
valid = unsignedValue(value, 100, probability) && probability != 0;
if (valid) fields.probability = probability == 100 ? 0 : uint8_t(probability);
else strcpy(reply, "Err - prob must be 1-100");
} else {
valid = enabledValue(value, fields.disabled);
if (!valid) strcpy(reply, "Err - enabled is on or off");
}
return valid ? 1 : -1;
}
inline bool routeMatches(const Fields& fields, const mesh::Packet* packet, uint8_t context) {
if (!packet) return false;
if (fields.route == LegacyRoute) return context != 0 || packet->isRouteFlood();
return fields.route == AnyRoute || (fields.route == FloodRoute ? packet->isRouteFlood() : packet->isRouteDirect());
}
inline bool matchesEnvelope(const Fields& fields, const mesh::Packet* packet, uint8_t context) {
return packet && !fields.disabled && routeMatches(fields, packet, context)
&& (!fields.payload_set || (packet->payload_len >= fields.min_payload
&& packet->payload_len <= fields.max_payload));
}
inline bool matchesProbability(const Fields& fields, uint32_t roll) {
return fields.probability == 0 || fields.probability == 100
|| roll % 100U < fields.probability;
}
// A received packet can be evaluated during both timing and forwarding.
// Use one boot-randomized decision per payload/rule/admission phase, stable
// across path growth and scope rewrites. Repeated evaluation cannot reroll it.
template <typename PacketType>
inline uint32_t probabilityRoll(const PacketType* packet, uint32_t seed,
unsigned slot, uint8_t context) {
uint32_t hash = 2166136261UL;
const auto add = [&hash](uint8_t byte) { hash = (hash ^ byte) * 16777619UL; };
for (unsigned i = 0; i < 4; ++i) add(uint8_t(seed >> (8 * i)));
add(uint8_t(slot)); add(context);
if (packet) {
add(packet->getPayloadType());
add(uint8_t(packet->payload_len)); add(uint8_t(packet->payload_len >> 8));
const unsigned length = packet->payload_len <= MAX_PACKET_PAYLOAD
? packet->payload_len : MAX_PACKET_PAYLOAD;
for (unsigned i = 0; i < length; ++i) add(packet->payload[i]);
}
hash ^= hash >> 16;
hash *= 0x85ebca6bUL;
hash ^= hash >> 13;
hash *= 0xc2b2ae35UL;
return hash ^ (hash >> 16);
}
inline int hexValue(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
inline bool parsePath(const char* value, uint8_t& width, uint8_t& hops,
uint8_t first[9], Fields& fields, bool prefix_legacy) {
memset(first, 0, 9);
memset(fields.path_tail, 0, sizeof(fields.path_tail));
width = hops = 0;
fields.path_position = prefix_legacy ? Prefix : Anywhere;
if (!strcmp(value, "*")) { fields.path_position = Prefix; return true; }
bool start = *value == '^';
if (start) ++value;
const size_t size = strlen(value);
bool end = size && value[size - 1] == '$';
const char* finish = value + size - (end ? 1 : 0);
if (start || end) fields.path_position = start ? (end ? Exact : Prefix) : Suffix;
uint8_t bytes[12] = {};
while (value < finish) {
const char* next = value;
while (next < finish && *next != '>' && *next != ',') ++next;
const size_t digits = size_t(next - value);
if ((digits != 2 && digits != 4 && digits != 6) || hops >= 4
|| (width && digits != size_t(width) * 2)) return false;
width = uint8_t(digits / 2);
for (unsigned i = 0; i < width; ++i) {
const int high = hexValue(value[i * 2]), low = hexValue(value[i * 2 + 1]);
if (high < 0 || low < 0) return false;
bytes[hops * width + i] = uint8_t((high << 4) | low);
}
++hops;
if (next == finish) break;
value = next + 1;
if (value == finish) return false;
}
if (!hops) return false;
memcpy(first, bytes, 9);
memcpy(fields.path_tail, bytes + 9, 3);
return true;
}
inline bool pathMatches(const Fields& fields, const mesh::Packet* packet,
uint8_t width, uint8_t hops, const uint8_t first[9]) {
if (!hops) return true; // Existing hash-width-only condition is checked separately.
if (!packet || !packet->isRouteFlood() || packet->getPayloadType() == 9
|| width < 1 || width > 3 || width != packet->getPathHashSize()
|| hops > 4 || packet->getPathHashCount() < hops
|| unsigned(packet->getPathHashCount()) * width > MAX_PATH_SIZE) return false;
const unsigned count = packet->getPathHashCount();
if (fields.path_position == Exact && count != hops) return false;
uint8_t wanted[12];
memcpy(wanted, first, 9);
memcpy(wanted + 9, fields.path_tail, 3);
unsigned begin = fields.path_position == Suffix ? count - hops : 0;
unsigned last = fields.path_position == Anywhere ? count - hops : begin;
for (unsigned at = begin; at <= last; ++at)
if (!memcmp(packet->path + at * width, wanted, hops * width)) return true;
return false;
}
template <typename PacketType, typename Crypto = mesh::Utils>
inline bool matchesContent(const Fields& fields, const PacketType* packet,
const uint8_t* secret, uint8_t key_len,
uint32_t* work = nullptr, bool conservative = false,
bool* budget_exceeded = nullptr) {
if (budget_exceeded) *budget_exceeded = false;
if (!contentRequested(fields)) return true;
if (!packet || !secret || (key_len != 16 && key_len != 32)
|| packet->getPayloadType() != PAYLOAD_TYPE_GRP_TXT
|| packet->payload_len <= PATH_HASH_SIZE + CIPHER_MAC_SIZE) return false;
uint8_t plain[MAX_PACKET_PAYLOAD];
int size = Crypto::MACThenDecrypt(secret, plain,
packet->payload + PATH_HASH_SIZE, packet->payload_len - PATH_HASH_SIZE);
mesh::FloodGroupPlainText::View view;
if (size <= 0 || !mesh::FloodGroupPlainText::parse(
plain, size_t(size), TXT_TYPE_PLAIN, view)) return false;
using mesh::flood::PatternMatchResult;
if (fields.sender[0]) {
if (!view.has_sender) return false;
const auto result = mesh::flood::matchTextPattern(fields.sender, strlen(fields.sender),
view.sender.data, view.sender.size, false, work);
if (result == PatternMatchResult::BudgetExceeded) {
if (budget_exceeded) *budget_exceeded = true;
return conservative;
}
if (result != PatternMatchResult::Match) return false;
}
if (!fields.text[0]) return true;
const auto result = mesh::flood::matchTextPattern(fields.text, strlen(fields.text),
view.text.data, view.text.size, false, work);
if (result == PatternMatchResult::BudgetExceeded && budget_exceeded)
*budget_exceeded = true;
return result == PatternMatchResult::Match
|| (result == PatternMatchResult::BudgetExceeded && conservative);
}
template <typename File>
bool readFields(File& file, Fields& fields) {
memset(&fields, 0, sizeof(fields));
auto read = [&file](void* out, size_t size) {
return file.read(static_cast<uint8_t*>(out), size) == size;
};
uint8_t sender_size = 0, text_size = 0;
return read(&fields.min_payload, 2) && read(&fields.max_payload, 2)
&& read(&fields.route, 1) && read(&fields.probability, 1)
&& read(&fields.path_position, 1) && read(&fields.disabled, 1)
&& read(&fields.payload_set, 1)
&& read(fields.path_tail, 3)
&& read(&sender_size, 1) && sender_size < sizeof(fields.sender)
&& read(fields.sender, sender_size)
&& read(&text_size, 1) && text_size < sizeof(fields.text)
&& read(fields.text, text_size) && validFields(fields);
}
template <typename Writer>
bool writeFields(Writer& write, const Fields& fields) {
uint8_t sender_size = uint8_t(strlen(fields.sender));
uint8_t text_size = uint8_t(strlen(fields.text));
return write(&fields.min_payload, 2) && write(&fields.max_payload, 2)
&& write(&fields.route, 1) && write(&fields.probability, 1)
&& write(&fields.path_position, 1) && write(&fields.disabled, 1)
&& write(&fields.payload_set, 1)
&& write(fields.path_tail, 3)
&& write(&sender_size, 1) && write(fields.sender, sender_size)
&& write(&text_size, 1) && write(fields.text, text_size);
}
template <typename Writer>
void appendQuoted(Writer& writer, const char* key, const char* value) {
writer.append(" %s=\"", key);
for (const char* p = value; *p; ++p) {
if (*p == '"' || *p == '\\') writer.append("\\");
writer.append("%c", *p);
}
writer.append("\"");
}
template <typename Writer>
void appendFields(Writer& writer, const Fields& fields, bool compact) {
if (fields.sender[0]) appendQuoted(writer, compact ? "n" : "sender", fields.sender);
if (fields.text[0]) appendQuoted(writer, compact ? "x" : "text", fields.text);
if (fields.payload_set) {
const char* key = compact ? "l" : "len";
if (fields.max_payload == fields.min_payload) writer.append(" %s=%u", key, unsigned(fields.min_payload));
else if (fields.max_payload == MAX_PACKET_PAYLOAD) writer.append(" %s=%u+", key, unsigned(fields.min_payload));
else writer.append(" %s=%u-%u", key, unsigned(fields.min_payload), unsigned(fields.max_payload));
}
if (fields.route) writer.append(" %s=%s", compact ? "rt" : "route",
fields.route == FloodRoute ? (compact ? "f" : "flood")
: fields.route == DirectRoute ? (compact ? "d" : "direct") : (compact ? "a" : "any"));
if (fields.probability)
writer.append(" %s=%u", compact ? "pr" : "prob", unsigned(fields.probability));
if (fields.disabled) writer.append(" %s=off", compact ? "e" : "enabled");
}
template <typename Writer>
void appendPath(Writer& writer, const Fields& fields, uint8_t width,
uint8_t hops, const uint8_t first[9], bool compact) {
if (!hops) return;
const bool legacy = fields.path_position == Prefix && hops <= 3;
writer.append(" %s=", legacy ? (compact ? "p" : "prefix") : (compact ? "pa" : "path"));
if (!legacy && (fields.path_position == Prefix || fields.path_position == Exact)) writer.append("^");
uint8_t path[12];
memcpy(path, first, 9); memcpy(path + 9, fields.path_tail, 3);
for (unsigned i = 0; i < hops; ++i) {
if (i) writer.append(legacy ? "," : ">");
for (unsigned j = 0; j < width; ++j) writer.append("%02X", unsigned(path[i * width + j]));
}
if (fields.path_position == Suffix || fields.path_position == Exact) writer.append("$");
}
} // namespace FloodRuleExtensions
+319
View File
@@ -0,0 +1,319 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
namespace flood {
// Byte patterns: substring search by default, ^/$ anchors, ., escaped literals,
// classes/ranges/[^...], groups, alternation, and *, +, ? repetition. Matching is
// case-sensitive unless ASCII case folding is explicitly requested. No heap or
// backtracking is used; one budget can be shared across every rule for a packet.
static constexpr size_t MAX_TEXT_PATTERN_LENGTH = 63;
static constexpr uint32_t TEXT_PATTERN_WORK_BUDGET = 32768;
enum class PatternMatchResult : uint8_t {
NoMatch,
Match,
Invalid,
BudgetExceeded,
};
namespace pattern_detail {
static constexpr uint8_t NO_EDGE = 255;
static constexpr uint8_t MAX_STATES = 96;
static constexpr uint8_t MAX_GROUP_DEPTH = 8;
enum Op : uint8_t { Literal, Any, Class, Begin, End, Split, Jump, Accept };
struct State {
uint8_t op, out, other, value;
};
struct Fragment {
uint8_t start, head, tail;
};
// Thompson fragments use unfinished edges as a linked patch list while
// compiling. After compilation all those edges contain ordinary state indices.
class Compiler {
const char* pattern;
size_t length, cursor;
State* states;
uint8_t used, depth;
bool good;
uint8_t emit(Op op, uint8_t value = 0) {
if (used == MAX_STATES) { good = false; return 0; }
const uint8_t index = used++;
states[index] = {op, NO_EDGE, NO_EDGE, value};
return index;
}
uint8_t& edge(uint8_t index) {
return index & 1 ? states[index / 2].other : states[index / 2].out;
}
Fragment single(Op op, uint8_t value = 0) {
const uint8_t index = emit(op, value);
return {index, uint8_t(index * 2), uint8_t(index * 2)};
}
void patch(Fragment fragment, uint8_t target) {
uint8_t link = fragment.head;
while (link != NO_EDGE) {
const uint8_t next = edge(link);
edge(link) = target;
link = next;
}
}
Fragment append(Fragment left, Fragment right) {
edge(left.tail) = right.head;
return {left.start, left.head, right.tail};
}
bool classChar(uint8_t& value) {
if (cursor == length || pattern[cursor] == ']') return false;
value = uint8_t(pattern[cursor++]);
if (value == '\\') {
if (cursor == length) return false;
value = uint8_t(pattern[cursor++]);
}
return true;
}
bool characterClass() {
if (cursor < length && pattern[cursor] == '^') ++cursor;
bool any = false;
while (cursor < length && pattern[cursor] != ']') {
uint8_t first, last;
if (!classChar(first)) return false;
any = true;
if (cursor + 1 < length && pattern[cursor] == '-' && pattern[cursor + 1] != ']') {
++cursor;
if (!classChar(last) || first > last) return false;
}
}
if (!any || cursor == length) return false;
++cursor;
return true;
}
Fragment atom() {
const uint8_t value = uint8_t(pattern[cursor++]);
switch (value) {
case '(' : {
if (++depth > MAX_GROUP_DEPTH) { good = false; return single(Jump); }
Fragment result = expression();
--depth;
if (cursor == length || pattern[cursor++] != ')') good = false;
return result;
}
case '[' : {
const uint8_t offset = uint8_t(cursor);
if (!characterClass()) good = false;
return single(Class, offset);
}
case '\\' :
if (cursor == length) { good = false; return single(Jump); }
return single(Literal, uint8_t(pattern[cursor++]));
case '.' : return single(Any);
case '^' : return single(Begin);
case '$' : return single(End);
case '*' : case '+' : case '?' : case ']' :
good = false;
return single(Jump);
default : return single(Literal, value);
}
}
Fragment sequence() {
Fragment result = {NO_EDGE, NO_EDGE, NO_EDGE};
while (good && cursor < length && pattern[cursor] != ')' && pattern[cursor] != '|') {
const uint8_t first = uint8_t(pattern[cursor]);
Fragment next = atom();
if (!good) return next;
if (good && cursor < length && (pattern[cursor] == '*' || pattern[cursor] == '+' || pattern[cursor] == '?')) {
const char repeat = pattern[cursor++];
if (first == '^' || first == '$') { good = false; break; }
const uint8_t split = emit(Split);
if (!good) return next;
states[split].out = next.start;
const uint8_t tail = uint8_t(split * 2 + 1);
if (repeat == '?') {
const uint8_t start = split;
next = append(next, {start, tail, tail});
next.start = start;
} else {
const uint8_t start = repeat == '*' ? split : next.start;
patch(next, split);
next = {start, tail, tail};
}
// Lazy/stacked quantifiers are deliberately unsupported.
if (cursor < length && (pattern[cursor] == '*' || pattern[cursor] == '+' || pattern[cursor] == '?')) good = false;
}
if (result.start == NO_EDGE) result = next;
else { patch(result, next.start); result.head = next.head; result.tail = next.tail; }
}
return result.start == NO_EDGE ? single(Jump) : result;
}
Fragment expression() {
Fragment result = sequence();
while (good && cursor < length && pattern[cursor] == '|') {
++cursor;
Fragment right = sequence();
if (!good) return right;
const uint8_t split = emit(Split);
if (!good) return result;
states[split].out = result.start;
states[split].other = right.start;
result = append(result, right);
result.start = split;
}
return result;
}
public:
Compiler(const char* text, size_t size, State* output)
: pattern(text), length(size), cursor(0), states(output), used(0), depth(0), good(true) {}
bool compile(uint8_t& start) {
if (!pattern || length > MAX_TEXT_PATTERN_LENGTH) return false;
// NUL is data in the input text, but cannot appear inside a CLI pattern.
if (memchr(pattern, 0, length)) return false;
Fragment fragment = expression();
if (!good || cursor != length) return false;
const uint8_t finish = emit(Accept);
if (!good) return false;
patch(fragment, finish);
start = fragment.start;
return true;
}
};
inline bool spend(uint32_t& budget) {
if (!budget) return false;
--budget;
return true;
}
struct StateList {
uint8_t indices[MAX_STATES];
uint8_t seen[(MAX_STATES + 7) / 8];
uint8_t count;
void clear() { memset(seen, 0, sizeof(seen)); count = 0; }
void push(uint8_t index) {
const uint8_t bit = uint8_t(1U << (index & 7));
if (seen[index / 8] & bit) return;
seen[index / 8] |= bit;
indices[count++] = index;
}
bool add(const State* states, uint8_t start, size_t position, size_t length, uint32_t& budget) {
const uint8_t first = count;
push(start);
for (uint8_t cursor = first; cursor < count; ++cursor) {
if (!spend(budget)) return false;
const State& state = states[indices[cursor]];
switch (state.op) {
case Split: push(state.other); push(state.out); break;
case Jump: push(state.out); break;
case Begin: if (position == 0) push(state.out); break;
case End: if (position == length) push(state.out); break;
default: break;
}
}
return true;
}
};
inline uint8_t fold(uint8_t byte) {
return byte >= 'A' && byte <= 'Z' ? uint8_t(byte + ('a' - 'A')) : byte;
}
inline uint8_t classChar(const char* pattern, uint8_t& cursor) {
uint8_t byte = uint8_t(pattern[cursor++]);
if (byte == '\\') byte = uint8_t(pattern[cursor++]);
return byte;
}
inline int classMatches(const char* pattern, uint8_t cursor, uint8_t byte, bool ignoreCase, uint32_t& budget) {
const bool negate = pattern[cursor] == '^';
if (negate) ++cursor;
bool matched = false;
while (pattern[cursor] != ']') {
if (!spend(budget)) return -1;
const uint8_t first = classChar(pattern, cursor);
uint8_t last = first;
if (pattern[cursor] == '-' && pattern[cursor + 1] != ']') {
++cursor;
last = classChar(pattern, cursor);
}
// Test the original interval and the opposite ASCII case. This keeps
// mixed ranges such as [A-z] faithful when case folding is enabled.
matched |= byte >= first && byte <= last;
if (ignoreCase) {
const uint8_t folded = fold(byte);
matched |= folded >= first && folded <= last;
if (folded >= 'a' && folded <= 'z') {
const uint8_t upper = uint8_t(folded - ('a' - 'A'));
matched |= upper >= first && upper <= last;
}
}
}
return matched != negate;
}
} // namespace pattern_detail
inline bool textPatternValid(const char* pattern, size_t patternLength) {
pattern_detail::State states[pattern_detail::MAX_STATES];
uint8_t start;
return pattern_detail::Compiler(pattern, patternLength, states).compile(start);
}
inline PatternMatchResult matchTextPattern(const char* pattern, size_t patternLength,
const uint8_t* text, size_t textLength,
bool ignoreCase = false, uint32_t* remainingWork = nullptr) {
using namespace pattern_detail;
if (!text && textLength) return PatternMatchResult::Invalid;
State states[MAX_STATES];
uint8_t start;
if (!Compiler(pattern, patternLength, states).compile(start)) return PatternMatchResult::Invalid;
uint32_t localBudget = TEXT_PATTERN_WORK_BUDGET;
uint32_t& budget = remainingWork ? *remainingWork : localBudget;
StateList lists[2];
StateList* current = &lists[0];
StateList* next = &lists[1];
current->clear();
for (size_t position = 0;; ++position) {
// Injecting the start state at each byte provides substring search without
// restarting the automaton or rescanning any input prefix.
if (!current->add(states, start, position, textLength, budget)) return PatternMatchResult::BudgetExceeded;
next->clear();
for (uint8_t cursor = 0; cursor < current->count; ++cursor) {
if (!spend(budget)) return PatternMatchResult::BudgetExceeded;
const State& state = states[current->indices[cursor]];
if (state.op == Accept) return PatternMatchResult::Match;
if (position == textLength) continue;
bool matches = state.op == Any;
if (state.op == Literal) matches = ignoreCase ? fold(text[position]) == fold(state.value) : text[position] == state.value;
else if (state.op == Class) {
const int result = classMatches(pattern, state.value, text[position], ignoreCase, budget);
if (result < 0) return PatternMatchResult::BudgetExceeded;
matches = result != 0;
}
if (matches && !next->add(states, state.out, position + 1, textLength, budget)) return PatternMatchResult::BudgetExceeded;
}
if (position == textLength) return PatternMatchResult::NoMatch;
StateList* old = current; current = next; next = old;
}
}
} // namespace flood
} // namespace mesh
+2 -1
View File
@@ -71,7 +71,8 @@ class ClientAclCliTest(unittest.TestCase):
sources = {role: path.read_text() for role, path in ROLES.items()}
utils = (ROOT / "src/Utils.cpp").read_text()
hex_chars = re.search(r"static const char hex_chars\[\].*;", utils).group()
generated = "namespace mesh {\n" + hex_chars + "\n"
generated = f'#include "{(ROOT / "src/helpers/FloodCommandAliases.h").as_posix()}"\n'
generated += "namespace mesh {\n" + hex_chars + "\n"
generated += extract_braced(utils, "void Utils::toHex(") + "\n"
for signature in ("void Utils::printHex(", "static uint8_t hexVal(",
"bool Utils::isHexChar(", "bool Utils::fromHex("):
+6 -6
View File
@@ -357,7 +357,7 @@ int main() {
"ENABLE_OTA": 1, "OTA_HEAP_CONTEXT": 1,
}, "GEPRC_Linkflow_900_repeater")
self.assertEqual(policy["components"]["client_table"], 32 * 320 + 16)
self.assertEqual(policy["components"]["flood_filter_table"], 63 * 192 + 16)
self.assertEqual(policy["components"]["flood_filter_table"], 63 * 336 + 16)
self.assertEqual(policy["components"]["ota_context"], 16384 + 16)
self.assertGreaterEqual(policy["required_contiguous_bytes"], 16384 + 16)
reduced = ram.requirements("STM32_PLATFORM", {
@@ -589,7 +589,7 @@ int main() {
self.assertEqual(components["display_pixels_and_driver"], 2048)
self.assertEqual(combined["required_heap_bytes"] - legacy["required_heap_bytes"], 3072 - 2048)
self.assertEqual(components["loop_and_callback_stacks"], 8192 + 3072)
self.assertEqual(components["flood_filter_table"], 63 * 192 + 16)
self.assertEqual(components["flood_filter_table"], 63 * 336 + 16)
# An alternate port selects the same bridge instead of allocating two.
alternate = ram.requirements("NRF52_PLATFORM", {
**defines, "RAK4631_COMBINED_ETHERNET": 1,
@@ -700,7 +700,7 @@ int main() {
alias = ram.requirements("ESP32_PLATFORM", {"MESH_CLIENT_REPEATER_ONLY": 1},
"custom_companion_alias")
self.assertEqual(alias["components"]["client_table"], 32 * 284 + 16)
self.assertEqual(alias["components"]["flood_filter_table"], 63 * 192 + 16)
self.assertEqual(alias["components"]["flood_filter_table"], 63 * 336 + 16)
self.assertEqual(alias["components"]["neighbor_history"], 2048 * 9 + 16)
self.assertEqual(alias["components"]["radio_packet_pool"], 10240)
@@ -732,13 +732,13 @@ int main() {
}
full = ram.requirements("ESP32_PLATFORM", defines, "Heltec_v3_repeater_observer_mqtt")
parts = full["components"]
self.assertEqual(full["required_heap_bytes"], 209696)
self.assertEqual(full["required_heap_bytes"], 218768)
self.assertEqual(parts["display_pixels_and_driver"], 2048)
self.assertEqual(parts["uart_bridge_and_driver"], 3072 + 4096)
self.assertEqual(parts["wireless_stacks"], 49152)
self.assertEqual(parts["mqtt_startup_allowance"], 24576)
self.assertEqual(parts["allocation_and_transient_margin"], 16384)
self.assertEqual(full["required_contiguous_bytes"], 18464)
self.assertEqual(full["required_contiguous_bytes"], 21184)
for driver, expected in (("SH1106Display", 2048), ("SH1107Display", 4096),
("ST7735Display", 25602)):
with self.subTest(driver=driver):
@@ -749,7 +749,7 @@ int main() {
self.assertEqual(other["components"], {
**parts, "display_pixels_and_driver": expected,
})
self.assertEqual(other["required_contiguous_bytes"], max(18464, expected))
self.assertEqual(other["required_contiguous_bytes"], max(21184, expected))
def test_sh1106_full_mqtt_release_profile_is_linked_in_ci(self):
workflow = (ROOT / ".github/workflows/run-unit-tests.yml").read_text()
@@ -117,6 +117,8 @@ TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) {
"del flood.filter.blacklist.2", "del flood.filter all",
"get flood.rule.2", "set flood.rule.2 type=any rate=10/min stop",
"del flood.rule.2", "del flood.rule all",
"get fr", "get fr.2", "set fr.2 any c=public q=10 s",
"del fr.2", "del fr all",
"get flood.moderation.2", "set flood.moderation public '*' rate=2/min",
"del flood.moderation all", "get flood.channel.scope.3",
"set flood.channel.scope public scope=usa", "del flood.channel.scope.3",
@@ -246,6 +248,9 @@ TEST_F(FleetCommandTest, NoGenericAdminNamespaceOrPrefixEscapes) {
"set tx.reply both", "get tempradio", "set tempradio 910.5,500,5,5,10",
"get flood.filter.secret", "get flood.filtering", "get flood.rulebook",
"get flood.rule.1.secret", "get flood.filter.blacklistx",
"get fridge", "get fr.secret", "get fr.1.secret", "get fr.0",
"get fr.01", "get fr.256", "get fr.9999999999", "get fr.2 all",
"set frwifi.password secret", "set fr.2", "del fr.2 extra",
"get flood.channel.scope.required", "get flood.channel.data.secret",
"get flood.max.secret", "get flood.max.unscoped.extra", "get flood.rule.0",
"get flood.rule.01", "get flood.rule.256", "get flood.rule.9999999999",
+45
View File
@@ -377,6 +377,51 @@ class FleetCommandCryptoTests(unittest.TestCase):
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), "reject")
def test_filter_shorthand_uses_the_same_signed_management_envelope(self):
for command in ["get fr", "get fr.2", "get fr.2 n", "get flood.rule.2 text",
"set fr.2 any c=public q=10 s",
"del fr.2", "del fr all", "get fm", "get fs.1", "get fsr.1",
"get fb", "set fm.2 public Alice drop", "set fs.2 public scope=local",
"set fsr.2 public", "set fb.2 AABBCC", "del fm.2", "del fs.2",
"del fsr.2", "del fb.2", "get fa", "get fa 2", "get fa key 1",
"clear fa all", "clear flood.advert " + "11" * 32]:
with self.subTest(command=command):
expected = envelope(command)
actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command)
self.assertEqual(actual, expected.hex())
self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\n{command}")
for command in ["get fridge", "get fr.secret", "get fr.1.secret", "get fr.0",
"get fr.01", "get fr.256", "get fr.9999999999", "get fr.2 all",
"set frwifi.password secret", "set fr.2", "del fr.2 extra",
"get fmore", "get fsr.secret", "get fb.0", "set fa off",
"clear fa", "clear fa 0102", "clear fa ALL", "get fa key",
"get fa 0", "get fa 01", "get fa 65536", "get fa key 1 extra"]:
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), "reject")
def test_filter_patterns_preserve_alternation_and_escapes_without_new_command_namespaces(self):
for family in ["fr.4", "flood.rule.4", "flood.filter.4"]:
for fields in [r'sender=^(Alice|Bob)$ text=^BEACON\.[0-9]+$',
r'n="^(Alice|Bob) Smith$" x="^RX in place\?"',
r'n=^A\|B$ x="a\\b"']:
command = f"set {family} any c=public {fields} d"
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), f"{NOW}\n{NOW + 120}\n{command}")
self.assertEqual(self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command),
envelope(command).hex())
for command in [r'set flood.rule.1 any c=#a|reboot d',
r'set fr.1 any c=#a\reboot d', r'set fr.1 any x="a|b" c=#a|reboot d',
r'set fr.1 any n="a|b"\reboot d', r'set fr.1 any n="a|b d',
r'set fr.1 any n="a|b"reboot d', r'set fm.1 public a|b drop',
r'set flood.channel.scope public scope=a|b',
r'set fr.1 any text="a;reboot" d', r'set fr.1 any text="a&b" d',
r'set fr.1 any text="a`b" d', r'get radio2|reboot',
r'set flood.max a|b', r'get fr.1|reboot']:
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), "reject")
def test_scheduled_radio_commands_have_cross_language_real_signatures(self):
for command in [
"set radioat 910.5,500,5,5,+1,auto",
+1
View File
@@ -363,6 +363,7 @@ class FleetRoleWiringTests(unittest.TestCase):
common = body(management, "bool CommonCLI::handleFleetCommand(")
boot = body(body(management, "void CommonCLI::beginManagement("),
"if (_callbacks->supportsFleetControl()")
helpers = f'#include "{(ROOT / "src/helpers/FloodCommandAliases.h").as_posix()}"\n' + helpers
harness = AUTHORIZATION.replace("@MANAGER_HELPERS@", helpers)
harness = harness.replace("@COMMON_FLEET@", common).replace("@MANAGER_GUARD@", guard)
harness = harness.replace("@BOOT_FLEET@", boot)
+469
View File
@@ -0,0 +1,469 @@
"""Execute real moderation decoding and FGM1 saves with reset and I/O faults."""
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
from test_serial_wifi_sessions import SANITIZER_FLAGS
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <helpers/FloodGroupPlainText.h>
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <map>
#include <stdexcept>
#include <string>
#include <vector>
#define PUB_KEY_SIZE 32
#define CIPHER_KEY_SIZE 16
#define CIPHER_BLOCK_SIZE 16
#define CIPHER_MAC_SIZE 2
#define PATH_HASH_SIZE 1
#define MAX_PACKET_PAYLOAD 250
#define TXT_TYPE_PLAIN 0
#define PAYLOAD_TYPE_GRP_TXT 5
#define FLOOD_GROUP_MODERATION_SLOTS 16
#define FLOOD_CHANNEL_KEY_PREFIX_LEN 8
#define FLOOD_GROUP_MODERATION_NAME_LEN 32
#define FLOOD_GROUP_MODERATION_PATH_HOPS_MAX 3
#define FLOOD_GROUP_MODERATION_PATH_BYTES_MAX 9
#define FLOOD_GROUP_MODERATION_HOPS_ALL 255
#define FLOOD_GROUP_MODERATION_RATE_UNLIMITED 65535
#define FLOOD_PACKET_FILTER_MAX_HOPS 63
#define FLOOD_CHANNEL_SCOPE_SLOTS 31
#define FLOOD_RETRY_BRIDGE_BUCKETS 6
#define FLOOD_GROUP_MODERATION_FILE "/flood_grp_mod"
#define FLOOD_GROUP_MODERATION_TEMP_FILE "/flood_grp_mod.tmp"
#define FLOOD_GROUP_MODERATION_BACKUP_FILE "/flood_grp_mod.bak"
#define FILE_O_WRITE 1
#define MESH_DEBUG_PRINTLN(...) ((void)0)
struct PowerCut {};
struct MockFS;
struct File {
MockFS* fs = nullptr;
std::string path;
size_t cursor = 0;
bool valid = false;
explicit operator bool() const { return valid; }
size_t size() const;
size_t read(uint8_t*, size_t);
size_t write(const uint8_t*, size_t);
void close();
void flush();
};
struct MockFS {
std::map<std::string, std::vector<uint8_t>> files;
unsigned operations = 0, cut_after = 0;
bool short_write = false, corrupt_write = false;
std::string fail_open_path, fail_read_path, fail_rename_from;
std::string false_after_rename, noop_rename_from, corrupt_rename_to;
void checkpoint() { if (++operations == cut_after) throw PowerCut{}; }
bool exists(const char* path) { bool found = files.count(path); checkpoint(); return found; }
bool remove(const char* path) { bool removed = files.erase(path); checkpoint(); return removed; }
bool rename(const char* from, const char* to) {
if (fail_rename_from == from) { checkpoint(); return false; }
if (noop_rename_from == from) { checkpoint(); return true; }
if (!files.count(from) || files.count(to)) { checkpoint(); return false; }
files[to] = files[from]; files.erase(from);
if (corrupt_rename_to == to) files[to][0] ^= 0x40;
checkpoint(); return false_after_rename != from;
}
File open(const char* path, const char* mode, bool = false) {
if (fail_open_path == path) { checkpoint(); return {}; }
if (*mode == 'w') files[path].clear();
File file{this, path, 0, files.count(path) != 0}; checkpoint(); return file;
}
File open(const char* path, int mode) { return open(path, mode ? "w" : "r"); }
};
size_t File::size() const { return fs->files.at(path).size(); }
size_t File::read(uint8_t* out, size_t size) {
if (fs->fail_read_path == path) { fs->checkpoint(); return 0; }
size = std::min(size, fs->files[path].size() - cursor);
memcpy(out, fs->files[path].data() + cursor, size); cursor += size;
fs->checkpoint(); return size;
}
size_t File::write(const uint8_t* in, size_t size) {
if (fs->short_write) size /= 2;
auto& bytes = fs->files[path]; bytes.insert(bytes.end(), in, in + size);
if (fs->corrupt_write && size) bytes.back() ^= 0x80;
fs->checkpoint(); return size;
}
void File::close() { fs->checkpoint(); }
void File::flush() { fs->checkpoint(); }
#define FILESYSTEM MockFS
namespace mesh {
struct Packet {
uint8_t payload[MAX_PACKET_PAYLOAD] = {}, payload_len = 19;
uint8_t hops = 2, path[9] = {}, width = 1, type = PAYLOAD_TYPE_GRP_TXT;
bool flood = true;
bool isRouteFlood() const { return flood; }
uint8_t getPayloadType() const { return type; }
uint8_t getPathHashCount() const { return hops; }
uint8_t getPathHashSize() const { return width; }
};
static std::vector<uint8_t> plaintext;
static bool authenticated = true;
struct Utils {
static bool isHexChar(char c) { return (c>='0'&&c<='9')||(c>='A'&&c<='F')||(c>='a'&&c<='f'); }
static bool fromHex(uint8_t* dest, size_t count, const char* text) {
for(size_t i=0;i<count;++i) {
unsigned value=0;if(sscanf(text+i*2,"%2x",&value)!=1)return false;dest[i]=uint8_t(value);
} return true;
}
static void toHex(char* dest, const uint8_t* bytes, size_t size) {
for(size_t i=0;i<size;++i)sprintf(dest+i*2,"%02X",bytes[i]);
}
static void sha256(uint8_t* dest, size_t length, const uint8_t*, size_t) { memset(dest, 0x11, length); }
static int MACThenDecrypt(const uint8_t*, uint8_t* dest, const uint8_t*, size_t) {
if (!authenticated) return 0;
memcpy(dest, plaintext.data(), plaintext.size()); return int(plaintext.size());
}
};
}
struct Clock { uint32_t now = 10; uint32_t getMillis() const { return now; } };
namespace FloodFilterPolicy {
enum {SCOPE_PATH_NONE=0,SCOPE_PATH_BLACKLIST=1,SCOPE_PATH_BRIDGE_BUCKET_BASE=2};
}
static const char FLOOD_CHANNEL_SCOPE_USAGE[]="Err - scope usage";
struct MyMesh {
@ENTRY@
MockFS* _fs;
Clock clock; Clock* _ms = &clock;
FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS] = {};
explicit MyMesh(MockFS& fs) : _fs(&fs) {}
bool readFloodGroupModerationFile(const char*, FloodGroupModerationEntry*, bool* = nullptr) const;
bool recoverFloodGroupModeration();
void loadFloodGroupModeration();
bool saveFloodGroupModeration();
bool decodeFloodGroupPlainText(const mesh::Packet*,const uint8_t*,uint8_t,uint32_t&,char*,size_t) const;
bool decodeFloodGroupPlainTextSpan(const mesh::Packet*,const uint8_t*,uint8_t,uint8_t*,size_t,mesh::FloodGroupPlainText::View&) const;
bool shouldBlockFloodGroupTextForward(const mesh::Packet*);
void setFloodGroupModeration(const char*,char*);
void setFloodChannelScope(const char*,char*);
uint8_t scope_path=0;bool scope_slow=false;
void formatFloodGroupModerationDetail(int,char* reply,size_t size) const { snprintf(reply,size,"> saved"); }
};
namespace StrHelper { void strncpy(char* dest,const char* source,size_t size) { snprintf(dest,size,"%s",source); } }
const uint8_t FLOOD_PUBLIC_CHANNEL_SECRET[16]={};
@SPACES@
@ASCII@
@UNSIGNED@
@MOD_UNSIGNED@
@TOKEN@
@CHANNEL@
@MOD_PATH@
@NAME_VALID@
@SCOPE_INDEX@
static File openFloodSettingsRead(FILESYSTEM* fs, const char* path) { return fs->open(path,"r"); }
@WRITE@
@HASH@
@VERIFY@
@PATH@
@READ@
@RECOVER@
@LOAD@
@SAVE@
@DECODE@
@SPAN@
@BLOCK@
@SET@
@SET_SCOPE@
static const char* target = FLOOD_GROUP_MODERATION_FILE;
static const char* scratch = FLOOD_GROUP_MODERATION_TEMP_FILE;
static const char* backup = FLOOD_GROUP_MODERATION_BACKUP_FILE;
void configure(MyMesh& mesh, const char* name, uint16_t rate = 65535, uint8_t hops = 2) {
auto& entry = mesh.flood_group_moderation[0];
entry = {}; entry.active = true; entry.key_len = 16;
memset(entry.hash_prefix, 0x11, sizeof(entry.hash_prefix));
strcpy(entry.channel_name, "public"); strcpy(entry.sender, name);
entry.max_hops = hops; entry.rate_per_minute = rate;
}
std::vector<uint8_t> plain(const std::string& text) {
std::vector<uint8_t> bytes = {0x12,0x34,0x56,0x78,0};
bytes.insert(bytes.end(),text.begin(),text.end()); return bytes;
}
mesh::Packet packet(const std::string& name) {
mesh::plaintext = plain(name + ": body");
mesh::Packet packet; packet.payload[0] = 0x11;
packet.payload_len = uint8_t(3 + ((mesh::plaintext.size() + 15) / 16) * 16);
mesh::authenticated = true; return packet;
}
std::vector<uint8_t> golden(MyMesh& mesh, unsigned count = 16) {
std::vector<uint8_t> bytes = {'F','G','M','1',uint8_t(count)};
auto append = [&bytes](const void* ptr, size_t size) {
const auto* in = static_cast<const uint8_t*>(ptr); bytes.insert(bytes.end(),in,in+size);
};
for (unsigned i=0;i<count;++i) {
const auto& entry = mesh.flood_group_moderation[i];
bytes.push_back(entry.active); bytes.push_back(entry.key_len);
append(entry.secret,32); append(entry.channel_name,32); append(entry.sender,32);
bytes.push_back(entry.path_hash_size); bytes.push_back(entry.path_hops);
append(entry.path,9); bytes.push_back(entry.max_hops);
bytes.push_back(uint8_t(entry.rate_per_minute)); bytes.push_back(uint8_t(entry.rate_per_minute >> 8));
}
return bytes;
}
void spans() {
using namespace mesh::FloodGroupPlainText;
View view;
for (size_t size : {size_t(1),size_t(31),size_t(32),size_t(200)}) {
auto bytes = plain(std::string(size,'A') + " : body: more");
assert(parse(bytes.data(),bytes.size(),0,view) && view.has_sender);
assert(view.sender.size == size && view.text.size == 10);
assert(std::string((const char*)view.text.data,view.text.size) == "body: more");
}
for (const std::string& text : std::vector<std::string>{"no colon",": empty", " : empty", "Al\nice: body", std::string("A\0lice: body",12)}) {
auto bytes = plain(text); assert(parse(bytes.data(),bytes.size(),0,view) && !view.has_sender);
}
auto bytes = plain("Alice: "); assert(parse(bytes.data(),bytes.size(),0,view));
assert(senderEquals(view,"ALICE") && !senderEquals(view,"AliceX") && view.text.size == 0);
bytes=plain("Alice: body");bytes.resize(32,0);
assert(parse(bytes.data(),bytes.size(),0,view) && view.text.size==4);
bytes[4] = 4; assert(!parse(bytes.data(),bytes.size(),0,view));
for (size_t size=0;size<=5;++size) assert(!parse(bytes.data(),size,0,view));
assert(!parse(nullptr,100,0,view));
}
void moderation() {
MockFS fs; MyMesh mesh(fs); configure(mesh,"*");
for (size_t size : {size_t(31),size_t(32),size_t(200)}) {
auto p = packet(std::string(size,'A')); assert(mesh.shouldBlockFloodGroupTextForward(&p));
p.hops = 1; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
}
configure(mesh,"Alice",0,255);
auto p = packet("aLiCe "); assert(mesh.shouldBlockFloodGroupTextForward(&p));
uint8_t key[32]={}; uint32_t timestamp=0; char sender[32];
assert(mesh.decodeFloodGroupPlainText(&p,key,16,timestamp,sender,sizeof(sender)));
assert(!strcmp(sender,"aLiCe") && timestamp==0x78563412);
configure(mesh,std::string(31,'A').c_str(),0,255);
p=packet(std::string(32,'A')); assert(!mesh.shouldBlockFloodGroupTextForward(&p));
assert(!mesh.decodeFloodGroupPlainText(&p,key,16,timestamp,sender,sizeof(sender)) && !sender[0]);
configure(mesh,"*"); p=packet("Alice"); mesh::authenticated=false;
assert(!mesh.shouldBlockFloodGroupTextForward(&p)); mesh::authenticated=true;
for (const std::string name : {"A\nlice", "", " "}) {
p=packet(name); assert(!mesh.shouldBlockFloodGroupTextForward(&p));
}
p=packet("Alice"); mesh::plaintext=plain("no colon");
assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p=packet("Alice"); p.flood=false; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p.flood=true; p.type=6; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p.type=5; p.payload[0]^=1; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p.payload[0]^=1; p.payload_len-=1; assert(!mesh.shouldBlockFloodGroupTextForward(&p));
p=packet("Alice"); uint8_t too_small[15]; mesh::FloodGroupPlainText::View view;
assert(!mesh.decodeFloodGroupPlainTextSpan(&p,key,16,too_small,sizeof(too_small),view));
}
void formats() {
MockFS fs; MyMesh mesh(fs); configure(mesh,"Alice",3,5);
assert(mesh.saveFloodGroupModeration()); const auto bytes=fs.files[target];
assert(bytes==golden(mesh) && bytes.size()==1797);
for(unsigned count=0;count<=16;++count) {
fs.files[target]=golden(mesh,count); MyMesh boot(fs); boot.loadFloodGroupModeration();
assert(boot.flood_group_moderation[0].active==(count!=0));
}
for(size_t size=0;size<bytes.size();++size) {
fs.files.clear();fs.files[target]={bytes.begin(),bytes.begin()+size};
MyMesh boot(fs);boot.loadFloodGroupModeration();assert(!boot.flood_group_moderation[0].active);
}
for(unsigned invalid : {0U,4U,5U,6U,39U,71U,103U,104U,114U}) {
fs.files.clear();fs.files[target]=bytes;
if(invalid==39||invalid==71) std::fill(fs.files[target].begin()+invalid,fs.files[target].begin()+invalid+32,'X');
else fs.files[target][invalid]=invalid==114?64:0xff;
MyMesh boot(fs);boot.loadFloodGroupModeration();assert(!boot.flood_group_moderation[0].active);
}
fs.files.clear();fs.files[target]=bytes;fs.files[target].push_back(0);
MyMesh boot(fs);boot.loadFloodGroupModeration();assert(!boot.flood_group_moderation[0].active);
}
void failures() {
MockFS base; MyMesh initial(base); configure(initial,"Alice",3,5); assert(initial.saveFloodGroupModeration());
const auto old=base.files[target];
for(unsigned fault=0;fault<9;++fault) {
MockFS fs;fs.files=base.files;MyMesh mesh(fs);configure(mesh,"Bob",7,9);
if(fault==0)fs.short_write=true;
if(fault==1)fs.corrupt_write=true;
if(fault==2)fs.fail_open_path=scratch;
if(fault==3)fs.fail_read_path=scratch;
if(fault==4)fs.fail_rename_from=target;
if(fault==5)fs.fail_rename_from=scratch;
if(fault==6)fs.noop_rename_from=target;
if(fault==7)fs.noop_rename_from=scratch;
if(fault==8)fs.corrupt_rename_to=target;
assert(!mesh.saveFloodGroupModeration());
MockFS reboot;reboot.files=fs.files;MyMesh boot(reboot);boot.loadFloodGroupModeration();
assert(boot.flood_group_moderation[0].active && !strcmp(boot.flood_group_moderation[0].sender,"Alice"));
assert(reboot.files[target]==old);
}
for(const char* path : {target,backup}) {
MockFS fs;fs.files=base.files;if(path==backup){fs.files[backup]=old;fs.files.erase(target);}
fs.fail_read_path=path;MyMesh mesh(fs);configure(mesh,"Bob");
assert(!mesh.saveFloodGroupModeration() && fs.files[path]==old);
}
for(const char* path : {target,scratch}) {
MockFS fs;fs.files=base.files;fs.false_after_rename=path;MyMesh mesh(fs);configure(mesh,"Bob");
assert(mesh.saveFloodGroupModeration());MyMesh boot(fs);boot.loadFloodGroupModeration();
assert(!strcmp(boot.flood_group_moderation[0].sender,"Bob"));
}
}
void recovery() {
MockFS original;MyMesh mesh(original);configure(mesh,"Alice",3,5);assert(mesh.saveFloodGroupModeration());
auto old=original.files[target];configure(mesh,"Bob",7,9);assert(mesh.saveFloodGroupModeration());auto next=original.files[target];
for(unsigned state=0;state<6;++state) {
MockFS fs;
if(state==0){fs.files[target]=old;fs.files[scratch]=next;}
if(state==1){fs.files[backup]=old;fs.files[scratch]=next;}
if(state==2){fs.files[target]={'x'};fs.files[backup]=old;fs.files[scratch]=next;}
if(state==3){fs.files[target]=next;fs.files[backup]=old;}
if(state==4){fs.files[scratch]=next;}
if(state==5){fs.files[backup]=old;fs.fail_rename_from=backup;}
MyMesh boot(fs);boot.loadFloodGroupModeration();
assert(boot.flood_group_moderation[0].active);
assert(!strcmp(boot.flood_group_moderation[0].sender,state==3||state==4?"Bob":"Alice"));
if(state==5)assert(fs.files[backup]==old);
}
MockFS completed;completed.files[target]=old;MyMesh save(completed);configure(save,"Bob",7,9);
assert(save.saveFloodGroupModeration());unsigned steps=completed.operations;
for(unsigned cut=1;cut<=steps;++cut) {
MockFS fs;fs.files[target]=old;fs.cut_after=cut;MyMesh saving(fs);configure(saving,"Bob",7,9);
try {saving.saveFloodGroupModeration();} catch(const PowerCut&) {}
MockFS reboot;reboot.files=fs.files;MyMesh boot(reboot);boot.loadFloodGroupModeration();
assert(boot.flood_group_moderation[0].active);
assert(reboot.files[target]==old||reboot.files[target]==next);
}
}
void cli() {
MockFS first,second;MyMesh longform(first),shortform(second);char reply[160];
struct Case {const char* full;const char* compact;};
for(const Case& item : std::vector<Case>{
{" public Alice rate=3/min hops=5 path=A1B2C3,D4E5F6"," public Alice q=3 h=5 p=A1B2C3,D4E5F6"},
{" public Alice rate=7/min hops=all path=*"," public Alice q=7/min h=all p=*"},
{" public Alice drop path=71"," public Alice d p=71"},
{" public 'Field User' rate=8/m hops=0"," public 'Field User' q=8/m h=0"},
{" public '*' hops=2"," public '*' h=2"}}) {
longform.setFloodGroupModeration(item.full,reply);assert(!strncmp(reply,"OK",2));
shortform.setFloodGroupModeration(item.compact,reply);assert(!strncmp(reply,"OK",2));
assert(first.files[target]==second.files[target]);
}
const auto original=second.files[target];
for(const char* invalid : {" public Alice q=1 rate=2/min", " public Alice rate=1/min q=2",
" public Alice d q=2", " public Alice q=2 drop", " public Alice q=", " public Alice q=-1",
" public Alice q=65535", " public Alice q=7/sec", " public Alice q=1/min/min",
" public Alice h=64", " public Alice p=A1,B2C3 d", " public Alice p= d",
" public Alice d extra", " public Alice rate=7"}) {
shortform.setFloodGroupModeration(invalid,reply);assert(!strncmp(reply,"Err",3));
assert(second.files[target]==original);
}
second.short_write=true;shortform.setFloodGroupModeration(" public Alice q=4",reply);
assert(strstr(reply,"save unconfirmed")&&strstr(reply,"reboot may differ"));
for(const Case& item : std::vector<Case>{
{" public scope=local path=blacklist tx=slow"," public scope=local p=bl tx=s"},
{" public scope=local path=bucket:1 tx=fast"," public scope=local p=b1 tx=f"},
{" public scope=local path=bucket:6"," public scope=local p=b6"}}) {
longform.setFloodChannelScope(item.full,reply);assert(!strcmp(reply,"OK"));
shortform.setFloodChannelScope(item.compact,reply);assert(!strcmp(reply,"OK"));
assert(longform.scope_path==shortform.scope_path&&longform.scope_slow==shortform.scope_slow);
}
for(const char* invalid : {" public scope=local p=b0", " public scope=local p=b7",
" public scope=local p=b", " public scope=local p=b1junk", " public scope=local p=blacklist",
" public scope=local p=bl path=bucket:1", " public scope=local tx=s tx=fast",
" public scope=local tx=x", " public scope=local tx=f path=bucket:01x"}) {
shortform.setFloodChannelScope(invalid,reply);assert(!strncmp(reply,"Err",3));
}
}
int main(int argc,char** argv) {
assert(argc==2);std::string which=argv[1];
if(which=="spans")spans();else if(which=="moderation")moderation();
else if(which=="formats")formats();else if(which=="failures")failures();
else if(which=="recovery")recovery();else if(which=="cli")cli();else assert(false);
}
'''
class FloodModerationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
if not compiler:
raise unittest.SkipTest('native C++ compiler unavailable')
source = (ROOT / 'examples/simple_repeater/MyMesh.cpp').read_text()
header = (ROOT / 'examples/simple_repeater/MyMesh.h').read_text()
entry = re.search(r' struct FloodGroupModerationEntry \{.*?\n \};', header, re.S).group()
harness = HARNESS.replace('@ENTRY@', entry)
methods = {
'WRITE': 'static File openFloodSettingsWrite(',
'HASH': 'static uint32_t updateFloodSettingsHash(',
'VERIFY': 'static bool verifyFloodSettingsWrite(',
'PATH': 'static bool floodModerationPathMatches(',
'READ': 'bool MyMesh::readFloodGroupModerationFile(',
'RECOVER': 'bool MyMesh::recoverFloodGroupModeration(',
'LOAD': 'void MyMesh::loadFloodGroupModeration(',
'SAVE': 'bool MyMesh::saveFloodGroupModeration(',
'DECODE': 'bool MyMesh::decodeFloodGroupPlainText(',
'SPAN': 'bool MyMesh::decodeFloodGroupPlainTextSpan(',
'BLOCK': 'bool MyMesh::shouldBlockFloodGroupTextForward(',
'SPACES': 'static const char* skipFloodFilterSpaces(',
'ASCII': 'static bool floodFilterAsciiEqual(',
'UNSIGNED': 'static bool parseFloodFilterUnsigned(',
'MOD_UNSIGNED': 'static bool parseFloodModerationUnsigned(',
'TOKEN': 'static int takeFloodModerationToken(',
'CHANNEL': 'static bool parseFloodModerationChannel(',
'MOD_PATH': 'static bool parseFloodModerationPath(',
'NAME_VALID': 'static bool floodModerationSenderNameValid(',
'SET': 'void MyMesh::setFloodGroupModeration(',
'SCOPE_INDEX': 'static bool parseFloodChannelScopeIndex(',
}
for marker, signature in methods.items():
harness = harness.replace('@' + marker + '@', method(source[source.rfind(signature):], signature))
# Execute the complete production scope option parser before its
# unrelated region lookup and filesystem mutation phase.
setter = method(source, 'void MyMesh::setFloodChannelScope(')
setter = setter[:setter.index(' bool direct_target =')]
setter += ' scope_path=path_selector;scope_slow=slow_timing;strcpy(reply,"OK");\n}\n'
harness = harness.replace('@SET_SCOPE@', setter)
cls.temp = tempfile.TemporaryDirectory(prefix='meshcore-flood-moderation-')
cls.addClassCleanup(cls.temp.cleanup)
work = Path(cls.temp.name)
test = work / 'moderation.cpp'
test.write_text(harness)
cls.binaries = {}
for platform in ('NRF52', 'STM32', 'ESP32', 'RP2040'):
binary = work / platform
result = subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror',
*SANITIZER_FLAGS, '-D' + platform + '_PLATFORM=1', '-I', str(ROOT / 'src'),
str(test), '-o', str(binary)], capture_output=True, text=True, timeout=60)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
cls.binaries[platform] = binary
def scenario(self, name):
for platform, binary in self.binaries.items():
with self.subTest(platform=platform):
result = subprocess.run([str(binary), name], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_full_length_sender_and_body_views(self):
self.scenario('spans')
def test_wildcard_moderation_cannot_be_bypassed_by_long_sender(self):
self.scenario('moderation')
def test_fgm1_compatibility_and_malformed_images(self):
self.scenario('formats')
def test_failed_saves_preserve_previous_rules_after_reboot(self):
self.scenario('failures')
def test_transaction_recovery_and_every_power_cut_boundary(self):
self.scenario('recovery')
def test_cli_shortforms_match_full_commands_and_preserve_validation(self):
self.scenario('cli')
if __name__ == '__main__':
unittest.main()
+66 -6
View File
@@ -35,20 +35,21 @@ PRELUDE = r'''
namespace mesh {
File openFileRead(FILESYSTEM* fs, const char* path) { return fs->open(path); }
struct Utils {
static void sha256(void*, size_t, const uint8_t*, size_t) {
assert(false && "crypto is outside these capacity fixtures");
static void sha256(void* out, size_t size, const uint8_t*, size_t key_size) {
// Cryptography has separate tests. Only the derived one-byte channel hash
// is required by these real storage paths; preserve the complete key.
assert(size == 1 && (key_size == 16 || key_size == 32));
std::memset(out,0,size);
}
};
}
struct RegionMap {
static bool is_name_char(uint8_t c) { return std::isalnum(c) || c=='_' || c=='-'; }
};
struct StrHelper {
static void strzcpy(char* out, const char* in, size_t size) {
void StrHelper::strzcpy(char* out, const char* in, size_t size) {
if (out!=in) std::memmove(out,in,std::min(size,std::strlen(in)+1));
if (size) out[size-1]=0;
}
};
}
'''
CLASS = r'''
class MyMesh {
@@ -101,6 +102,21 @@ template<class Entry> void canonicalRule(Entry& row) {
row.priority=7;row.stop_on_match=true;row.retry_on_match=true;
row.rate_window_started=0xfedcba98;row.rate_window_count=17;row.rate_window_active=true;
}
template<class Entry> void extendedRule(Entry& row) {
row={};row.active=true;row.payload_type=PAYLOAD_TYPE_GRP_TXT;row.max_hops=63;
row.channel_key_len=16;row.channel_secret[0]=0x42;
std::strcpy(row.channel_name,"Weather");
row.path_hash_size=3;row.path_hops=4;
for(unsigned index=0;index<9;++index)row.path[index]=uint8_t(index+1);
row.drop_on_match=true;
row.extra.min_payload=20;row.extra.max_payload=80;row.extra.payload_set=1;
row.extra.route=FloodRuleExtensions::FloodRoute;
row.extra.probability=17;row.extra.path_position=FloodRuleExtensions::Suffix;
row.extra.disabled=1;
row.extra.path_tail[0]=10;row.extra.path_tail[1]=11;row.extra.path_tail[2]=12;
std::strcpy(row.extra.sender,"^(Alice|Bob) [0-9]+$");
std::strcpy(row.extra.text,"weather (rain|sun)");
}
std::vector<uint8_t> fixture(uint8_t slots, uint8_t highest) {
MemoryFS fs;MyMesh writer(fs,slots);
for (uint8_t index : {uint8_t(0),highest}) {
@@ -120,7 +136,51 @@ std::map<std::string,std::vector<uint8_t>> snapshot(const MemoryFS& fs) {
for (const auto& row:fs.files) result[row.first]=*row.second;
return result;
}
void extendedStorage() {
// Disabled high slots remain allocated and durable. They must neither be
// trimmed from the file nor silently discarded by smaller-capacity builds.
MemoryFS fs;MyMesh writer(fs,63);extendedRule(writer.storage[62]);
assert(writer.saveFloodPacketFilters());
const auto image=fs.get(FLOOD_PACKET_FILTER_FILE);
assert(!std::memcmp(image.data(),"FPF8",4) && image[4]==63);
MyMesh reader(fs,63);assert(reader.loadFloodPacketFilters());
assert(FloodFilterPolicy::sameRuleConfiguration(writer.storage[62],reader.storage[62]));
assert(reader.storage[62].active && reader.storage[62].extra.disabled);
assert(reader.saveFloodPacketFilters() && fs.get(FLOOD_PACKET_FILTER_FILE)==image);
MyMesh smaller(fs,4);assert(!smaller.loadFloodPacketFilters());
assert(smaller.flood_policy_capacity_limited && !smaller.saveFloodPacketFilters());
assert(fs.get(FLOOD_PACKET_FILTER_FILE)==image);
// A room server serializes the same extension and the repeater consumes it.
MemoryFS room_fs;FloodRuleEngine room(room_fs);extendedRule(room.storage[3]);
assert(room.save());assert(!std::memcmp(room_fs.get(FLOOD_PACKET_FILTER_FILE).data(),"FPF8",4));
MyMesh from_room(room_fs,63);assert(from_room.loadFloodPacketFilters());
assert(FloodRuleExtensions::sameFields(room.storage[3].extra,from_room.storage[3].extra));
assert(from_room.storage[3].path_hops==4 && from_room.storage[3].path_hash_size==3);
assert(!std::memcmp(room.storage[3].path,from_room.storage[3].path,9));
// No truncated FPF8 forwarding row may activate a partial policy. The
// optional FPS1 extension follows the rows and has separate legacy rules.
for(size_t length=0;length<image.size()-9;++length) {
MemoryFS cut;cut.put(FLOOD_PACKET_FILTER_FILE,{image.begin(),image.begin()+length});
MyMesh boot(cut,63);assert(boot.loadFloodPacketFilters());
assert(boot.seeded==1 && !boot.flood_policy_capacity_limited);
assert(std::none_of(boot.storage.begin(),boot.storage.end(),[](const auto& row){return row.active;}));
}
// Verify failed staging and publication still leave a complete old or new
// FPF8 policy recoverable, including all extended fields.
for(const char* fault : {"open:w:/flood_filter.tmp", "open:r:/flood_filter.tmp",
"rename:/flood_filter:/flood_filter.bak", "rename:/flood_filter.tmp:/flood_filter"}) {
MemoryFS failed;failed.put(FLOOD_PACKET_FILTER_FILE,image);
MyMesh saving(failed,63);assert(saving.loadFloodPacketFilters());
saving.storage[62].extra.probability=18;failed.faults.insert(fault);
assert(!saving.saveFloodPacketFilters());
failed.faults.clear();MyMesh reboot(failed,63);assert(reboot.loadFloodPacketFilters());
assert(reboot.storage[62].active && reboot.storage[62].extra.disabled);
assert(reboot.storage[62].extra.probability==17 || reboot.storage[62].extra.probability==18);
assert(!std::strcmp(reboot.storage[62].extra.sender,writer.storage[62].extra.sender));
}
}
int main() {
extendedStorage();
// Canonical FPF7 bytes remain independent of the compact in-memory layout.
// Exercise every persisted packed flag plus the addressable retry flag.
{
+201
View File
@@ -0,0 +1,201 @@
"""Exercise the production repeater setter and packet-rule evaluation together."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_flood_rule_extensions import UTILS
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
PRELUDE = r'''
#include <algorithm>
#include <cassert>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <Utils.h>
#include <helpers/FloodRuleCLI.h>
#include <helpers/RegionNameUtils.h>
#define MESH_ENABLE_FLOOD_RULE_ENGINE 1
#define FLOOD_PACKET_FILTER_SCOPE_NAME_LEN 32
#define FLOOD_GROUP_MODERATION_NAME_LEN 32
#define FLOOD_CHANNEL_KEY_PREFIX_LEN 8
#define FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX 9
#define FLOOD_PACKET_FILTER_ANY_TYPE 255
#define FLOOD_PACKET_FILTER_MAX_HOPS 63
#define FLOOD_PACKET_FILTER_SLOTS 63
#define FLOOD_GROUP_MODERATION_RATE_UNLIMITED 65535
#define REGION_DENY_FLOOD 1
static const char FLOOD_PACKET_FILTER_USAGE[]="Err - usage";
static const char FLOOD_PACKET_FILTER_DUPLICATE[]="Err - duplicate";
mesh::Packet::Packet() : header(0), payload_len(0), path_len(0), transport_codes{}, path{}, payload{},
_rssi(0), _snr(0), tx_cr(0), flood_retry_policy(0) {}
static std::vector<uint8_t> decrypted;
static bool authentic = true;
int mesh::Utils::MACThenDecrypt(const uint8_t* secret, uint8_t* out, const uint8_t*, int) {
if (!authentic || secret[0]!=0x42)return 0;
assert(decrypted.size()<=MAX_PACKET_PAYLOAD);
memcpy(out,decrypted.data(),decrypted.size());return int(decrypted.size());
}
struct RegionEntry { const char* name="Town"; uint8_t flags=0; bool isWildcard()const{return false;} };
struct TransportKey { bool isNull()const{return true;} uint16_t calcTransportCode(const mesh::Packet*)const{return 0;} };
struct RegionMap {
static bool is_name_char(uint8_t c){return std::isalnum(c)||c=='_'||c=='-';}
RegionEntry* findByNamePrefix(const char*){return nullptr;}
int getTransportKeysFor(const RegionEntry&,TransportKey*,unsigned){return 0;}
};
void StrHelper::strzcpy(char* out,const char* in,size_t size){if(size)snprintf(out,size,"%s",in);}
void StrHelper::strncpy(char* out,const char* in,size_t size){if(size)snprintf(out,size,"%s",in);}
static void deriveFloodFilterScopeKey(const char*,TransportKey&){}
// Channel cryptography/resolution is separately exercised. The setter still
// performs every production key-vs-hash and content/routing compatibility gate.
static bool parseFloodRuleChannel(const char* text,uint8_t* secret,uint8_t& key_len,
uint8_t* prefix,char* name,size_t capacity) {
memset(secret,0,PUB_KEY_SIZE);memset(prefix,0,FLOOD_CHANNEL_KEY_PREFIX_LEN);
uint8_t hash;
if(FloodFilterPolicy::parseChannelHashMatcher(text,hash)){
key_len=1;secret[0]=hash;prefix[0]=hash;snprintf(name,capacity,"hash:%02X",hash);return true;
}
if(strcmp(text,"#weather")&&strcmp(text,"public"))return false;
key_len=16;secret[0]=0x42;snprintf(name,capacity,"%s",text);return true;
}
class MyMesh {
public:
@ENTRY@;
FloodPacketFilterEntry rows[63]{};
FloodPacketFilterEntry* flood_packet_filters=rows;
uint8_t flood_packet_filter_slots=63,flood_channel_data_rule_slot=255;
uint32_t flood_filter_probability_seed=0x12345678;
RegionMap region_map;
bool save_ok=true;
unsigned saves=0;
void setFloodPacketFilter(const char*,char*,bool require_explicit_action=false);
bool saveFloodPacketFilters(){++saves;return save_ok;}
void formatFloodPacketFilterDetail(int,char* out,size_t size,bool=false)const{snprintf(out,size,"> saved");}
bool isAnyTempRadioActive()const{return false;}
bool floodPacketFilterBlacklistMatches(const mesh::Packet*)const{return false;}
bool floodPacketFilterFieldsMatch(const FloodPacketFilterEntry&,const mesh::Packet*,bool,uint16_t,bool,const RegionEntry*,uint8_t)const;
bool authenticateFloodPacketFilterChannel(const FloodPacketFilterEntry&,const mesh::Packet*)const;
bool resolveFloodPacketFilterTargetRegion(const char*,TransportKey&,const char*&){return false;}
uint64_t applyFloodPacketFilterStop(uint64_t);
uint64_t evaluateFloodPacketFilterMatches(const mesh::Packet*,bool,const RegionEntry*,uint8_t);
};
'''
SCENARIOS = r'''
static void set(MyMesh& mesh,const char* args,bool expected=true,bool strict=true){
struct Reply {uint8_t before[8];char text[160];uint8_t after[8];} reply;
memset(&reply,0xa5,sizeof(reply));
mesh.setFloodPacketFilter(args,reply.text,strict);
assert(!strncmp(reply.text,expected?"OK":"Err",expected?2:3));
for(uint8_t byte:reply.before)assert(byte==0xa5);
for(uint8_t byte:reply.after)assert(byte==0xa5);
}
static void plain(const std::string& text){
decrypted.assign(5,0);decrypted.insert(decrypted.end(),text.begin(),text.end());decrypted.push_back(0);
}
static void parserAndRollback(){
MyMesh mesh;
set(mesh,R"(.1 grp_txt channel=#weather sender="^(Alice|Bob) [0-9]+$" text='weather (rain|sun)' len=20-80 route=flood prob=25 path=AA>BB$ drop)");
const auto original=mesh.rows[0];
assert(original.active&&original.drop_on_match&&original.path_hops==2&&original.extra.path_position==FloodRuleExtensions::Suffix);
assert(original.extra.probability==25&&original.extra.payload_set&&original.extra.min_payload==20&&original.extra.max_payload==80);
set(mesh,R"(.2 t=grp_txt c=#weather n="^(Alice|Bob) [0-9]+$" x='weather (rain|sun)' l=20-80 rt=f pr=25 pa=AA>BB$ d)");
assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],mesh.rows[1]));
set(mesh,".1 enabled=off");assert(mesh.rows[0].active&&mesh.rows[0].extra.disabled);
auto disabled=original;disabled.extra.disabled=1;
assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],disabled));
const auto saves=mesh.saves;
set(mesh,".1 off");assert(mesh.saves==saves);
mesh.flood_channel_data_rule_slot=0;
mesh.save_ok=false;
set(mesh,".1 e=on",false);assert(mesh.rows[0].extra.disabled);
assert(mesh.flood_channel_data_rule_slot==0);
mesh.save_ok=true;
set(mesh,".1 e=on");assert(mesh.flood_channel_data_rule_slot==255);
mesh.save_ok=false;
set(mesh,".1 any route=direct drop",false);
assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],original));
mesh.save_ok=true;set(mesh,".1 on");assert(!mesh.rows[0].extra.disabled);
assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],original));
for(const char* invalid:{".3 grp_txt c=hash:00 n=Alice drop",".3 grp_txt n=Alice drop",
".3 advert c=#weather n=Alice drop",".3 grp_txt c=#weather n=[ drop",
".3 any route=direct scope=Town", ".3 any route=any retry",
".3 any l=1-2 len=2-3 drop", ".3 any pa=AA>BB>CC>DD>EE drop",
".3 any", ".3 off", ".0 any drop", ".64 any drop"}) {
unsigned previous=mesh.saves;set(mesh,invalid,false);assert(mesh.saves==previous&&!mesh.rows[2].active);
}
set(mesh,".3 any route=direct drop");assert(mesh.rows[2].extra.route==FloodRuleExtensions::DirectRoute);
set(mesh,".4 any",true,false);assert(mesh.rows[3].drop_on_match);
}
static void allowAndDirect(){
MyMesh mesh;
set(mesh,".1 grp_txt c=#weather n=^Alice$ a pri=2 rt=a");
set(mesh,".2 grp_txt c=#weather d pri=1 rt=a");
mesh::Packet packet;packet.header=ROUTE_TYPE_FLOOD|(PAYLOAD_TYPE_GRP_TXT<<PH_TYPE_SHIFT);packet.payload_len=19;
assert(mesh.rows[0].stop_on_match&&!mesh.rows[0].drop_on_match&&mesh.rows[1].drop_on_match);
plain("Alice: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==1);
plain("Bob: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==2);
plain(std::string(140,'x')+"Alice: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==2);
packet.header=ROUTE_TYPE_DIRECT|(PAYLOAD_TYPE_GRP_TXT<<PH_TYPE_SHIFT);
plain("Alice: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==1);
plain("Bob: hello");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==2);
set(mesh,".2 off");assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==0);
set(mesh,".2 on");authentic=false;
assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==0);authentic=true;
// Existing radio rules keep their flood-only behavior until route is stated.
MyMesh legacy;set(legacy,".1 any drop");
assert(legacy.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==0);
set(legacy,".1 any rt=d drop");assert(legacy.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==1);
}
static void budgetExhaustion(){
MyMesh mesh;
for(unsigned slot=1;slot<=63;++slot){
char command[128];snprintf(command,sizeof(command),".%u grp_txt c=#weather x=^(a|aa)*b$ d",slot);set(mesh,command);
}
mesh::Packet packet;packet.header=ROUTE_TYPE_FLOOD|(PAYLOAD_TYPE_GRP_TXT<<PH_TYPE_SHIFT);packet.payload_len=19;
plain("Alice: "+std::string(160,'a'));
// A whole-packet work limit returns the reserved suppression bit rather
// than accidentally permitting a packet after a partial rule evaluation.
assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==(uint64_t(1)<<63));
}
int main(){parserAndRollback();allowAndDirect();budgetExhaustion();}
'''
class FloodRuleCliTests(unittest.TestCase):
def test_actual_repeater_parser_and_evaluator(self):
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text()
parts = [PRELUDE.replace("@ENTRY@", extract_braced(header, "struct FloodPacketFilterEntry"))]
for marker in ("static const char* skipFloodFilterSpaces(", "static bool floodFilterAsciiEqual(",
"static bool floodFilterAsciiStartsWith(", "static bool parseFloodFilterUnsigned(",
"static bool parseFloodFilterPayloadType(", "static bool parseFloodFilterHopSpec(",
"static bool normalizeFloodFilterScopeName("):
parts.append(extract_braced(source, marker))
marker = "static bool parseFloodModerationUnsigned("
parts.append(extract_braced(source[source.rindex(marker):], marker))
for marker in ("void MyMesh::setFloodPacketFilter(", "bool MyMesh::floodPacketFilterFieldsMatch(",
"bool MyMesh::authenticateFloodPacketFilterChannel(", "uint64_t MyMesh::applyFloodPacketFilterStop(",
"uint64_t MyMesh::evaluateFloodPacketFilterMatches("):
parts.append(extract_braced(source, marker))
parts.append(SCENARIOS)
with tempfile.TemporaryDirectory(prefix="flood-rule-cli-") as folder:
folder = Path(folder)
(folder / "Utils.h").write_text(UTILS)
cpp = folder / "cli.cpp"
cpp.write_text("\n".join(parts))
binary = folder / "cli"
flags = []
if os.name != "nt":
flags = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
subprocess.run(["g++", "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", *flags,
"-I" + str(folder), "-I" + str(ROOT / "src"), str(cpp), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()
+307
View File
@@ -0,0 +1,307 @@
"""Run the production extended-rule parser, matching, and wire-field helpers."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
UTILS = r'''#pragma once
#include <stddef.h>
#include <stdint.h>
namespace mesh { struct Utils {
static int MACThenDecrypt(const uint8_t*, uint8_t*, const uint8_t*, int);
}; }
'''
PROGRAM = r'''
#include <algorithm>
#include <cassert>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <Utils.h>
#include <helpers/FloodRuleExtensions.h>
using namespace FloodRuleExtensions;
mesh::Packet::Packet() : header(0), payload_len(0), path_len(0), transport_codes{}, path{}, payload{},
_rssi(0), _snr(0), tx_cr(0), flood_retry_policy(0) {}
static std::vector<uint8_t> plaintext;
static unsigned decrypts;
static bool decrypt_ok = true;
int mesh::Utils::MACThenDecrypt(const uint8_t* secret, uint8_t* out, const uint8_t*, int) {
++decrypts;
if (!decrypt_ok || secret[0] != 0x42) return 0;
assert(plaintext.size() <= MAX_PACKET_PAYLOAD);
memcpy(out, plaintext.data(), plaintext.size());
return int(plaintext.size());
}
struct StringWriter {
std::string text;
void append(const char* format, ...) {
char buffer[512];
va_list args; va_start(args, format);
int n = vsnprintf(buffer, sizeof(buffer), format, args); va_end(args);
assert(n >= 0 && n < int(sizeof(buffer)));
text.append(buffer, size_t(n));
}
};
static Fields parse(const char* command) {
char buffer[512], reply[160] = {};
assert(strlen(command) < sizeof(buffer));
strcpy(buffer, command);
char* tokens[24];
int count = tokenize(buffer, tokens, 24);
assert(count >= 0);
Fields fields{}; uint8_t seen = 0;
for (int i = 0; i < count; ++i) assert(parseToken(tokens[i], fields, seen, reply) == 1);
assert(validFields(fields));
return fields;
}
static void parsing() {
Fields normal = parse(R"(sender="^(Alice|Bob) [0-9]+$" text='[Ww]eather: rain?' len=0-60 route=any prob=25 enabled=off)");
Fields compact = parse(R"(n="^(Alice|Bob) [0-9]+$" x='[Ww]eather: rain?' l=0-60 rt=a pr=25 e=0)");
assert(sameFields(normal, compact));
assert(normal.payload_set && normal.min_payload == 0 && normal.max_payload == 60);
assert(normal.route == AnyRoute && normal.probability == 25 && normal.disabled);
assert(contentRequested(normal) && hasFields(normal));
Fields empty{};
assert(validFields(empty) && !hasFields(empty) && !contentRequested(empty));
assert(sameFields(parse("prob=100"), empty));
Fields terminator = empty;
memset(terminator.sender, 'a', sizeof(terminator.sender));
assert(!validFields(terminator));
terminator = empty; memset(terminator.text, 'a', sizeof(terminator.text));
assert(!validFields(terminator));
for (const char* token : {"sender=", "text=", "sender=[", "text=a**", "len=", "len=-1", "len=3-2",
"len=185", "len=184-185", "len=1+2", "len=1+-2", "len=1-2+", "len=1--2", "len=99999999999999999999",
"route=bad", "prob=0", "prob=101", "prob=-1", "prob=50%", "enabled=yes"}) {
Fields row{}; uint8_t seen = 0; char reply[160] = {};
assert(parseToken(token, row, seen, reply) == -1 && strncmp(reply, "Err -", 5) == 0);
}
const std::string oversized = "sender=" + std::string(64, 'a');
uint8_t seen = 0; char reply[160] = {}; Fields row{};
assert(parseToken(oversized.c_str(), row, seen, reply) == -1);
seen = 0;
assert(parseToken("sender=Alice", row, seen, reply) == 1);
assert(parseToken("n=Bob", row, seen, reply) == -1 && !strcmp(row.sender, "Alice"));
assert(parseToken("mode=radio", row, seen, reply) == 0);
assert(parseToken("drop", row, seen, reply) == 0);
char malformed[] = "text=\"unterminated"; char* tokens[2];
assert(tokenize(malformed, tokens, 2) == -1);
char excess[] = "a b c"; assert(tokenize(excess, tokens, 2) == -1);
char whitespace[] = "\t "; assert(tokenize(whitespace, tokens, 2) == 0);
for (const char* syntax : {"len=0", "l=0-0"}) {
const auto zero = parse(syntax);
mesh::Packet packet; packet.header = ROUTE_TYPE_FLOOD;
assert(matchesEnvelope(zero, &packet, 0));
packet.payload_len = 1; assert(!matchesEnvelope(zero, &packet, 0));
}
for (bool abbreviated : {false, true}) {
for (const char* syntax : {R"(text='has "quote"')", R"(text='has \"quote\"')",
R"(text='\\server\ folder')", R"(text="it\'s both")"}) {
Fields source = parse(syntax);
StringWriter writer; appendFields(writer, source, abbreviated);
Fields restored = parse(writer.text.c_str());
for (const char* sample : {"has \"quote\"", "has quote", "\\server folder", "it's both", "unrelated"}) {
using mesh::flood::matchTextPattern;
const auto* bytes = reinterpret_cast<const uint8_t*>(sample);
assert(matchTextPattern(source.text, strlen(source.text), bytes, strlen(sample))
== matchTextPattern(restored.text, strlen(restored.text), bytes, strlen(sample)));
}
}
StringWriter writer; appendFields(writer, normal, abbreviated);
assert(sameFields(normal, parse(writer.text.c_str())));
}
}
static void envelopesAndProbability() {
Fields row{};
assert(!matchesEnvelope(row, nullptr, 0));
for (uint8_t route = 0; route < 4; ++route) {
mesh::Packet packet; packet.header = route; packet.payload_len = 44;
const bool flood = packet.isRouteFlood();
assert(matchesEnvelope(row, &packet, 0) == flood);
assert(matchesEnvelope(row, &packet, 1));
row.route = FloodRoute; assert(matchesEnvelope(row, &packet, 0) == flood);
row.route = DirectRoute; assert(matchesEnvelope(row, &packet, 0) == !flood);
row.route = AnyRoute; assert(matchesEnvelope(row, &packet, 0));
row.payload_set = 1; row.min_payload = 44; row.max_payload = 44;
assert(matchesEnvelope(row, &packet, 0));
packet.payload_len = 43; assert(!matchesEnvelope(row, &packet, 0));
packet.payload_len = 45; assert(!matchesEnvelope(row, &packet, 0));
packet.payload_len = 44; row.disabled = 1; assert(!matchesEnvelope(row, &packet, 0));
row = {};
}
for (unsigned probability = 0; probability <= 100; ++probability) {
row.probability = uint8_t(probability);
unsigned selected = 0;
for (uint32_t roll = 0; roll < 10000; ++roll) selected += matchesProbability(row, roll);
assert(selected == (probability ? probability * 100 : 10000));
}
}
static void paths() {
mesh::Packet packet; packet.header = ROUTE_TYPE_FLOOD;
packet.setPathHashSizeAndCount(1, 5);
const uint8_t bytes[] = {0x11, 0x22, 0x33, 0x44, 0x55}; memcpy(packet.path, bytes, sizeof(bytes));
for (const char* syntax : {"22>33", "22,33"}) {
Fields row{}; uint8_t width, hops, first[9];
assert(parsePath(syntax, width, hops, first, row, false));
assert(width == 1 && hops == 2 && row.path_position == Anywhere);
assert(pathMatches(row, &packet, width, hops, first));
row.path_position = Prefix; assert(!pathMatches(row, &packet, width, hops, first));
row.path_position = Suffix; assert(!pathMatches(row, &packet, width, hops, first));
}
for (const char* syntax : {"^11>22", "44>55$", "^11>22>33>44$"}) {
Fields row{}; uint8_t width, hops, first[9];
assert(parsePath(syntax, width, hops, first, row, false));
assert(pathMatches(row, &packet, width, hops, first) == (row.path_position != Exact));
}
for (unsigned width = 1; width <= 3; ++width) {
Fields row{}; uint8_t parsedWidth, hops, first[9];
StringWriter wanted;
for (unsigned hop = 0; hop < 4; ++hop) {
if (hop) wanted.append(">");
for (unsigned byte = 0; byte < width; ++byte) wanted.append("%02X", 0x11 + hop * width + byte);
}
assert(parsePath(wanted.text.c_str(), parsedWidth, hops, first, row, false));
assert(parsedWidth == width && hops == 4);
packet.setPathHashSizeAndCount(uint8_t(width), 6);
memset(packet.path, 0x99, sizeof(packet.path));
memcpy(packet.path + width, first, std::min(9U, 4 * width));
if (width == 3) memcpy(packet.path + width + 9, row.path_tail, 3);
assert(pathMatches(row, &packet, parsedWidth, hops, first));
for (bool compact : {false, true}) {
StringWriter writer; appendPath(writer, row, parsedWidth, hops, first, compact);
const char* syntax = strchr(writer.text.c_str(), '=') + 1;
Fields copy{}; uint8_t copiedWidth, copiedHops, copiedFirst[9];
assert(parsePath(syntax, copiedWidth, copiedHops, copiedFirst, copy, false));
assert(copiedWidth == parsedWidth && copiedHops == hops && sameFields(row, copy));
assert(!memcmp(first, copiedFirst, 9));
}
packet.header = ROUTE_TYPE_DIRECT; assert(!pathMatches(row, &packet, parsedWidth, hops, first));
packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT);
assert(!pathMatches(row, &packet, parsedWidth, hops, first));
packet.header = ROUTE_TYPE_FLOOD;
packet.setPathHashSizeAndCount(uint8_t(width), 63);
if (width > 1) assert(!pathMatches(row, &packet, parsedWidth, hops, first));
}
for (const char* invalid : {"", "^", "$", "^$", "AA>", ">AA", "AA>>BB", "GG", "A", "AAAAA", "AA>BBBB", "AA>BB>CC>DD>EE"}) {
Fields row{}; uint8_t width, hops, first[9];
assert(!parsePath(invalid, width, hops, first, row, false));
}
Fields row{}; uint8_t width, hops, first[9];
assert(parsePath("*", width, hops, first, row, false) && !width && !hops);
assert(pathMatches(row, &packet, width, hops, first));
}
static void content() {
uint8_t key[32] = {0x42};
mesh::Packet packet; packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); packet.payload_len = 35;
Fields row = parse(R"(sender=^Alice$ text='weather (sun|rain)')");
const auto setPlain = [](const std::string& text) {
plaintext.assign(5, 0); plaintext.insert(plaintext.end(), text.begin(), text.end()); plaintext.push_back(0);
};
setPlain("Alice: weather rain");
assert(matchesContent(row, &packet, key, 16));
assert(matchesContent(row, &packet, key, 32));
uint32_t work = 1000;
assert(matchesContent(row, &packet, key, 16, &work));
assert(work > 0 && work < 1000);
const uint32_t afterFirst = work;
assert(matchesContent(row, &packet, key, 16, &work) && work < afterFirst);
work = 0;
assert(!matchesContent(row, &packet, key, 16, &work, false));
assert(matchesContent(row, &packet, key, 16, &work, true));
bool exhausted = false;
assert(!matchesContent(row, &packet, key, 16, &work, false, &exhausted) && exhausted);
// An allow rule cannot become an unconditional exemption when work runs
// out. A deny rule may conservatively suppress uncertain authenticated data.
Fields textOnly = row; textOnly.sender[0] = 0;
assert(!matchesContent(textOnly, &packet, key, 16, &work, false));
assert(matchesContent(textOnly, &packet, key, 16, &work, true));
exhausted = false;
assert(!matchesContent(textOnly, &packet, key, 16, &work, false, &exhausted) && exhausted);
work = 1000;
assert(matchesContent(textOnly, &packet, key, 16, &work, false, &exhausted) && !exhausted);
assert(!matchesContent(row, &packet, key, 1));
assert(!matchesContent(row, &packet, nullptr, 16));
assert(!matchesContent(row, static_cast<const mesh::Packet*>(nullptr), key, 16));
decrypt_ok = false;
assert(!matchesContent(row, &packet, key, 16));
assert(!matchesContent(row, &packet, key, 16, &work, true));
exhausted = true;
assert(!matchesContent(row, &packet, key, 16, &work, false, &exhausted) && !exhausted);
decrypt_ok = true;
setPlain("alice: weather rain"); assert(!matchesContent(row, &packet, key, 16));
setPlain("Alice: dry"); assert(!matchesContent(row, &packet, key, 16));
setPlain("weather rain"); assert(!matchesContent(row, &packet, key, 16));
row.sender[0] = 0; assert(matchesContent(row, &packet, key, 16));
setPlain(std::string(140, 'x') + "Alice: weather sun");
strcpy(row.sender, "Alice$"); assert(matchesContent(row, &packet, key, 16));
plaintext[4] = TXT_TYPE_CLI_COMMAND << 2; assert(!matchesContent(row, &packet, key, 16));
packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
assert(!matchesContent(row, &packet, key, 16));
row = {}; decrypts = 0;
assert(matchesContent(row, static_cast<const mesh::Packet*>(nullptr), nullptr, 0) && decrypts == 0);
}
struct ByteReader {
std::vector<uint8_t> bytes; size_t position = 0;
size_t read(uint8_t* out, size_t size) {
size = std::min(size, bytes.size() - position);
if (size) memcpy(out, bytes.data() + position, size);
position += size; return size;
}
};
static void storage() {
Fields source = parse(R"(sender=Alice text='rain storm' len=0-80 route=direct prob=33 enabled=off)");
source.path_position = Suffix; source.path_tail[2] = 0x99;
std::vector<uint8_t> bytes;
auto writer = [&bytes](const void* data, size_t size) {
const auto* first = static_cast<const uint8_t*>(data); bytes.insert(bytes.end(), first, first + size); return true;
};
assert(writeFields(writer, source));
const std::vector<uint8_t> expected = {0, 0, 80, 0, DirectRoute, 33, Suffix, 1, 1, 0, 0, 0x99,
5, 'A', 'l', 'i', 'c', 'e', 10, 'r', 'a', 'i', 'n', ' ', 's', 't', 'o', 'r', 'm'};
assert(bytes == expected);
ByteReader full{bytes}; Fields loaded{};
assert(readFields(full, loaded) && full.position == bytes.size() && sameFields(source, loaded));
for (size_t length = 0; length < bytes.size(); ++length) {
ByteReader shortRead{{bytes.begin(), bytes.begin() + length}};
assert(!readFields(shortRead, loaded));
}
const size_t senderLength = 12; // Four uint16 bytes, five control bytes, three tail bytes.
assert(bytes[senderLength] == strlen(source.sender));
for (const auto& change : std::vector<std::pair<size_t, uint8_t>>{{4, 4}, {5, 101}, {6, 4}, {7, 2}, {8, 2}, {senderLength, 64}, {senderLength + 1, '*'}}) {
ByteReader corrupt{bytes}; corrupt.bytes[change.first] = change.second;
assert(!readFields(corrupt, loaded));
}
unsigned successfulCalls = 0;
auto counter = [&successfulCalls](const void*, size_t) { ++successfulCalls; return true; };
assert(writeFields(counter, source));
for (unsigned failure = 0; failure < successfulCalls; ++failure) {
unsigned calls = 0;
auto broken = [&calls, failure](const void*, size_t) { return calls++ != failure; };
assert(!writeFields(broken, source));
}
}
int main() { parsing(); envelopesAndProbability(); paths(); content(); storage(); }
'''
class FloodRuleExtensionsTests(unittest.TestCase):
def test_real_parser_matching_and_storage(self):
with tempfile.TemporaryDirectory(prefix="flood-rule-extensions-") as folder:
folder = Path(folder)
(folder / "Utils.h").write_text(UTILS)
source = folder / "extensions.cpp"
source.write_text(PROGRAM)
binary = folder / "extensions"
flags = []
if os.name != "nt":
flags = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
subprocess.run(["g++", "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", *flags,
"-I" + str(folder), "-I" + str(ROOT / "src"), str(source), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()
+21 -3
View File
@@ -24,8 +24,8 @@ static constexpr uint8_t NAME_LEN = 32, PATH_PREFIX_BYTES_MAX = 9;
'''
SCENARIOS = r'''
static_assert(sizeof(RepeaterEntry) == 192 && sizeof(RoomEntry) == 192,
"63 repeater rows / 31 room rows must retain the 192-byte bound");
static_assert(sizeof(RepeaterEntry) == 332 && sizeof(RoomEntry) == 332,
"extended rows retain the reviewed 332-byte RAM bound");
static_assert(sizeof(LegacyEntry) == 40, "retain legacy STM32 rule layout");
static_assert(alignof(RepeaterEntry) >= alignof(uint32_t));
static_assert(alignof(RoomEntry) >= alignof(uint32_t));
@@ -95,7 +95,7 @@ template<class Entry> void exerciseConfigurationEquality() {
assert(FloodFilterPolicy::sameRuleConfiguration(original, live));
// Bytewise struct comparisons are forbidden: neither alignment padding nor
// live counters identify a rule. Poison trailing padding to enforce this.
for (size_t offset = offsetof(Entry, transport_modes) + 1; offset < sizeof(Entry); ++offset)
for (size_t offset = offsetof(Entry, extra) + sizeof(live.extra); offset < sizeof(Entry); ++offset)
reinterpret_cast<unsigned char*>(&live)[offset] = 0xa5;
assert(FloodFilterPolicy::sameRuleConfiguration(original, live));
// Every configured field must still distinguish two rows. Arrays include
@@ -133,6 +133,24 @@ template<class Entry> void exerciseConfigurationEquality() {
assert(!FloodFilterPolicy::sameRuleConfiguration(original, changed));
assert(!FloodFilterPolicy::sameRuleConfiguration(changed, original));
}
for (unsigned field = 0; field < 11; ++field) {
Entry changed = original;
switch (field) {
case 0: changed.extra.min_payload = 1; break;
case 1: changed.extra.max_payload = 1; break;
case 2: changed.extra.payload_set = 1; break;
case 3: changed.extra.route = FloodRuleExtensions::DirectRoute; break;
case 4: changed.extra.probability = 30; break;
case 5: changed.extra.path_position = FloodRuleExtensions::Anywhere; break;
case 6: changed.extra.disabled = 1; break;
case 7: changed.extra.path_tail[2] = 1; break;
case 8: std::strcpy(changed.extra.sender, "Alice"); break;
case 9: std::strcpy(changed.extra.text, "weather"); break;
case 10: changed.extra.path_tail[0] = 1; break;
}
assert(!FloodFilterPolicy::sameRuleConfiguration(original, changed));
assert(!FloodFilterPolicy::sameRuleConfiguration(changed, original));
}
}
template<class Rules, class Entry> void exerciseProductionSlotSelection() {
Rules rules;
+221
View File
@@ -0,0 +1,221 @@
"""Exercise the production bounded matcher, including hostile regex-like input."""
from pathlib import Path
import os
import random
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
PROGRAM = r'''
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#include "FloodTextPattern.h"
using namespace mesh::flood;
static bool forbidAllocation = false;
__attribute__((noinline)) void* operator new(size_t size) {
assert(!forbidAllocation);
void* result = malloc(size ? size : 1);
if (!result) std::abort();
return result;
}
__attribute__((noinline)) void* operator new[](size_t size) { return ::operator new(size); }
__attribute__((noinline)) void operator delete(void* pointer) noexcept { free(pointer); }
__attribute__((noinline)) void operator delete[](void* pointer) noexcept { free(pointer); }
static_assert(sizeof(pattern_detail::State) == 4, "small NFA instructions");
static_assert(sizeof(pattern_detail::State) * pattern_detail::MAX_STATES
+ 2 * sizeof(pattern_detail::StateList) <= 640, "bounded matching scratch");
static PatternMatchResult match(const char* pattern, const char* text, bool fold = false) {
forbidAllocation = true;
const auto result = matchTextPattern(pattern, strlen(pattern), reinterpret_cast<const uint8_t*>(text), strlen(text), fold);
forbidAllocation = false;
return result;
}
static void checks() {
using R = PatternMatchResult;
assert(match("", "") == R::Match);
assert(match("Alice", "a message from Alice today") == R::Match);
assert(match("^Alice$", "Alice") == R::Match);
assert(match("^Alice$", "alice") == R::NoMatch);
assert(match("^Alice$", "alice", true) == R::Match);
assert(match("^Alice$", "Alice2") == R::NoMatch);
assert(match("^Alice$", "2Alice") == R::NoMatch);
assert(match("Alice$", "Alice\n") == R::NoMatch);
assert(match("A.*e", "A\ne") == R::Match);
assert(match("^a\\.b\\*\\[\\]\\(\\)\\|\\+\\?\\^\\$\\\\$", "a.b*[]()|+?^$\\") == R::Match);
assert(match("^[A-F0-9]+$", "aBcF09", true) == R::Match);
assert(match("^[A-F0-9]+$", "aBcF09") == R::NoMatch);
assert(match("^[^A-F]+$", "xyz", true) == R::Match);
assert(match("^[^A-F]+$", "xyzA", true) == R::NoMatch);
assert(match("^[A-z]+$", "[_Zaz", true) == R::Match);
assert(match("^[\\]\\-\\[]+$", "]-[") == R::Match);
assert(match("^[a-]+$", "a--") == R::Match);
assert(match("^[-a]+$", "a--") == R::Match);
assert(match("^(Alice|Bob)[0-9]?$", "Bob3") == R::Match);
assert(match("^(Alice|Bob)[0-9]?$", "Bob33") == R::NoMatch);
assert(match("^(ab)*c+$", "ababc") == R::Match);
assert(match("^(ab)+c?$", "") == R::NoMatch);
assert(match("^(ab)*c?$", "") == R::Match);
assert(match("^(a|ab)*b$", "aabab") == R::Match);
assert(match("^(()*)*$", "") == R::Match); // epsilon cycles terminate
assert(match("^(a|)*$", "aaaa") == R::Match);
for (const char* invalid : {"[", "[]", "[^]", "[z-a]", "[a-\\]]", "\\", "(", ")", "a)",
"a**", "a+?", "a??", "*a", "^*", "$+", "a]"}) {
assert(!textPatternValid(invalid, strlen(invalid)));
assert(match(invalid, "anything") == R::Invalid);
}
assert(!textPatternValid(nullptr, 0));
assert(matchTextPattern("a", 1, nullptr, 1) == R::Invalid);
assert(matchTextPattern("", 0, nullptr, 0) == R::Match);
const char nulPattern[] = {'a', 0, 'b'};
assert(!textPatternValid(nulPattern, sizeof(nulPattern)));
const uint8_t nulText[] = {'a', 0, 'b'};
assert(matchTextPattern("^a.b$", 5, nulText, 3) == R::Match);
assert(matchTextPattern("^a$", 3, nulText, 3) == R::NoMatch);
const std::string maximum(MAX_TEXT_PATTERN_LENGTH, 'a');
assert(textPatternValid(maximum.data(), maximum.size()));
assert(!textPatternValid(maximum.data(), maximum.size() + 1));
assert(!textPatternValid("(((((((((a)))))))))", 19));
assert(textPatternValid("((((((((a))))))))", 17));
const std::string tooManyStates(MAX_TEXT_PATTERN_LENGTH, '|');
assert(!textPatternValid(tooManyStates.data(), tooManyStates.size()));
const std::string longSender = std::string(200, 'x') + "Alice";
assert(match("Alice$", longSender.c_str()) == R::Match);
assert(match("^x+$", longSender.c_str()) == R::NoMatch);
uint32_t budget = 0;
assert(matchTextPattern("a", 1, reinterpret_cast<const uint8_t*>("a"), 1, false, &budget) == R::BudgetExceeded);
assert(budget == 0);
budget = 100;
assert(matchTextPattern("a", 1, reinterpret_cast<const uint8_t*>("a"), 1, false, &budget) == R::Match);
assert(budget < 100 && budget > 0);
const uint32_t afterFirst = budget;
assert(matchTextPattern("b", 1, reinterpret_cast<const uint8_t*>("b"), 1, false, &budget) == R::Match);
assert(budget < afterFirst);
const std::string hostile(100000, 'a');
budget = 500;
assert(matchTextPattern("(a|aa)*b", 8, reinterpret_cast<const uint8_t*>(hostile.data()), hostile.size(), false, &budget) == R::BudgetExceeded);
assert(budget == 0);
// This pattern is exponential with a backtracking engine. The NFA finishes
// within the default fixed budget for the largest packet-sized payload.
const std::string packet(255, 'a');
assert(match("^(a|aa)*b$", packet.c_str()) == R::NoMatch);
}
static std::vector<uint8_t> unhex(const std::string& text) {
std::vector<uint8_t> result;
if (text == "-") return result;
for (size_t index = 0; index < text.size(); index += 2) {
result.push_back(uint8_t(std::stoul(text.substr(index, 2), nullptr, 16)));
}
return result;
}
int main() {
checks();
std::string patternHex, textHex;
bool ignoreCase;
while (std::cin >> patternHex >> textHex >> ignoreCase) {
const auto pattern = unhex(patternHex);
const auto text = unhex(textHex);
// Exact-sized allocations let ASan detect an accidental C-string read.
char* rawPattern = new char[pattern.size() ? pattern.size() : 1];
uint8_t* rawText = new uint8_t[text.size() ? text.size() : 1];
if (!pattern.empty()) memcpy(rawPattern, pattern.data(), pattern.size());
if (!text.empty()) memcpy(rawText, text.data(), text.size());
const auto result = matchTextPattern(rawPattern, pattern.size(), rawText, text.size(), ignoreCase);
std::cout << int(result) << '\n';
delete[] rawPattern;
delete[] rawText;
}
}
'''
ARM_PROGRAM = r'''
#include "FloodTextPattern.h"
extern "C" __attribute__((noinline)) mesh::flood::PatternMatchResult packet_match(
const char* pattern, size_t patternLength, const uint8_t* text, size_t textLength, uint32_t* budget) {
return mesh::flood::matchTextPattern(pattern, patternLength, text, textLength, false, budget);
}
'''
class FloodTextPatternTests(unittest.TestCase):
def test_packet_patterns_bounds_and_reference_agreement(self):
rng = random.Random(436)
patterns = ["", "a", "^a$", "a|b", "^(ab|a)*b$", "a?b+c*", "[ab]+", "[^a]*b$", "[A-z]+"]
atoms = ["a", "b", "c", ".", "[ab]", "[^c]", "[a-c]", "[0-2]", r"\.", "(ab|c)", "(a|)"]
for _ in range(300):
pattern = "^" if rng.randrange(2) else ""
for _ in range(rng.randrange(1, 5)):
pattern += rng.choice(atoms) + rng.choice(["", "", "?", "*", "+"])
if rng.randrange(2):
pattern += "$"
patterns.append(pattern)
cases = []
for pattern in patterns:
for _ in range(15):
text = "".join(rng.choice("abcABC012.") for _ in range(rng.randrange(21)))
fold = bool(rng.randrange(2))
expected = bool(re.search(pattern, text, re.DOTALL | (re.IGNORECASE | re.ASCII if fold else 0)))
cases.append((pattern, text, fold, expected))
encoded = "".join(f"{pattern.encode().hex() or '-'} {text.encode().hex() or '-'} {int(fold)}\n"
for pattern, text, fold, _ in cases)
# Deterministic malformed-input fuzzing exercises validation and patch
# list bounds without assuming Python supports exactly our byte syntax.
fuzz = ["".join(rng.choice("abc^$.*+?()[\\]|-") for _ in range(rng.randrange(1, 64)))
for _ in range(4000)]
encoded += "".join(f"{pattern.encode().hex()} 616263 0\n" for pattern in fuzz)
with tempfile.TemporaryDirectory() as folder:
folder = Path(folder)
source = folder / "patterns.cpp"
source.write_text(PROGRAM)
binary = folder / "patterns"
flags = []
if os.name != "nt":
flags = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
subprocess.run(["g++", "-std=c++11", "-O1", "-Wall", "-Wextra", "-Werror", *flags,
"-I" + str(ROOT / "src/helpers"), str(source), "-o", str(binary)], check=True)
result = subprocess.run([str(binary)], input=encoded, text=True, capture_output=True, check=True, timeout=30)
results = result.stdout.splitlines()
self.assertEqual(len(results), len(cases) + len(fuzz))
for value, case in zip(results, cases):
self.assertEqual(int(value), int(case[3]), case)
self.assertTrue(all(value in ("0", "1", "2") for value in results[len(cases):]))
def test_arm_stack_is_bounded(self):
compiler = shutil.which("arm-none-eabi-g++")
if not compiler:
packages = Path.home() / ".platformio/packages"
candidates = sorted(packages.glob("toolchain-gccarmnoneeabi*/bin/arm-none-eabi-g++*"))
candidates = [path for path in candidates if path.suffix in ("", ".exe")]
if candidates:
compiler = str(candidates[0])
if not compiler:
self.skipTest("ARM compiler unavailable")
with tempfile.TemporaryDirectory() as folder:
folder = Path(folder)
source = folder / "patterns.cpp"
source.write_text(ARM_PROGRAM)
subprocess.run([compiler, "-std=c++11", "-mcpu=cortex-m4", "-mthumb", "-Os", "-fstack-usage",
"-I" + str(ROOT / "src/helpers"), "-c", str(source), "-o", str(folder / "patterns.o")], check=True)
frames = {}
for line in (folder / "patterns.su").read_text().splitlines():
name, size, kind = line.rsplit("\t", 2)
self.assertEqual(kind, "static", line)
frames[name] = int(size)
self.assertTrue(frames)
# Compiler recursion is limited to eight groups. Conservatively
# count each parser frame at every level, plus runtime scratch.
recursive = sum(size for name, size in frames.items()
if any(token in name for token in ("::atom(", "::sequence(", "::expression(")))
other = max(size for name, size in frames.items()
if not any(token in name for token in ("::atom(", "::sequence(", "::expression(")))
self.assertLessEqual(recursive * 9 + other + 64, 2048, frames)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -133,6 +133,7 @@ class MorseAuthorizationTests(unittest.TestCase):
"static bool commandFamilyMatches(", "static bool isCommonManagerReadOnlyAllowed(",
"static bool isRegionMgrAllowed(", "static bool isFilterMgrAllowed(",
))
helpers = f'#include "{(ROOT / "src/helpers/FloodCommandAliases.h").as_posix()}"\n' + helpers
code = HARNESS.replace("@MANAGER_HELPERS@", helpers).replace("@PORTABLE_HANDOFF@", handoff)
for extended_filters in (0, 1):
with self.subTest(extended_filters=extended_filters):
+266
View File
@@ -0,0 +1,266 @@
"""Run the complete production FULL-room rule engine over host I/O boundaries."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
UTILS = r'''#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
namespace mesh {
inline std::vector<uint8_t> test_plaintext;
inline bool test_decrypt_ok=false;
struct RNG { virtual ~RNG() = default; virtual void random(uint8_t*, size_t) = 0; };
struct Utils {
static void sha256(uint8_t* out, size_t size, const uint8_t*, size_t) { memset(out, 0, size); }
static bool isHexChar(char c) { return (c>='0'&&c<='9')||(c>='a'&&c<='f')||(c>='A'&&c<='F'); }
static unsigned nibble(char c) { return c<='9' ? c-'0' : (c|32)-'a'+10; }
static bool fromHex(uint8_t* out, int size, const char* text) {
if (strlen(text)!=size_t(size)*2) return false;
for (int i=0;i<size;++i) {
if (!isHexChar(text[i*2])||!isHexChar(text[i*2+1])) return false;
out[i]=uint8_t(nibble(text[i*2])*16+nibble(text[i*2+1]));
}
return true;
}
static void toHex(char* out,const uint8_t* bytes,size_t size) {
const char* digits="0123456789ABCDEF";
for(size_t i=0;i<size;++i) {out[i*2]=digits[bytes[i]>>4];out[i*2+1]=digits[bytes[i]&15];}
out[size*2]=0;
}
// Explicit crypto boundary: only the budget test supplies authenticated
// plaintext. These tests do not claim to verify ciphertext authentication.
static int MACThenDecrypt(const uint8_t*,uint8_t* out,const uint8_t*,int) {
if(!test_decrypt_ok)return 0;
memcpy(out,test_plaintext.data(),test_plaintext.size());return int(test_plaintext.size());
}
};
}
'''
BOUNDARIES = r'''#pragma once
#include <algorithm>
#include <cassert>
#include <cctype>
#include <map>
#include <memory>
#include <set>
#include <string>
#include <vector>
#include <Packet.h>
#include <Utils.h>
#define TXT_TYPE_PLAIN 0
class MemoryFS;
class File {
MemoryFS* owner=nullptr;
std::shared_ptr<std::vector<uint8_t>> bytes;
size_t cursor=0;
public:
File()=default;
File(MemoryFS* fs,std::shared_ptr<std::vector<uint8_t>> data):owner(fs),bytes(data){}
operator bool() const {return bool(bytes);}
bool isDirectory() const {return false;}
size_t size() const {return bytes ? bytes->size() : 0;}
size_t available() const {return size()-cursor;}
size_t read(uint8_t* out,size_t count) {
count=std::min(count,available());
if(count) memcpy(out,bytes->data()+cursor,count);
cursor+=count;return count;
}
size_t write(const uint8_t*,size_t);
void close() {bytes.reset();}
};
class MemoryFS {
public:
std::map<std::string,std::shared_ptr<std::vector<uint8_t>>> files;
std::set<std::string> faults;
bool exists(const char* path) const {return files.count(path)!=0;}
bool remove(const char* path) {return files.erase(path)!=0;}
bool rename(const char* from,const char* to) {
if(faults.count(std::string("rename:")+from+":"+to)||!exists(from)||exists(to))return false;
files[to]=files[from];files.erase(from);return true;
}
File open(const char* path,const char* mode="r",bool=false) {
if(faults.count(std::string("open:")+mode+":"+path))return {};
if(mode[0]=='w')files[path]=std::make_shared<std::vector<uint8_t>>();
return exists(path)?File(this,files[path]):File();
}
};
inline size_t File::write(const uint8_t* data,size_t count) {
if(!bytes)return 0;
bytes->insert(bytes->end(),data,data+count);return count;
}
using FILESYSTEM=MemoryFS;
struct TransportKey {
uint8_t key[16]{};
bool isNull() const {for(auto byte:key)if(byte)return false;return true;}
uint16_t calcTransportCode(const mesh::Packet*) const {return 0;}
};
#define REGION_DENY_FLOOD 1
struct RegionEntry {uint8_t flags=0;char name[32]{};bool isWildcard()const{return false;}};
struct RegionMap {
static bool is_name_char(uint8_t c) {return std::isalnum(c)||c=='_'||c=='-';}
RegionEntry* findByName(const char*) {return nullptr;}
RegionEntry* findByNamePrefix(const char*) {return nullptr;}
int getTransportKeysFor(const RegionEntry&,TransportKey*,int) {return 0;}
};
'''
PROGRAM = r'''
#include "FloodRuleEngine.h"
#include <cstdio>
mesh::Packet::Packet():header(ROUTE_TYPE_FLOOD),payload_len(0),path_len(0),
transport_codes{},path{},payload{},_rssi(0),_snr(0),tx_cr(0),flood_retry_policy(0){}
struct TestRng:mesh::RNG {
uint32_t value=0;unsigned calls=0;
void random(uint8_t* out,size_t size) override {assert(size==4);memcpy(out,&value,4);++calls;}
};
static MemoryFS disk;
static RegionMap regions;
static TestRng rng;
static FloodRuleEngine rules;
static char reply[160];
void command(const char* text,bool success=true) {
assert(rules.handleCommand(text,reply));
assert((strncmp(reply,"Err",3)!=0)==success);
}
bool blocked(mesh::Packet& packet,uint32_t now=0,bool commit=false) {
auto matches=rules.evaluate(&packet,false,true,nullptr);
bool result=rules.shouldBlock(&packet,matches,now);
if(!result&&commit)rules.commitRates(&packet,matches,now);
return result;
}
void reboot() {rules.begin(&disk,&regions,&rng);}
uint32_t seedFor(const mesh::Packet& packet,unsigned slot,unsigned wanted) {
for(uint32_t seed=0;;++seed)
if(FloodRuleExtensions::probabilityRoll(&packet,seed,slot,0)%100==wanted)return seed;
}
int main() {
reboot();command("del fr all");
assert(!memcmp(disk.files.at("/flood_filter")->data(),"FPF7",4));
mesh::Packet packet;packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_FLOOD;
packet.payload_len=40;
const uint32_t stable=FloodRuleExtensions::probabilityRoll(&packet,12345,3,0);
packet.path[0]=0xAB;packet.setPathHashSizeAndCount(3,1);packet.transport_codes[0]=0x1234;
packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_TRANSPORT_FLOOD;
assert(stable==FloodRuleExtensions::probabilityRoll(&packet,12345,3,0));
packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_FLOOD;
assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12346,3,0));
assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12345,4,0));
assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12345,3,1));
packet.payload[0]=1;assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12345,3,0));
packet.payload[0]=0;
command("set flood.rule.4 type=advert len=30-50 prob=25 drop");
assert(!memcmp(disk.files.at("/flood_filter")->data(),"FPF8",4));
rng.value=seedFor(packet,3,24);reboot();assert(blocked(packet));
const unsigned draws=rng.calls;
for(unsigned i=0;i<20;++i)assert(blocked(packet));
assert(rng.calls==draws);
rng.value=seedFor(packet,3,25);reboot();assert(!blocked(packet));
packet.payload_len=29;assert(!blocked(packet));packet.payload_len=51;assert(!blocked(packet));
packet.payload_len=40;rng.value=seedFor(packet,3,0);reboot();assert(blocked(packet));
command("set fr.4 off");assert(!blocked(packet));reboot();assert(!blocked(packet));
command("set fr.4 e=on");assert(blocked(packet));
command("set fr.4 enabled=off");assert(!blocked(packet));
command("get fr.4");assert(strstr(reply,"e=off"));
command("set fr.4 enabled=on");assert(blocked(packet));
command("set fr.5 any rt=d d");packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_DIRECT;
assert(blocked(packet));packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_FLOOD;
assert(!blocked(packet));
command("set fr.5 any rt=d h=2+ d");packet.setPathHashSizeAndCount(1,3);
packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_DIRECT;assert(!blocked(packet));
command("set fr.5 any rt=d s=test",false);
command("del fr all");
packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_FLOOD;
command("set fr.4 any pa=AABB>CCDD$ d");
packet.setPathHashSizeAndCount(2,3);
const uint8_t path[]={0,1,0xAA,0xBB,0xCC,0xDD};memcpy(packet.path,path,sizeof(path));
assert(blocked(packet));packet.path[0]=0xAA;packet.path[1]=0xBB;
packet.path[2]=0xCC;packet.path[3]=0xDD;packet.path[4]=0;packet.path[5]=1;
assert(!blocked(packet));
command("set fr.4 any pa=^AA>BB>CC>DD d");packet.setPathHashSizeAndCount(1,4);
const uint8_t four[]={0xAA,0xBB,0xCC,0xDD};memcpy(packet.path,four,4);
assert(blocked(packet));reboot();assert(blocked(packet));
assert(!memcmp(disk.files.at("/flood_filter")->data(),"FPF8",4));
command("get fr.4");assert(strstr(reply,"pa=^AA>BB>CC>DD"));
command("set fr.4 any c=public n=\"^(Alice|Bob) Smith$\" x=\"^RX in place\" a");
command("get fr.4 n");assert(!strcmp(reply,"> ^(Alice|Bob) Smith$"));
command("get flood.rule.4 text");assert(!strcmp(reply,"> ^RX in place"));
reboot();command("get fr.4 x");assert(!strcmp(reply,"> ^RX in place"));
command("set fr.4 any c=hash:11 n=Alice d",false);
command("set fr.4 any n=Alice d",false);
command("set fr.4 any c=public n=\"unterminated d",false);
command("get fr.4 n");assert(!strcmp(reply,"> ^(Alice|Bob) Smith$"));
command("set fr.31 advert d");
disk.faults.insert("open:w:/flood_filter.tmp");command("del fr all",false);
command("get flood.rule.31");assert(strstr(reply,"advert"));
command("set fr.31 off",false);command("get fr.31");assert(!strstr(reply,"e=off"));
disk.faults.clear();command("del fr all");
command("set fr.4 advert q=1");
packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_FLOOD;
assert(!blocked(packet,0,true));assert(blocked(packet,1));
command("set fr.4 off");assert(!blocked(packet,2,true));
command("set fr.4 on");assert(blocked(packet,3));
assert(!blocked(packet,60000));
command("del fr all");command("set fr.4 any l=0 d");
packet.payload_len=0;assert(blocked(packet));packet.payload_len=1;assert(!blocked(packet));
command("del fr all");
for(unsigned slot=1;slot<=31;++slot) {
char text[256];
snprintf(text,sizeof(text),"set fr.%u any c=public x=(a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a)*z q=1 s=test stop",slot);
command(text);
}
packet.header=uint8_t(PAYLOAD_TYPE_GRP_TXT<<2)|ROUTE_TYPE_FLOOD;
packet.payload_len=179;packet.payload[0]=0;
mesh::test_plaintext.assign(176,'a');
memset(mesh::test_plaintext.data(),0,5);
memcpy(mesh::test_plaintext.data()+5,"U: ",3);
mesh::test_decrypt_ok=true;
const uint32_t exhausted=rules.evaluate(&packet,false,true,nullptr);
assert(exhausted==(uint32_t(1)<<31));
assert(rules.shouldBlock(&packet,exhausted,0));
bool scope_set=false,fast_track=false;
assert(!rules.applyScope(&packet,exhausted,scope_set,fast_track));
assert(!scope_set&&!fast_track);
rules.commitRates(&packet,exhausted,0);
mesh::test_decrypt_ok=false;
assert(!rules.handleCommand("get fridge",reply));
command("get fb",false);
puts("FULL-room filter parser, persistence, route, path, probability and rollback passed");
}
'''
class RoomFilterExtensionTests(unittest.TestCase):
def test_complete_production_engine(self):
with tempfile.TemporaryDirectory(prefix="room-filter-extensions-") as directory:
work = Path(directory)
(work / "Utils.h").write_text(UTILS)
(work / "boundaries.h").write_text(BOUNDARIES)
(work / "RoomServerFeatures.h").write_text("#define MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE 1\n")
header = (ROOT / "examples/simple_room_server/FloodRuleEngine.h").read_text()
for include in ("Arduino.h", "Mesh.h", "helpers/IdentityStore.h", "helpers/RegionMap.h"):
header = header.replace(f"#include <{include}>", "")
(work / "FloodRuleEngine.h").write_text('#include "boundaries.h"\n' + header)
(work / "FloodRuleEngine.cpp").write_text(
(ROOT / "examples/simple_room_server/FloodRuleEngine.cpp").read_text())
(work / "test.cpp").write_text(PROGRAM)
executable = work / ("test.exe" if os.name == "nt" else "test")
sanitizer = [] if os.name == "nt" else [
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
built = subprocess.run(["g++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra",
"-Wno-unused-variable", *sanitizer, "-I", str(work), "-I", str(ROOT / "src"),
str(work / "test.cpp"), str(work / "FloodRuleEngine.cpp"), "-o", str(executable)],
capture_output=True, text=True, timeout=90)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
run = subprocess.run([str(executable)], capture_output=True, text=True, timeout=20)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
if __name__ == "__main__":
unittest.main()
+93 -3
View File
@@ -206,6 +206,76 @@ int main() {
'''
class TransportFilterWiring(unittest.TestCase):
def test_aliases_dispatch_the_original_suffix_without_expanding_a_command_buffer(self):
source = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
start = source.index(' if (commandFamilyMatches(command, "get flood.channel.scope.require"))')
end = source.index('#if MESH_ENABLE_CLOCK_SYNC', start)
dispatch = source[start:end]
helper = method(source, 'static bool commandFamilyMatches(')
code = (f'#include "{(ROOT/"src/helpers/FloodCommandAliases.h").as_posix()}"\n'
'#include <cassert>\n#include <cstring>\n#include <initializer_list>\n') + helper + r'''
static const char* actual_args;
static const char* actual_action;
#define STUB(name) static void name(const char* args,char* reply) {actual_args=args;actual_action=#name;strcpy(reply,"OK");}
STUB(formatFloodChannelScopeRequirements)
STUB(setFloodChannelScopeRequirement)
STUB(deleteFloodChannelScopeRequirement)
STUB(formatFloodChannelScopes)
STUB(setFloodChannelScope)
STUB(deleteFloodChannelScope)
STUB(formatFloodPacketFilterBlacklist)
STUB(setFloodPacketFilterBlacklist)
STUB(deleteFloodPacketFilterBlacklist)
STUB(deleteFloodPacketFilter)
STUB(formatFloodGroupModeration)
STUB(setFloodGroupModeration)
STUB(deleteFloodGroupModeration)
static void formatFloodPacketFilters(const char* args,char* reply,bool=false) {actual_args=args;actual_action="formatFloodPacketFilters";strcpy(reply,"OK");}
static void setFloodPacketFilter(const char* args,char* reply,bool=false) {actual_args=args;actual_action="setFloodPacketFilter";strcpy(reply,"OK");}
void run(const char* command,char* reply) {
@DISPATCH@
else strcpy(reply,"unhandled");
}
int main() {
struct Test {const char* command;const char* suffix;const char* action;};
for(const auto& row : {
Test{"get fm.2",".2","formatFloodGroupModeration"},
Test{"set fm.2 public Alice drop",".2 public Alice drop","setFloodGroupModeration"},
Test{"del fm.2",".2","deleteFloodGroupModeration"},
Test{"get fs.2",".2","formatFloodChannelScopes"},
Test{"set fs.2 public scope=local",".2 public scope=local","setFloodChannelScope"},
Test{"del fs.2",".2","deleteFloodChannelScope"},
Test{"get fsr.2",".2","formatFloodChannelScopeRequirements"},
Test{"set fsr.2 public",".2 public","setFloodChannelScopeRequirement"},
Test{"del fsr.2",".2","deleteFloodChannelScopeRequirement"},
Test{"get fb.2",".2","formatFloodPacketFilterBlacklist"},
Test{"set fb.2 AABBCC",".2 AABBCC","setFloodPacketFilterBlacklist"},
Test{"del fb.2",".2","deleteFloodPacketFilterBlacklist"},
Test{"get fr.2 n",".2 n","formatFloodPacketFilters"},
Test{"set fr.2 any n=Alice d",".2 any n=Alice d","setFloodPacketFilter"},
Test{"del fr.2",".2","deleteFloodPacketFilter"},
Test{"set flood.rule.2 any n=Alice d",".2 any n=Alice d","setFloodPacketFilter"}}) {
char reply[160]{};
actual_args=nullptr;actual_action=nullptr;
run(row.command,reply);
assert(!strcmp(reply,"OK") && actual_args && actual_action);
assert(!strcmp(actual_args,row.suffix) && !strcmp(actual_action,row.action));
assert(actual_args>=row.command && actual_args<=row.command+strlen(row.command));
}
for(const char* command : {"get fmore","get fsrs","get fbwifi.password","get fridge"}) {
char reply[160]{};run(command,reply);assert(!strcmp(reply,"unhandled"));
}
}
'''
code = code.replace('@DISPATCH@', dispatch)
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
(work/'test.cpp').write_text(code)
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-DMESH_ENABLE_FLOOD_RULE_ENGINE=1', '-DMESH_ENABLE_FLOOD_GROUP_MODERATION=1',
str(work/'test.cpp'), '-o', str(work/'test')], check=True)
subprocess.run([str(work/'test')], check=True)
def test_temporary_filter_suspension_checks_both_profiles_in_all_role_paths(self):
repeater = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
suspension = re.findall(r'entry\.suspend_on_temp_radio && (\w+)\(\)', repeater)
@@ -219,14 +289,34 @@ class TransportFilterWiring(unittest.TestCase):
helpers = '\n'.join(method(source, signature) for signature in [
'static bool commandFamilyMatches(', 'static bool isCommonManagerReadOnlyAllowed(',
'static bool isRegionMgrAllowed(', 'static bool isFilterMgrAllowed('])
code = '#include <cstring>\n#include <cassert>\n#include <initializer_list>\n' + helpers + r'''
code = (f'#include "{(ROOT/"src/helpers/FloodCommandAliases.h").as_posix()}"\n'
'#include <cstring>\n#include <cassert>\n#include <initializer_list>\n') + helpers + r'''
int main() {
for (const char* cmd : {"get fr", "get fr.3", "set fr.3 any m=bc d", "del fr.3", "del fr all"}) {
for (const char* cmd : {"get fr", "get fr.3", "set fr.3 any m=bc d", "del fr.3", "del fr all",
"set fr.3 any c=public sender=^Alice$ text=BEACON len=1-80 prob=50 drop",
"set flood.rule.3 type=grp_txt channel=public sender=^Alice$ text=BEACON drop",
"set flood.rule.3 type=any route=direct path=^AABB>CCDD$ enabled=off stop"}) {
assert(isFilterMgrAllowed(cmd) == (MESH_ENABLE_FLOOD_RULE_ENGINE != 0));
assert(!isRegionMgrAllowed(cmd));
}
for (const char* cmd : {"get fridge", "set frwifi.password secret", "get wifi.password", "del frx"})
for (const char* cmd : {"get fridge", "set frwifi.password secret", "get wifi.password", "del frx",
"set frwifi.password sender=^Alice$", "set flood.rulebook type=any drop",
"get fleet.channel", "set fleet.controller off"})
assert(!isFilterMgrAllowed(cmd));
for (const char* cmd : {"get fm", "set fm.2 public Alice drop", "del fm.2",
"get fb", "set fb.2 AABBCC", "del fb.2", "get fa", "get fa 2", "clear fa all"}) {
assert(isFilterMgrAllowed(cmd));
assert(!isRegionMgrAllowed(cmd));
}
for (const char* cmd : {"get fs", "set fs.2 public scope=local", "del fs.2",
"get fsr", "set fsr.2 public", "del fsr.2"}) {
assert(isRegionMgrAllowed(cmd));
assert(!isFilterMgrAllowed(cmd));
}
for (const char* cmd : {"get fmore", "set fss.password secret", "get fsrs", "clear failure all"}) {
assert(!isFilterMgrAllowed(cmd));
assert(!isRegionMgrAllowed(cmd));
}
}
'''
with tempfile.TemporaryDirectory() as directory: