mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-24 01:02:22 +00:00
Automatically recover internal Companion ExtraFS on boot
This commit is contained in:
1 parent
1c91f82ec0
commit
73f10f3fc3
7 files changed
+379
-70
No files matched your search
@@ -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.
|
||||
@@ -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<CustomLFS*>(_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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<const volatile uint32_t*>(
|
||||
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
|
||||
|
||||
@@ -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 <typename Format, typename Mount, typename Validate>
|
||||
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 <typename IsReady, typename Remount, typename Repair>
|
||||
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
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =")
|
||||
|
||||
Reference in new issue
Block a user