SPIFFS regular-file reads and login replay state
Arduino-ESP32 SPIFFS can return a truthy directory handle from a read-open of a
nonexistent filename. File::operator bool() alone does not prove that a
regular file exists. A directory's size() and read() are zero.
The first privileged login on an upgraded G2 encountered this in
ClientACL::writeClientLoginReplayCeiling(): opening the not-yet-created
/s_login_replay appeared successful, then subtracting the eight-byte trailer
from size zero underflowed. Copying the supposed records failed and login was
rejected. Waiting or changing the repeater clock cannot fix that file-open bug.
Fix
Use mesh::openFileRead() for regular-file reads. It checks existence and
rejects directory handles, while preserving real empty files. Replay record
counts also validate the minimum trailer size, record alignment, and maximum
count before subtraction. An existing replay file that becomes unreadable is
not treated as a new store.
No replay records are cleared and authentication is not weakened. Invalid or unwritable replay state still fails closed. The first successful privileged login creates a 44-byte file: one 36-byte identity/ceiling record plus its eight-byte integrity trailer.
Setting the repeater clock must not clear this file. Admission compares the sender's timestamp with that sender's saved boundary, not with the repeater's current time. Clearing the boundary could make captured requests reusable. A sender clock rollback is a separate condition and remains subject to the saved boundary after this fix.
Companion directory enumeration uses the separate openDirectory() API,
which deliberately permits SPIFFS virtual directories. Regular-file reads are
also enforced for companion data, repeater/room logs, flood-rule verification,
and HTTP packet-log downloads (missing logs return 404; real empty logs 200).
Audit boundary
The audit covered file opens and size arithmetic in src and examples.
Identity, region, clock, and common preference loaders already gate reads with
filesystem existence checks; SPIFFS's exists() explicitly excludes directory
handles. Relevant MQTT length subtraction follows validated headers and exact
reads. ESP32 OTA staging uses partition APIs, not these SPIFFS file handles.
Intentional directory enumeration must not be changed to a regular-file read.
Regression checks
test/test_client_acl_spiffs.pycompiles the actualClientACL.cppagainst a filesystem that reproduces the misleading missing-file directory handle. It covers first creation, fresh/stale login, reboot ceilings, corrupt and unreadable state, short writes, failed publication, and truncated sources.test/test_regular_file_reads.pyexecutes production companion readers, flood-file verification, and HTTP log delivery with the same filesystem behavior, including platform-specific file API differences.test/test_esp32_tinyusb_cooperative_output.pyexercises both real role log pumps, including missing logs and bounded output.- Native ACL transaction, login persistence, and client-path persistence suites retain the existing security and recovery coverage.
The pre-fix actual ACL code was reproduced as first_login=rejected, one
missing read-open, and no replay file. The fixed code accepted that identical
scenario without a missing read-open and created the valid 44-byte record.
Host simulations do not replace a post-flash LoRa login test on the G2.