Files
agessaman 1be09b9bd6 fix(mqtt): harden /mqtt_prefs migration (atomic durability, tests)
Rework the /mqtt_prefs load/save path so preference migrations are
crash-safe and, for the first time, unit-testable on the host.

Most of this is extraction. The multi-format migration that previously
lived inline in CommonCLI.cpp (and could only run on-device) is moved
into three dependency-free headers so it can be exercised without
Arduino, a filesystem, or the radio stack:

  - MQTTPrefsStorage.h  frozen layout structs for every /mqtt_prefs
                        format ever shipped, with static_asserts that
                        fail the build if any on-flash offset changes.
  - MQTTPrefsCodec.h    pure format classification, field-copy
                        migration, and plausibility validation.
  - MQTTPrefsAtomicStore.h  transactional writer plus the power-cut
                        upgrade gate, both host-testable.

New behavior, beyond the refactor:

  - Atomic writes: /mqtt_prefs is written to /mqtt_prefs.tmp, verified,
    then published with an atomic rename; the writer never removes the
    existing file. A failed or interrupted save leaves the current
    config intact.
  - Power-cut ordering: LegacyUpgradeGate guarantees /mqtt_prefs is
    durably committed before the legacy /com_prefs (or /node_prefs)
    carrying the observer tail is compacted or removed, so an
    interrupted two-file upgrade retries on the next boot without
    losing settings.
  - Corrupt, unsupported-version, and newer-than-known files are
    preserved and the device boots on in-RAM defaults, rather than
    overwriting a file this firmware cannot fully decode.
  - Headerless legacy formats are validated for plausibility before
    they are trusted and rewritten (raw prefs carry no checksum).

The full historical format matrix is migrated forward to the versioned
v1 layout: pre-slot (including pre-wifi-power), 3-slot (base and
token/topic tails), and headerless 6-slot (base, audience, rx, ntp).

Scope note: only /mqtt_prefs and the one-time /node_prefs -> /com_prefs
name migration use the atomic path. Ordinary /com_prefs saves remain a
direct rewrite, unchanged by this commit.

Tests: adds two host GoogleTest suites (pio test -e native).
  - test_mqtt_prefs_codec: format classification, migration fixtures,
    v1 header integrity, downgrade preservation.
  - test_mqtt_prefs_atomic_store: transactional writes, short-write
    detection, begin/finish/rename failure cleanup, original-file
    preservation.
2026-07-18 18:44:37 -07:00

1.4 KiB

MQTT preferences fixture provenance

The codec tests construct synthetic, non-secret byte vectors at hard-coded offsets. They intentionally do not serialize the production structs: a layout change must disagree with the frozen fixture bytes and fail the field checks.

Bytes Historical layout Source history
472 Pre-slot, before and after wifi_power_save Initial /mqtt_prefs layout; 34c8bea7 inserted WiFi power without changing the padded total size
1032 Initial three-slot layout b43e9618
1464 Three slots with token/topic tails 95874f0c
2452 Six slots with token/topic tails 1b5884bd
2836 Six slots with audience tail 1263e71d
2840 Six slots with RX flag 47b632aa
2904 Six slots with NTP server 7416d632
2736 / 2860 Version-1 payload before observer tail / complete payload 58b9cb66 introduced the eight-byte versioned header; the shorter form exercises its append-compatible prefix contract

The 3024-byte raw observer-tail form is not accepted as deployed fleet data: repository history indicates it existed briefly before versioning but was not shipped. Tests require it to be preserved rather than guessed and rewritten.

Headerless formats have no checksum. Plausibility checks reject obvious random or malformed content, but cannot authenticate a byte sequence that happens to look like a valid historical struct.