Files
MeshCore/test/test_esp32_partition_migration_preservation.py
T

206 lines
9.4 KiB
Python

"""Execute strict 4 MiB source/retention guards without hardware or PlatformIO."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "examples/esp32_partition_migrator/main.cpp"
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <map>
#include <string>
#include <vector>
#include <helpers/ESP32PartitionMigrationPolicy.h>
namespace migration = mesh::esp32_partition_migration;
constexpr size_t kIdentityFileBytes=96;
char status_text[192]={};
struct Entry {std::string path;bool dir;size_t bytes;std::vector<std::string> children;};
struct File {
Entry* item=nullptr;size_t index=0;
explicit operator bool() const{return item!=nullptr;}
bool isDirectory()const{return item&&item->dir;}
const char* path()const{return item?item->path.c_str():nullptr;}
size_t size()const{return item?item->bytes:0;}
File openNextFile();
void close(){item=nullptr;}
};
struct Filesystem {
std::map<std::string,Entry> entries;
bool mounted=true;unsigned begins=0,ends=0;
bool begin(bool format){assert(!format);++begins;return mounted;}
void end(){++ends;}
bool exists(const char* path){return entries.count(path)!=0;}
File open(const char* path,const char* mode){
assert(!strcmp(mode,"r"));
auto it=entries.find(path);return {it==entries.end()?nullptr:&it->second,0};
}
void add(const char* path,size_t bytes,const char* parent="/") {
entries[path]={path,false,bytes,{}};entries[parent].children.push_back(path);
}
void base() {
*this=Filesystem();entries["/"]={"/",true,0,{}};
add("/identity/_main.id",96);
}
} SPIFFS;
File File::openNextFile(){
if(!item||index>=item->children.size())return {};
return SPIFFS.open(item->children[index++].c_str(),"r");
}
@CONFIG@
@METHODS@
int main(int argc,char**argv) {
assert(argc==2);int scenario=atoi(argv[1]);SPIFFS.base();
if(scenario==0) {
SPIFFS.add("/com_prefs",512);SPIFFS.add("/s_contacts",2048);
SPIFFS.add("/s_login_replay",256);SPIFFS.add("/radio_profiles",1024);
SPIFFS.add("/mqtt_prefs",2888);SPIFFS.add("/management",512);
assert(preflightStrictPreservation(migration::kTarget4MB));
assert(SPIFFS.begins==1&&SPIFFS.ends==1);
} else if(scenario==1) {
for(const char* path:{"/custom.ini","/mqtt_prefs.tmp","/identity/other.id","/logs.txt"}) {
SPIFFS.base();SPIFFS.add(path,10);
assert(!preflightStrictPreservation(migration::kTarget4MB));
assert(strstr(status_text,"unpreserved"));assert(SPIFFS.ends==1);
}
} else if(scenario==2) {
for(size_t bytes:{size_t(0),size_t(16385),size_t(UINT32_MAX)+1}) {
SPIFFS.base();SPIFFS.add("/radio_profiles",bytes);
assert(!preflightStrictPreservation(migration::kTarget4MB));
}
SPIFFS.base();SPIFFS.add("/s_contacts",12000);
SPIFFS.add("/radio_profiles",12000);SPIFFS.add("/mqtt_prefs",12000);
assert(!preflightStrictPreservation(migration::kTarget4MB));
assert(strstr(status_text,"budget"));
} else if(scenario==3) {
for(const auto& layout:{migration::kLegacyLayout,migration::kMinSpiffs4MBLayout,
migration::kExpanded4MBLayout}) {
assert(migration::isReviewed4MBSource(4U*1024U*1024U,layout));
assert(!migration::isReviewed4MBSource(8U*1024U*1024U,layout));
auto changed=layout;changed.spiffs_address+=4096;
assert(!migration::isReviewed4MBSource(4U*1024U*1024U,changed));
changed=layout;changed.nvs_address+=4096;
assert(!migration::isReviewed4MBSource(4U*1024U*1024U,changed));
changed=layout;changed.app1_address=changed.app0_address;
assert(!migration::isReviewed4MBSource(4U*1024U*1024U,changed));
}
auto target=migration::kTarget4MB;target.layout.spiffs_size=0x30000;
assert(!preflightStrictPreservation(target));assert(SPIFFS.begins==0);
assert(migration::kMinSpiffs4MBLayout.spiffs_address==0x3D0000);
assert(migration::kMinSpiffs4MBLayout.spiffs_size==0x20000);
// min_spiffs has a separate 64 KiB coredump after its 128 KiB filesystem;
// silently treating the coredump as filesystem capacity is not reviewed.
auto includes_coredump=migration::kMinSpiffs4MBLayout;
includes_coredump.spiffs_size=0x30000;
assert(!migration::isReviewed4MBSource(4U*1024U*1024U,includes_coredump));
assert(migration::kMinSpiffs4MBLayout.app0_address+migration::kMinSpiffs4MBLayout.app0_size
==migration::kMinSpiffs4MBLayout.app1_address);
// An old-B bridge can copy to A without erasing its currently running bytes.
assert(migration::kExpanded4MBLayout.app0_address+migration::kMinSpiffs4MBLayout.app1_size
<=migration::kMinSpiffs4MBLayout.app1_address);
} else if(scenario==4) {
SPIFFS.entries["/"].children.clear();
SPIFFS.entries["/"].children.push_back("/identity");
SPIFFS.entries["/identity"]={"/identity",true,0,{"/identity/_main.id"}};
assert(preflightStrictPreservation(migration::kTarget4MB));
SPIFFS.base();SPIFFS.entries["/"].children.push_back("/unknown");
SPIFFS.entries["/unknown"]={"/unknown",true,0,{}};
assert(!preflightStrictPreservation(migration::kTarget4MB));
SPIFFS.base();SPIFFS.entries["/"].children.clear();
assert(!preflightStrictPreservation(migration::kTarget4MB));
SPIFFS.base();SPIFFS.entries["/radio_profiles"]={"/radio_profiles",false,10,{}};
assert(!preflightStrictPreservation(migration::kTarget4MB));
assert(strstr(status_text,"incomplete"));
SPIFFS.base();SPIFFS.mounted=false;
assert(!preflightStrictPreservation(migration::kTarget4MB));
} else if(scenario==5) {
SPIFFS.entries["/identity/_main.id"].bytes=128;
assert(!preflightStrictPreservation(migration::kTarget4MB));
assert(strstr(status_text,"identity"));
SPIFFS.base();SPIFFS.entries["/"].children.push_back("/identity/_main.id");
assert(!preflightStrictPreservation(migration::kTarget4MB));
} else if(scenario==6) {
migration::PreservationBudget budget(65536);
for(unsigned i=0;i<33;++i)assert(budget.addFile(96));
assert(!budget.addFile(96));
migration::PreservationBudget large(65536);
assert(large.addFile(96)&&large.addFile(16384));
assert(large.requiredBytes()<=65536);
assert(!large.addFile(16384));
migration::PreservationBudget invalid(65536);
assert(!invalid.addFile(0)&&!invalid.addFile(UINT32_MAX));
} else return 2;
}
'''
class StrictPartitionPreservationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.directory = tempfile.TemporaryDirectory(prefix="strict-partition-preservation-")
source = SOURCE.read_text()
start = source.index("struct ConfigFile {")
end = source.index(";", source.index("constexpr size_t kConfigFileCount", start)) + 1
config = source[start:end]
methods = "\n".join(extract_braced(source, name) for name in (
"bool inventoryStrictDirectory(", "bool preflightStrictPreservation("))
path = Path(cls.directory.name) / "test.cpp"
cls.binary = Path(cls.directory.name) / "test"
path.write_text(HARNESS.replace("@CONFIG@", config).replace("@METHODS@", methods))
result = subprocess.run([
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
"-I", str(ROOT / "src"), str(path), "-o", str(cls.binary),
], text=True, capture_output=True)
if result.returncode:
cls.directory.cleanup()
raise AssertionError(result.stdout + result.stderr)
@classmethod
def tearDownClass(cls):
cls.directory.cleanup()
def test_saved_node_acl_radios_mqtt_fit(self):
subprocess.run([str(self.binary), "0"], check=True)
def test_unknown_files_are_not_silently_discarded(self):
subprocess.run([str(self.binary), "1"], check=True)
def test_empty_oversized_and_over_capacity_config_is_refused(self):
subprocess.run([str(self.binary), "2"], check=True)
def test_only_reviewed_source_maps_and_target_capacity_are_accepted(self):
subprocess.run([str(self.binary), "3"], check=True)
def test_nested_flat_missing_and_incomplete_inventory(self):
subprocess.run([str(self.binary), "4"], check=True)
def test_unpreserved_identity_metadata_and_duplicate_paths_are_refused(self):
subprocess.run([str(self.binary), "5"], check=True)
def test_budget_reserves_gc_and_an_atomic_largest_file_rewrite(self):
subprocess.run([str(self.binary), "6"], check=True)
def test_preflight_precedes_every_destructive_migration_step(self):
source = SOURCE.read_text()
body = extract_braced(source, "void runMigration()")
guard = body.index("preflightStrictPreservation(*plan)")
for operation in ("stageLegacyIdentity()", "stageLegacyConfig()", "copyAndVerify(*running",
"esp_ota_set_boot_partition(", "publishExpandedPartitionTable(*plan)"):
self.assertLess(guard, body.index(operation))
self.assertIn("MESHCORE_MIGRATION_STRICT_4MB", source)
# Existing boards keep their existing migration behavior unless a new,
# reviewed package explicitly opts into this narrower contract.
self.assertIn("#if defined(MESHCORE_MIGRATION_STRICT_4MB) && MESHCORE_MIGRATION_STRICT_4MB", body)
if __name__ == "__main__":
unittest.main()