diff --git a/docs/nrf52_companion_storage_recovery.md b/docs/nrf52_companion_storage_recovery.md new file mode 100644 index 000000000..8f302956f --- /dev/null +++ b/docs/nrf52_companion_storage_recovery.md @@ -0,0 +1,68 @@ +# nRF52 Companion automatic ExtraFS recovery + +Companion builds using internal ExtraFS reserve 100 KiB at +`0xD4000..0xED000`. Identity and preferences remain in the separate 28 KiB +primary store at `0xED000..0xF4000`. This applies to internal-ExtraFS Companion +builds, including Full, not repeaters or external QSPI storage. + +At startup the firmware: + +1. Mounts primary storage without erasing nonblank media. Only proven-blank + primary storage may be initialized automatically. +2. Checks the exact ExtraFS address, size, block geometry, and application + linker reservation, then attempts a non-formatting secondary mount. +3. Validates primary filesystem metadata before any destructive secondary + recovery. An unavailable primary store blocks recovery and identity replacement. +4. Keeps an already mounted, metadata-valid ExtraFS unchanged. If its initial + mount or validation failed, unmounts and retries once without formatting. +5. If the retry remains unusable, automatically formats only the reserved + 100 KiB region, then remounts and validates it. There is one format attempt + per boot; failure keeps the contact/channel write quarantine active. +6. Runs the existing verified migration from primary into ExtraFS. Primary + contact/channel sources are retired only after every destination is copied, + read back, and the migration transaction is committed. Identity and + preferences stay on primary. + +No terminal repair command, phone intervention, or bootloader update is needed +for this recovery path when a compatible application/layout is already used. +After successful recovery, the Companion storage API reports 100 KiB total +rather than the 28 KiB primary fallback. + +## Data-loss boundary + +Automatic rebuilding prioritizes a usable contact/channel store over retaining +an unreadable secondary filesystem. Data stored only in that damaged region +can be lost. Surviving primary files are migrated, but may not contain the +latest secondary-only changes. An API backup remains advisable before flashing. +Primary storage is never formatted by this recovery path. + +This is mount/metadata recovery, not forensic file recovery. An invalid +migration journal, unreadable individual contact page, primary-source error, +full filesystem, or failed migration cleanup does **not** on its own trigger a +format of a mountable, metadata-valid secondary store. Those cases retain the +existing validation, retry, and incomplete-load protections. + +## Regression coverage + +`test/test_internal_secondary_fs_repair` exercises healthy/no-format startup, +successful remount, failed remount/traversal followed by one repair, and +format/remount/validation failure. `test/test_nrf52_extrafs_contract.py` checks +primary-first ordering, internal-only geometry guards, quarantine handling, +and the separation between migration failures and destructive recovery. + +## T1000-E hardware qualification (2026-09-03) + +A device reporting 25/28 KiB primary fallback and contact enumeration error 5 +was flashed with the recovery build. With no explicit repair command, startup +activated 100 KiB ExtraFS and loaded all 125 contacts and 40 channel slots. +Identity, preferences, radio settings, and channels matched the pre-update API +snapshot. Add, edit, and delete of an owned temporary contact each survived a +separate normal reboot; the device ended with the original 125 contacts and +27/100 KiB usage. The 161 offline/native regression tests and target build passed. + +This was not lossless recovery of every historical RAM value: 21 contacts had +older advertisement/local-modification timestamps, including one older advertised +location. No contact was missing, and no computer-backup restoration was used. +A subsequent read-only scan found no readable cached advert for any of the 125 +contacts. Without a surviving newer copy, firmware cannot infer those earlier +values; receiving new verified advertisements can refresh them normally. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 549a582e6..f988450d7 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -105,13 +105,18 @@ void DataStore::begin() { _fsExtra = nullptr; return; } +#if defined(EXTRAFS) && !defined(QSPIFLASH) + // Validate primary first: automatic secondary recovery must never hide a + // primary/identity fault. Retry or rebuild before loading any RAM state. + recoverInternalExtraFSOnBoot(); +#else if (_fsExtra != nullptr && !validateLfsFilesystem(_fsExtra)) { - // A traversal-failed filesystem may still be recoverable offline. Keep - // both removable QSPI and managed on-chip ExtraFS intact until an explicit - // local repair/factory-reset operation authorizes erasing it. + // Automatic destructive recovery is restricted to reserved internal + // ExtraFS. Removable/external QSPI still requires an explicit repair. MESH_DEBUG_PRINTLN("DataStore: secondary LittleFS metadata is corrupt; preserving it and using primary storage"); disableSecondaryFS(true); } +#endif if (primary_ready) cleanupAtomicTempFiles(_fs); if (_fsExtra != nullptr) cleanupAtomicTempFiles(_fsExtra); #endif @@ -210,6 +215,53 @@ static void cleanupAtomicTempFiles(FILESYSTEM* fs) { } #if defined(EXTRAFS) && !defined(QSPIFLASH) +bool DataStore::recoverInternalExtraFSOnBoot() { + if (_configuredFsExtra == nullptr) return false; + + CustomLFS* extra = static_cast(_configuredFsExtra); + if (_primary_storage_unavailable + || !mesh::storage::isExpectedInternalExtraFsGeometry( + extra->getFlashAddr(), extra->getFlashSize(), extra->getBlockSize()) + || !mesh::storage::isInternalExtraFsReservedByApplication( + (uint32_t)(uintptr_t)__flash_arduino_end)) { + disableSecondaryFS(true); + MESH_DEBUG_PRINTLN("DataStore: refusing automatic ExtraFS recovery outside reserved 100 KiB region"); + return false; + } + + const mesh::storage::InternalSecondaryFsRecoveryResult result = + mesh::storage::recoverInternalSecondaryFilesystem( + [this]() -> bool { + return _fsExtra == _configuredFsExtra + && validateLfsFilesystem(_fsExtra); + }, + [this, extra]() -> bool { + MESH_DEBUG_PRINTLN("DataStore: retrying internal ExtraFS mount before recovery"); + _fsExtra = nullptr; + extra->end(); + if (!extra->Adafruit_LittleFS::begin()) return false; + _fsExtra = _configuredFsExtra; + return true; + }, + [this]() -> bool { + MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS remains unusable; rebuilding 100 KiB (secondary-only data may be lost)"); + return reinitializeInternalExtraFS(); + }); + if (result == mesh::storage::InternalSecondaryFsRecoveryResult::Failed) { + disableSecondaryFS(true); + return false; + } + + // This runs only before migration and user-data loading. Clear the initial + // mount quarantine, not errors from a later incomplete contact/prefs load. + // Migration below establishes authority again and can re-latch any error. + _secondary_authority_unknown = false; + _contact_load_incomplete = false; + _identity_creation_blocked = false; + _prefs_load_incomplete = false; + return true; +} + bool DataStore::reinitializeInternalExtraFS() { if (_configuredFsExtra == nullptr) return false; diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index ecd3d411a..aeefc7751 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -19,8 +19,8 @@ class DataStore { FILESYSTEM* _fs; FILESYSTEM* _fsExtra; // Keep the configured secondary even when normal I/O falls back to the - // primary filesystem. An explicit local repair/factory reset must still be - // able to address the inactive on-chip ExtraFS. + // primary filesystem. Boot recovery and explicit local repair must still + // be able to address the inactive on-chip ExtraFS. FILESYSTEM* _configuredFsExtra; mesh::RTCClock* _clock; IdentityStore identity_store; @@ -47,6 +47,7 @@ class DataStore { bool truncateLegacyContacts(uint16_t remaining_contacts); void resetContactPageState(bool clear_incomplete = false); #if defined(EXTRAFS) && !defined(QSPIFLASH) + bool recoverInternalExtraFSOnBoot(); bool reinitializeInternalExtraFS(); #endif #endif diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f0d024962..1dcb63263 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2147,46 +2147,16 @@ void setup() { ExtraFS.getBlockSize()) && mesh::storage::isInternalExtraFsReservedByApplication( (uint32_t)(uintptr_t)__flash_arduino_end); - mesh::storage::InternalSecondaryFsBootResult extra_fs_boot = - mesh::storage::InternalSecondaryFsBootResult::InitializationFailed; - if (extra_fs_geometry_valid) { - extra_fs_boot = mesh::storage::prepareInternalSecondaryFilesystem( - []() -> bool { - // Bypass CustomLFS::begin(), whose mount-failure path erases - // nonblank media before DataStore can quarantine it. - return ExtraFS.Adafruit_LittleFS::begin(); - }, - []() -> bool { - const volatile uint32_t* word = - reinterpret_cast( - mesh::storage::INTERNAL_EXTRAFS_START); - for (uint32_t offset = 0; - offset < mesh::storage::INTERNAL_EXTRAFS_SIZE; - offset += sizeof(uint32_t), word++) { - if (*word != 0xFFFFFFFFUL) return false; - } - return true; - }, - []() -> bool { - ExtraFS.end(); - return ExtraFS.format(); - }); - } + // Initial mount is always non-destructive, even for blank media. + // DataStore validates primary first, then retries/rebuilds ExtraFS. if (!extra_fs_geometry_valid - || extra_fs_boot == mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank - || extra_fs_boot == mesh::storage::InternalSecondaryFsBootResult::InitializationFailed) { + || !ExtraFS.Adafruit_LittleFS::begin()) { if (!extra_fs_geometry_valid) { MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS geometry is not 100 KiB; refusing to mount or erase it"); - } else if (extra_fs_boot - == mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank) { - MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS mount failed; preserving nonblank media and using primary storage"); } else { - MESH_DEBUG_PRINTLN("CustomLFS: blank internal ExtraFS initialization failed; using primary storage"); + MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS mount failed; deferring automatic recovery until primary validation"); } - const bool secondary_authority_unknown = !extra_fs_geometry_valid - || extra_fs_boot - == mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank; - store.disableSecondaryFS(secondary_authority_unknown); + store.disableSecondaryFS(true); } #endif #endif diff --git a/src/helpers/nrf52/InternalSecondaryFsRepair.h b/src/helpers/nrf52/InternalSecondaryFsRepair.h index 3080e5895..c2b53496e 100644 --- a/src/helpers/nrf52/InternalSecondaryFsRepair.h +++ b/src/helpers/nrf52/InternalSecondaryFsRepair.h @@ -63,9 +63,8 @@ enum class InternalSecondaryFsRepairResult { ValidationFailed, }; -// Repair is deliberately separate from the normal boot path. A filesystem -// which mounts but fails a full metadata traversal may still be forensically -// recoverable, so erasing it requires an explicit local operator action. +// The caller must authorize and bound the erase. In particular, automatic +// recovery is only for the reserved secondary region, never identity storage. template InternalSecondaryFsRepairResult repairInternalSecondaryFilesystem( Format format, Mount mount, Validate validate) { @@ -75,5 +74,26 @@ InternalSecondaryFsRepairResult repairInternalSecondaryFilesystem( return InternalSecondaryFsRepairResult::Repaired; } +enum class InternalSecondaryFsRecoveryResult { + Ready, + Remounted, + Reinitialized, + Failed, +}; + +// A failed initial mount/traversal may be transient. Retry non-destructively +// before rebuilding, and attempt the destructive repair at most once. Repair +// must include its own mount and validation before reporting success. +template +InternalSecondaryFsRecoveryResult recoverInternalSecondaryFilesystem( + IsReady is_ready, Remount remount, Repair repair) { + if (is_ready()) return InternalSecondaryFsRecoveryResult::Ready; + if (remount() && is_ready()) { + return InternalSecondaryFsRecoveryResult::Remounted; + } + if (repair()) return InternalSecondaryFsRecoveryResult::Reinitialized; + return InternalSecondaryFsRecoveryResult::Failed; +} + } // namespace storage } // namespace mesh diff --git a/test/test_internal_secondary_fs_repair/test_internal_secondary_fs_repair.cpp b/test/test_internal_secondary_fs_repair/test_internal_secondary_fs_repair.cpp index 99d9d78fb..0854f6b2b 100644 --- a/test/test_internal_secondary_fs_repair/test_internal_secondary_fs_repair.cpp +++ b/test/test_internal_secondary_fs_repair/test_internal_secondary_fs_repair.cpp @@ -4,17 +4,23 @@ using mesh::storage::InternalSecondaryFsRepairResult; using mesh::storage::InternalSecondaryFsBootResult; +using mesh::storage::InternalSecondaryFsRecoveryResult; using mesh::storage::isInternalExtraFsReservedByApplication; using mesh::storage::isErasedFlashRange; using mesh::storage::isExpectedInternalExtraFsGeometry; using mesh::storage::prepareInternalSecondaryFilesystem; using mesh::storage::repairInternalSecondaryFilesystem; +using mesh::storage::recoverInternalSecondaryFilesystem; TEST(InternalSecondaryFsBoot, RequiresExact100KiBGeometry) { EXPECT_TRUE(isExpectedInternalExtraFsGeometry(0xD4000, 0x19000, 128)); EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xED000, 0x7000, 128)); EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD4000, 0x7000, 128)); EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD4000, 0x19000, 4096)); + EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD3000, 0x1A000, 128)); + EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD4000, 0x19001, 128)); + EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD4000, 0x18FFF, 128)); + EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD4000, 0, 128)); EXPECT_TRUE(isInternalExtraFsReservedByApplication(0xD4000)); EXPECT_TRUE(isInternalExtraFsReservedByApplication(0xC0000)); EXPECT_FALSE(isInternalExtraFsReservedByApplication(0xD4001)); @@ -171,6 +177,131 @@ TEST(InternalSecondaryFsRepair, RejectsFilesystemWhichRemainsInvalid) { EXPECT_EQ(result, InternalSecondaryFsRepairResult::ValidationFailed); } +TEST(InternalSecondaryFsRecovery, HealthyFilesystemNeverRemountsOrRepairs) { + int ready_calls = 0; + int remount_calls = 0; + int repair_calls = 0; + const auto result = recoverInternalSecondaryFilesystem( + [&]() { ready_calls++; return true; }, + [&]() { remount_calls++; return true; }, + [&]() { repair_calls++; return true; }); + + EXPECT_EQ(result, InternalSecondaryFsRecoveryResult::Ready); + EXPECT_EQ(ready_calls, 1); + EXPECT_EQ(remount_calls, 0); + EXPECT_EQ(repair_calls, 0); +} + +TEST(InternalSecondaryFsRecovery, SuccessfulRetryIsValidatedWithoutRepair) { + int sequence = 0; + int ready_calls = 0; + int repair_calls = 0; + const auto result = recoverInternalSecondaryFilesystem( + [&]() { + ready_calls++; + EXPECT_EQ(++sequence, ready_calls == 1 ? 1 : 3); + return ready_calls == 2; + }, + [&]() { EXPECT_EQ(++sequence, 2); return true; }, + [&]() { repair_calls++; return true; }); + + EXPECT_EQ(result, InternalSecondaryFsRecoveryResult::Remounted); + EXPECT_EQ(ready_calls, 2); + EXPECT_EQ(repair_calls, 0); + EXPECT_EQ(sequence, 3); +} + +TEST(InternalSecondaryFsRecovery, FailedRemountSkipsTraversalAndRepairsOnce) { + int ready_calls = 0; + int remount_calls = 0; + int repair_calls = 0; + const auto result = recoverInternalSecondaryFilesystem( + [&]() { ready_calls++; return false; }, + [&]() { remount_calls++; return false; }, + [&]() { + EXPECT_EQ(ready_calls, 1); + EXPECT_EQ(remount_calls, 1); + repair_calls++; + return true; + }); + + EXPECT_EQ(result, InternalSecondaryFsRecoveryResult::Reinitialized); + EXPECT_EQ(ready_calls, 1); + EXPECT_EQ(remount_calls, 1); + EXPECT_EQ(repair_calls, 1); +} + +TEST(InternalSecondaryFsRecovery, MountedButStillCorruptFilesystemIsRepairedOnce) { + int ready_calls = 0; + int remount_calls = 0; + int repair_calls = 0; + const auto result = recoverInternalSecondaryFilesystem( + [&]() { ready_calls++; return false; }, + [&]() { remount_calls++; return true; }, + [&]() { + EXPECT_EQ(ready_calls, 2); + repair_calls++; + return true; + }); + + EXPECT_EQ(result, InternalSecondaryFsRecoveryResult::Reinitialized); + EXPECT_EQ(ready_calls, 2); + EXPECT_EQ(remount_calls, 1); + EXPECT_EQ(repair_calls, 1); +} + +TEST(InternalSecondaryFsRecovery, RepairFailureRemainsFailedWithoutRetryLoop) { + int ready_calls = 0; + int remount_calls = 0; + int repair_calls = 0; + const auto result = recoverInternalSecondaryFilesystem( + [&]() { ready_calls++; return false; }, + [&]() { remount_calls++; return false; }, + [&]() { repair_calls++; return false; }); + + EXPECT_EQ(result, InternalSecondaryFsRecoveryResult::Failed); + EXPECT_EQ(ready_calls, 1); + EXPECT_EQ(remount_calls, 1); + EXPECT_EQ(repair_calls, 1); +} + +TEST(InternalSecondaryFsRecovery, ReinitializationRequiresFormatMountAndValidation) { + // The recovery policy delegates destructive work, but the real repair + // callback must never report success after only formatting or mounting. + for (int failing_step = 1; failing_step <= 3; failing_step++) { + int sequence = 0; + const auto result = recoverInternalSecondaryFilesystem( + []() { return false; }, + []() { return false; }, + [&]() { + const auto repaired = repairInternalSecondaryFilesystem( + [&]() { return ++sequence != failing_step; }, + [&]() { return ++sequence != failing_step; }, + [&]() { return ++sequence != failing_step; }); + return repaired == InternalSecondaryFsRepairResult::Repaired; + }); + EXPECT_EQ(result, InternalSecondaryFsRecoveryResult::Failed); + EXPECT_EQ(sequence, failing_step); + } +} + +TEST(InternalSecondaryFsRecovery, VerifiedReinitializationCompletesExactlyOnce) { + int sequence = 0; + const auto result = recoverInternalSecondaryFilesystem( + []() { return false; }, + []() { return false; }, + [&]() { + const auto repaired = repairInternalSecondaryFilesystem( + [&]() { EXPECT_EQ(++sequence, 1); return true; }, + [&]() { EXPECT_EQ(++sequence, 2); return true; }, + [&]() { EXPECT_EQ(++sequence, 3); return true; }); + return repaired == InternalSecondaryFsRepairResult::Repaired; + }); + + EXPECT_EQ(result, InternalSecondaryFsRecoveryResult::Reinitialized); + EXPECT_EQ(sequence, 3); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_nrf52_extrafs_contract.py b/test/test_nrf52_extrafs_contract.py index 910870e13..6113ccc22 100644 --- a/test/test_nrf52_extrafs_contract.py +++ b/test/test_nrf52_extrafs_contract.py @@ -365,30 +365,50 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): self.assertEqual(size, 100 * 1024) self.assertEqual(start + size, 0xED000) - def test_boot_mount_is_non_destructive_and_checks_runtime_geometry(self): + def test_initial_secondary_mount_defers_repair_until_primary_validation(self): main = MAIN.read_text(encoding="utf-8") setup = function_body(main, "void setup()") self.assertIn("isExpectedInternalExtraFsGeometry(", setup) self.assertIn("isInternalExtraFsReservedByApplication(", setup) self.assertIn("uint32_t __flash_arduino_end[]", main) self.assertIn("(uintptr_t)__flash_arduino_end", setup) - self.assertIn("prepareInternalSecondaryFilesystem(", setup) - self.assertIn( - "ExtraFS.Adafruit_LittleFS::begin()", setup - ) + geometry_at = setup.index("isExpectedInternalExtraFsGeometry(") + store_begin_at = setup.index("store.begin()", geometry_at) + secondary_mount = setup[geometry_at:store_begin_at] + self.assertIn("ExtraFS.Adafruit_LittleFS::begin()", secondary_mount) self.assertNotIn("ExtraFS.begin()", setup) - self.assertIn("PreservedNonBlank", setup) - self.assertIn( - "store.disableSecondaryFS(secondary_authority_unknown);", setup - ) - self.assertLess( - setup.index("prepareInternalSecondaryFilesystem("), - setup.index("store.begin()"), - ) + self.assertNotIn("prepareInternalSecondaryFilesystem(", secondary_mount) + self.assertNotIn("ExtraFS.format()", secondary_mount) + self.assertNotIn("isErasedFlashRange(", secondary_mount) + self.assertIn("store.disableSecondaryFS(true);", secondary_mount) - def test_corrupt_secondary_is_preserved_until_explicit_repair(self): + def test_boot_recovery_runs_only_for_internal_secondary_after_primary_validation(self): store = STORE.read_text(encoding="utf-8") begin = function_body(store, "void DataStore::begin()") + primary_failure = function_body(begin, "if (!primary_ready)") + self.assertIn("return;", primary_failure) + self.assertNotIn("recoverInternalExtraFSOnBoot", primary_failure) + self.assertLess( + begin.index("if (!primary_ready)"), + begin.index("recoverInternalExtraFSOnBoot()"), + ) + internal = re.search( + r"#if defined\(EXTRAFS\) && !defined\(QSPIFLASH\)(.*?)#else", + begin, + re.DOTALL, + ) + self.assertIsNotNone(internal) + self.assertIn("recoverInternalExtraFSOnBoot()", internal.group(1)) + self.assertLess( + begin.index("recoverInternalExtraFSOnBoot()"), + begin.index("cleanupAtomicTempFiles(_fs)"), + ) + migration_at = begin.index("migrateToSecondaryFS()") + self.assertLess(begin.index("recoverInternalExtraFSOnBoot()"), migration_at) + self.assertNotIn("reinitializeInternalExtraFS", begin[migration_at:]) + self.assertNotIn("recoverInternalExtraFSOnBoot", begin[migration_at:]) + + # QSPI/removable secondary storage retains the non-destructive path. invalid = re.search( r"if\s*\(_fsExtra\s*!=\s*nullptr\s*&&\s*" r"!validateLfsFilesystem\(_fsExtra\)\)\s*\{(.*?)\}", @@ -400,6 +420,23 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): self.assertNotIn("format", invalid.group(1)) self.assertNotIn("reinitializeInternalExtraFS", invalid.group(1)) + def test_boot_recovery_retains_configured_secondary_and_checks_exact_region(self): + store = STORE.read_text(encoding="utf-8") + recover = function_body(store, "bool DataStore::recoverInternalExtraFSOnBoot()") + self.assertIn("_configuredFsExtra == nullptr", recover) + policy_at = recover.index("recoverInternalSecondaryFilesystem(") + geometry_at = recover.index("isExpectedInternalExtraFsGeometry(") + reservation_at = recover.index("isInternalExtraFsReservedByApplication(") + self.assertLess(geometry_at, policy_at) + self.assertLess(reservation_at, policy_at) + self.assertIn("(uintptr_t)__flash_arduino_end", recover) + self.assertIn("extra->getFlashAddr()", recover) + self.assertIn("extra->getFlashSize()", recover) + self.assertIn("extra->getBlockSize()", recover) + self.assertIn("return false;", recover[geometry_at:policy_at]) + self.assertNotIn("->format(", recover) + self.assertNotIn("_fs->", recover) + header = STORE_HEADER.read_text(encoding="utf-8") self.assertIn("FILESYSTEM* _configuredFsExtra;", header) extra_constructor = re.search( @@ -410,6 +447,40 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): self.assertIsNotNone(extra_constructor) self.assertIn("_configuredFsExtra(&fsExtra)", extra_constructor.group(0)) + def test_boot_recovery_retries_nonformatting_mount_before_guarded_reinitialization(self): + store = STORE.read_text(encoding="utf-8") + recover = function_body(store, "bool DataStore::recoverInternalExtraFSOnBoot()") + policy_at = recover.index("recoverInternalSecondaryFilesystem(") + ready_at = recover.index("validateLfsFilesystem(_fsExtra)", policy_at) + unmount_at = recover.index("extra->end()", ready_at) + remount_at = recover.index("extra->Adafruit_LittleFS::begin()", unmount_at) + repair_at = recover.index("reinitializeInternalExtraFS()", remount_at) + self.assertLess(ready_at, unmount_at) + self.assertLess(unmount_at, remount_at) + self.assertLess(remount_at, repair_at) + self.assertIn("_fsExtra = _configuredFsExtra", recover[remount_at:repair_at]) + self.assertNotIn("extra->begin()", recover) + self.assertNotIn("migrateToSecondaryFS()", recover) + + def test_boot_recovery_only_clears_authority_latches_after_verified_success(self): + store = STORE.read_text(encoding="utf-8") + recover = function_body(store, "bool DataStore::recoverInternalExtraFSOnBoot()") + failure_at = recover.index("InternalSecondaryFsRecoveryResult::Failed") + clear_at = recover.index("_secondary_authority_unknown = false;") + failure = recover[failure_at:clear_at] + self.assertIn("disableSecondaryFS(true);", failure) + self.assertIn("return false;", failure) + success = recover[clear_at:] + for flag in ( + "_secondary_authority_unknown", + "_identity_creation_blocked", + "_contact_load_incomplete", + "_prefs_load_incomplete", + ): + self.assertIn(f"{flag} = false;", success) + self.assertNotIn("_primary_storage_unavailable = false", recover) + self.assertIn("return true;", success) + def test_repair_is_internal_only_and_revalidates_before_activation(self): store = STORE.read_text(encoding="utf-8") repair = function_body(store, "bool DataStore::repairInternalExtraFS()") @@ -865,21 +936,17 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): self.assertEqual(setup.count("InternalFS.begin();"), 1) self.assertGreater(setup.index("#else", primary_policy), primary_format) self.assertIn("store.disableSecondaryFS(true);", setup) - authority = setup.index( - "const bool secondary_authority_unknown = !extra_fs_geometry_valid" + secondary_geometry = setup.index( + "const bool extra_fs_geometry_valid =" ) - disable_call = setup.index( - "store.disableSecondaryFS(secondary_authority_unknown)", authority - ) - authority_expression = setup[authority:disable_call] - self.assertIn( - "InternalSecondaryFsBootResult::PreservedNonBlank", - authority_expression, - ) - self.assertNotIn( - "InternalSecondaryFsBootResult::InitializationFailed", - authority_expression, + secondary_disable = setup.index( + "store.disableSecondaryFS(true);", secondary_geometry ) + secondary_guard = setup[secondary_geometry:secondary_disable] + self.assertIn("if (!extra_fs_geometry_valid", secondary_guard) + self.assertIn("!ExtraFS.Adafruit_LittleFS::begin()", secondary_guard) + self.assertNotIn("ExtraFS.format()", secondary_guard) + self.assertNotIn("isErasedFlashRange(", secondary_guard) begin = function_body(mesh, "void MyMesh::begin(") load_at = begin.index("const bool identity_loaded =")