From b5aae24e7770cb3adcb31471b5d8a91d3a764704 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:44:26 +0000 Subject: [PATCH 1/8] fix: safely initialize erased LittleFS partitions --- lib/storage/LittleFSInitializationPolicy.h | 38 +++++++ src/main.cpp | 43 +++++++- .../test_message_persistence_contract.py | 4 +- .../test_patch_littlefs_paths.py | 20 +++- .../test_web_flasher_release_safety.py | 11 ++ .../test_littlefs_initialization_policy.cpp | 100 ++++++++++++++++++ .../test_littlefs_initialization_policy.py | 19 ++++ tools/audit_release_build.py | 1 + 8 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 lib/storage/LittleFSInitializationPolicy.h create mode 100644 tests/native/test_littlefs_initialization_policy.cpp create mode 100644 tests/native/test_littlefs_initialization_policy.py diff --git a/lib/storage/LittleFSInitializationPolicy.h b/lib/storage/LittleFSInitializationPolicy.h new file mode 100644 index 00000000..502cf19c --- /dev/null +++ b/lib/storage/LittleFSInitializationPolicy.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace Storage { + +constexpr std::size_t LITTLEFS_BLANK_SCAN_CHUNK_SIZE = 4096; + +template +bool mount_or_initialize_erased_littlefs( + Mount mount, + ResolvePartition resolve_partition, + ReadPartition read_partition, + FormatAndMount format_and_mount) { + if (mount()) return true; + + std::size_t partition_size = 0; + if (!resolve_partition(partition_size) || partition_size == 0) return false; + + uint8_t buffer[LITTLEFS_BLANK_SCAN_CHUNK_SIZE]; + std::size_t offset = 0; + while (offset < partition_size) { + const std::size_t remaining = partition_size - offset; + const std::size_t chunk = + remaining < sizeof(buffer) ? remaining : sizeof(buffer); + if (!read_partition(offset, buffer, chunk)) return false; + for (std::size_t i = 0; i < chunk; ++i) { + if (buffer[i] != 0xFF) return false; + } + offset += chunk; + } + + return format_and_mount(); +} + +} // namespace Storage diff --git a/src/main.cpp b/src/main.cpp index 96c51e71..639766df 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,8 @@ #include // placement new #include +#include "storage/LittleFSInitializationPolicy.h" + // Reticulum #include #include @@ -964,6 +966,8 @@ static void pump_ntp_sync_if_pending() { } } +static constexpr const char* LITTLEFS_PARTITION_LABEL = "spiffs"; + void setup_hardware() { INFO("\n=== Hardware Initialization ==="); @@ -981,7 +985,44 @@ void setup_hardware() { // Pyxis's lib/universal_filesystem/ is now dead code on this build path and // can be deleted once the graft lands. static microStore::Adapters::LittleFSFileSystem fs("/littlefs"); - persistent_storage_ready = fs.init(false); + + // Mount non-destructively first: never format on a mount failure by itself, + // because a corrupt filesystem holds user conversations that must be preserved. + // + // An erased or uninitialized LittleFS partition is all 0xFF. Its mount fails, + // but formatting is safe because the partition contains no programmed bytes. + // Any nonblank or unreadable partition remains untouched and falls through to + // recovery mode. The policy is portable so every destructive branch is covered + // by host tests while these callbacks retain the real ESP32 operations. + const esp_partition_t* littlefs_partition = nullptr; + persistent_storage_ready = Storage::mount_or_initialize_erased_littlefs( + []() { return fs.init(false); }, + [&littlefs_partition](size_t& size) { + littlefs_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_DATA, + ESP_PARTITION_SUBTYPE_DATA_SPIFFS, + LITTLEFS_PARTITION_LABEL); + if (!littlefs_partition) { + ERROR("Unable to locate LittleFS partition for blank check"); + return false; + } + size = littlefs_partition->size; + return true; + }, + [&littlefs_partition](size_t offset, uint8_t* buffer, size_t size) { + if (esp_partition_read(littlefs_partition, offset, buffer, size) != ESP_OK) { + ERROR("Failed to read LittleFS partition for blank check"); + return false; + } + return true; + }, + []() { + // Use LittleFS.begin() directly because microStore's adapter returns + // immediately when its non-destructive mount fails. + INFO("Blank LittleFS partition; formatting once"); + return LittleFS.begin( + true, "/littlefs", 10, LITTLEFS_PARTITION_LABEL); + }); location_filesystem_available = persistent_storage_ready; if (!persistent_storage_ready) { ERROR("FileSystem mount failed; preserving persistent data"); diff --git a/tests/build_scripts/test_message_persistence_contract.py b/tests/build_scripts/test_message_persistence_contract.py index d70938b0..b2bcecf9 100644 --- a/tests/build_scripts/test_message_persistence_contract.py +++ b/tests/build_scripts/test_message_persistence_contract.py @@ -147,7 +147,9 @@ def test_failed_littlefs_mount_enters_stable_recovery_mode_before_reticulum(): setup = function_body(source, "void setup()", "void loop()") loop = source[source.index("void loop()"):] - assert "persistent_storage_ready = fs.init(false);" in source + assert "Storage::mount_or_initialize_erased_littlefs(" in source + assert "[]() { return fs.init(false); }" in source + assert 'true, "/littlefs", 10, LITTLEFS_PARTITION_LABEL' in source assert "Persistent storage unavailable" in source assert "USB serial recovery remains available." in source assert setup.index("if (!persistent_storage_ready)") < setup.index("setup_reticulum();") diff --git a/tests/build_scripts/test_patch_littlefs_paths.py b/tests/build_scripts/test_patch_littlefs_paths.py index 2009d714..e6e6d8af 100644 --- a/tests/build_scripts/test_patch_littlefs_paths.py +++ b/tests/build_scripts/test_patch_littlefs_paths.py @@ -132,11 +132,23 @@ def test_persistent_partitions_do_not_overlap_app_slots(): if not line or line.startswith("#"): continue fields = [field.strip() for field in line.split(",")] - rows.append((fields[0], int(fields[3], 0), int(fields[4], 0))) + rows.append( + ( + fields[0], + fields[1], + fields[2], + int(fields[3], 0), + int(fields[4], 0), + ) + ) - by_name = {name: (offset, offset + size) for name, offset, size in rows} - persistent = [by_name["nvs"], by_name["spiffs"]] - applications = [by_name["app0"], by_name["app1"]] + by_name = { + name: (partition_type, subtype, offset, offset + size) + for name, partition_type, subtype, offset, size in rows + } + assert by_name["spiffs"] == ("data", "spiffs", 0x610000, 0x7F0000) + persistent = [by_name["nvs"][2:], by_name["spiffs"][2:]] + applications = [by_name["app0"][2:], by_name["app1"][2:]] for data_start, data_end in persistent: for app_start, app_end in applications: diff --git a/tests/build_scripts/test_web_flasher_release_safety.py b/tests/build_scripts/test_web_flasher_release_safety.py index b22cf090..f5ee0b37 100644 --- a/tests/build_scripts/test_web_flasher_release_safety.py +++ b/tests/build_scripts/test_web_flasher_release_safety.py @@ -67,6 +67,16 @@ def test_custom_firmware_upload_is_explicit_validated_and_update_only(): assert "const useFullInstall = customFirmwareBytes ? false : eraseCheckbox.checked;" in source +def test_full_install_erases_flash_while_updates_preserve_persistent_partitions(): + source = FLASHER.read_text() + flash = source[source.index("async function flash()") :] + + assert "const useFullInstall = customFirmwareBytes ? false : eraseCheckbox.checked;" in flash + assert "eraseAll: useFullInstall," in flash + assert "useFullInstall ? fw.full : fw.update" in flash + assert "{ offset: 0x610000" not in source + + def test_custom_firmware_selection_invalidates_stale_async_results(): source = FLASHER.read_text() handler = source[source.index("customFirmwareInput.addEventListener('change'"):source.index("function selectPublishedRelease")] @@ -163,6 +173,7 @@ def test_release_audit_rejects_stale_version_and_destructive_storage_firmware(): assert 'b"Firmware: v1.0.0"' in audit assert 'b"FileSystem mount failed; preserving persistent data"' in audit + assert 'b"Blank LittleFS partition; formatting once"' in audit assert '"git", "describe", "--tags", "--always", "--dirty"' in audit assert 'os.environ.get("PYXIS_VERSION_OVERRIDE")' in audit assert 'f"Firmware: {expected_version}".encode()' in audit diff --git a/tests/native/test_littlefs_initialization_policy.cpp b/tests/native/test_littlefs_initialization_policy.cpp new file mode 100644 index 00000000..88bfd55c --- /dev/null +++ b/tests/native/test_littlefs_initialization_policy.cpp @@ -0,0 +1,100 @@ +#include +#include +#include +#include + +#include "LittleFSInitializationPolicy.h" + +using Storage::mount_or_initialize_erased_littlefs; + +static int passed = 0; +static int failed = 0; +#define CHECK(expr) do { if (expr) { ++passed; } else { ++failed; std::cerr << "FAIL line " << __LINE__ << ": " #expr "\n"; } } while (0) + +struct Harness { + std::vector partition; + bool mount_result = false; + bool resolve_result = true; + bool read_result = true; + bool format_mount_result = true; + int mount_calls = 0; + int resolve_calls = 0; + int read_calls = 0; + int format_mount_calls = 0; + + bool run() { + return mount_or_initialize_erased_littlefs( + [this]() { + ++mount_calls; + return mount_result; + }, + [this](std::size_t& size) { + ++resolve_calls; + if (!resolve_result) return false; + size = partition.size(); + return true; + }, + [this](std::size_t offset, uint8_t* output, std::size_t size) { + ++read_calls; + if (!read_result || offset + size > partition.size()) return false; + for (std::size_t i = 0; i < size; ++i) output[i] = partition[offset + i]; + return true; + }, + [this]() { + ++format_mount_calls; + return format_mount_result; + }); + } +}; + +int main() { + { + Harness harness; + harness.mount_result = true; + CHECK(harness.run()); + CHECK(harness.mount_calls == 1); + CHECK(harness.resolve_calls == 0); + CHECK(harness.read_calls == 0); + CHECK(harness.format_mount_calls == 0); + } + { + Harness harness; + harness.partition.assign(8193, 0xFF); + CHECK(harness.run()); + CHECK(harness.read_calls == 3); + CHECK(harness.format_mount_calls == 1); + } + { + Harness harness; + harness.partition.assign(8193, 0xFF); + harness.partition.back() = 0x00; + CHECK(!harness.run()); + CHECK(harness.read_calls == 3); + CHECK(harness.format_mount_calls == 0); + } + { + Harness harness; + harness.resolve_result = false; + CHECK(!harness.run()); + CHECK(harness.read_calls == 0); + CHECK(harness.format_mount_calls == 0); + } + { + Harness harness; + harness.partition.assign(4096, 0xFF); + harness.read_result = false; + CHECK(!harness.run()); + CHECK(harness.read_calls == 1); + CHECK(harness.format_mount_calls == 0); + } + { + Harness harness; + harness.partition.assign(4096, 0xFF); + harness.format_mount_result = false; + CHECK(!harness.run()); + CHECK(harness.format_mount_calls == 1); + } + + std::cout << passed << " passed, " << failed << " failed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/tests/native/test_littlefs_initialization_policy.py b/tests/native/test_littlefs_initialization_policy.py new file mode 100644 index 00000000..b972646c --- /dev/null +++ b/tests/native/test_littlefs_initialization_policy.py @@ -0,0 +1,19 @@ +"""Compile and execute the portable LittleFS initialization policy regression.""" + +from pathlib import Path + +from native_test import compile_and_run + + +HERE = Path(__file__).resolve().parent +PYXIS_ROOT = HERE.parent.parent + + +def test_littlefs_initialization_policy(tmp_path): + ran = compile_and_run( + tmp_path, + name="test_littlefs_initialization_policy", + sources=[HERE / "test_littlefs_initialization_policy.cpp"], + include_dirs=[PYXIS_ROOT / "lib" / "storage"], + ) + assert "19 passed, 0 failed" in ran.stdout diff --git a/tools/audit_release_build.py b/tools/audit_release_build.py index 1446dc92..84626d87 100644 --- a/tools/audit_release_build.py +++ b/tools/audit_release_build.py @@ -34,6 +34,7 @@ EXCLUDED_STRINGS = ( ) REQUIRED_STRINGS = ( b"FileSystem mount failed; preserving persistent data", + b"Blank LittleFS partition; formatting once", ) EXCLUDED_SYMBOLS = ( "test_call_", From 7c0daca3221a3efb573dc4f6e8124858b1890b8d Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:40:13 +0000 Subject: [PATCH 2/8] test: lock LittleFS partition selection --- tests/build_scripts/test_message_persistence_contract.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/build_scripts/test_message_persistence_contract.py b/tests/build_scripts/test_message_persistence_contract.py index b2bcecf9..3256fbe0 100644 --- a/tests/build_scripts/test_message_persistence_contract.py +++ b/tests/build_scripts/test_message_persistence_contract.py @@ -149,6 +149,10 @@ def test_failed_littlefs_mount_enters_stable_recovery_mode_before_reticulum(): assert "Storage::mount_or_initialize_erased_littlefs(" in source assert "[]() { return fs.init(false); }" in source + assert "ESP_PARTITION_TYPE_DATA," in source + assert "ESP_PARTITION_SUBTYPE_DATA_SPIFFS," in source + assert "LITTLEFS_PARTITION_LABEL);" in source + assert 'static constexpr const char* LITTLEFS_PARTITION_LABEL = "spiffs";' in source assert 'true, "/littlefs", 10, LITTLEFS_PARTITION_LABEL' in source assert "Persistent storage unavailable" in source assert "USB serial recovery remains available." in source From c4d20e290744968649ac431b8c6f9db992f6bc80 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:44:06 +0000 Subject: [PATCH 3/8] test: cover incomplete LittleFS scans --- .../test_littlefs_initialization_policy.cpp | 18 +++++++++++++++++- .../test_littlefs_initialization_policy.py | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/native/test_littlefs_initialization_policy.cpp b/tests/native/test_littlefs_initialization_policy.cpp index 88bfd55c..3b64b258 100644 --- a/tests/native/test_littlefs_initialization_policy.cpp +++ b/tests/native/test_littlefs_initialization_policy.cpp @@ -16,6 +16,7 @@ struct Harness { bool mount_result = false; bool resolve_result = true; bool read_result = true; + std::size_t fail_read_at = SIZE_MAX; bool format_mount_result = true; int mount_calls = 0; int resolve_calls = 0; @@ -36,7 +37,8 @@ struct Harness { }, [this](std::size_t offset, uint8_t* output, std::size_t size) { ++read_calls; - if (!read_result || offset + size > partition.size()) return false; + if (!read_result || offset >= fail_read_at || + offset + size > partition.size()) return false; for (std::size_t i = 0; i < size; ++i) output[i] = partition[offset + i]; return true; }, @@ -79,6 +81,12 @@ int main() { CHECK(harness.read_calls == 0); CHECK(harness.format_mount_calls == 0); } + { + Harness harness; + CHECK(!harness.run()); + CHECK(harness.read_calls == 0); + CHECK(harness.format_mount_calls == 0); + } { Harness harness; harness.partition.assign(4096, 0xFF); @@ -87,6 +95,14 @@ int main() { CHECK(harness.read_calls == 1); CHECK(harness.format_mount_calls == 0); } + { + Harness harness; + harness.partition.assign(8192, 0xFF); + harness.fail_read_at = 4096; + CHECK(!harness.run()); + CHECK(harness.read_calls == 2); + CHECK(harness.format_mount_calls == 0); + } { Harness harness; harness.partition.assign(4096, 0xFF); diff --git a/tests/native/test_littlefs_initialization_policy.py b/tests/native/test_littlefs_initialization_policy.py index b972646c..0d350607 100644 --- a/tests/native/test_littlefs_initialization_policy.py +++ b/tests/native/test_littlefs_initialization_policy.py @@ -16,4 +16,4 @@ def test_littlefs_initialization_policy(tmp_path): sources=[HERE / "test_littlefs_initialization_policy.cpp"], include_dirs=[PYXIS_ROOT / "lib" / "storage"], ) - assert "19 passed, 0 failed" in ran.stdout + assert "25 passed, 0 failed" in ran.stdout From b003e00bf83249964cb67ce7f6a6f95163f4e358 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:44:56 +0000 Subject: [PATCH 4/8] test: bind LittleFS lookup arguments --- tests/build_scripts/test_message_persistence_contract.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/build_scripts/test_message_persistence_contract.py b/tests/build_scripts/test_message_persistence_contract.py index 3256fbe0..2d628f46 100644 --- a/tests/build_scripts/test_message_persistence_contract.py +++ b/tests/build_scripts/test_message_persistence_contract.py @@ -149,9 +149,10 @@ def test_failed_littlefs_mount_enters_stable_recovery_mode_before_reticulum(): assert "Storage::mount_or_initialize_erased_littlefs(" in source assert "[]() { return fs.init(false); }" in source - assert "ESP_PARTITION_TYPE_DATA," in source - assert "ESP_PARTITION_SUBTYPE_DATA_SPIFFS," in source - assert "LITTLEFS_PARTITION_LABEL);" in source + assert """littlefs_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_DATA, + ESP_PARTITION_SUBTYPE_DATA_SPIFFS, + LITTLEFS_PARTITION_LABEL);""" in source assert 'static constexpr const char* LITTLEFS_PARTITION_LABEL = "spiffs";' in source assert 'true, "/littlefs", 10, LITTLEFS_PARTITION_LABEL' in source assert "Persistent storage unavailable" in source From acfddc4c541889d5e7b6aa388e5a1482e6424ff6 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:13:42 +0000 Subject: [PATCH 5/8] fix: show prereleases in web flasher --- .github/workflows/release-firmware.yml | 2 +- docs/flasher/index.html | 11 +++++++---- .../build_scripts/test_web_flasher_release_safety.py | 9 ++++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-firmware.yml b/.github/workflows/release-firmware.yml index 87918f8c..408d5cf0 100644 --- a/.github/workflows/release-firmware.yml +++ b/.github/workflows/release-firmware.yml @@ -134,7 +134,7 @@ jobs: # Fetch all published releases and download their firmware assets # so versioned firmware is available same-origin on GitHub Pages. # With keep_files: true, each release only needs to be downloaded once. - for tag in $(gh api repos/${{ github.repository }}/releases --jq '.[] | select(.draft == false and .prerelease == false) | select(any(.assets[]; .name == "pyxis-release.json")) | .tag_name'); do + for tag in $(gh api repos/${{ github.repository }}/releases --jq '.[] | select(.draft == false) | select(any(.assets[]; .name == "pyxis-release.json")) | .tag_name'); do dir="docs/flasher/firmware/releases/${tag}" # Skip if we already have this version's firmware (e.g., current tagged build) if python tools/validate_pyxis_web_release.py --directory "${dir}" --version "${tag}"; then diff --git a/docs/flasher/index.html b/docs/flasher/index.html index 3ac0a0e0..77ce2bae 100644 --- a/docs/flasher/index.html +++ b/docs/flasher/index.html @@ -411,7 +411,7 @@ const CONNECT_TIMEOUT_MS = 15000; const EXPECTED_CHIP = 'ESP32-S3'; - // Fail closed until the GitHub API supplies a published, non-prerelease version. + // Fail closed until the GitHub API supplies a published release with audited metadata. let firmwarePathPrefix = null; let selectedReleaseMetadata = null; let customFirmwareBytes = null; @@ -788,7 +788,7 @@ let publishedCount = 0; for (const release of releases) { - if (release.draft || release.prerelease) continue; + if (release.draft) continue; const assetNames = release.assets.map(a => a.name); if (!assetNames.includes(RELEASE_METADATA_ASSET)) continue; if (!assetNames.includes('firmware.bin')) continue; @@ -803,8 +803,11 @@ const option = document.createElement('option'); const hasAll = REQUIRED_FULL_ASSETS.every(f => assetNames.includes(f)); + const releaseChannel = release.prerelease ? ' — Pre-release' : ''; option.value = release.tag_name; - option.textContent = release.tag_name + (release.name && release.name !== release.tag_name ? ` — ${release.name}` : ''); + option.textContent = release.tag_name + + (release.name && release.name !== release.tag_name ? ` — ${release.name}` : '') + + releaseChannel; option.dataset.downloadUrl = prefix; option.dataset.hasFullAssets = hasAll ? 'true' : 'false'; option.releaseMetadata = metadata; @@ -953,7 +956,7 @@ flashBtn.addEventListener('click', flash); // Initialize with no flashable target; loadVersions enables flashing only - // after selecting a published, non-prerelease GitHub release. + // after selecting a published GitHub release with audited metadata. loadVersions(); diff --git a/tests/build_scripts/test_web_flasher_release_safety.py b/tests/build_scripts/test_web_flasher_release_safety.py index f5ee0b37..2ac4426a 100644 --- a/tests/build_scripts/test_web_flasher_release_safety.py +++ b/tests/build_scripts/test_web_flasher_release_safety.py @@ -20,10 +20,12 @@ def test_flasher_starts_disabled_until_a_published_release_is_selected(): assert '