"""Exercise the production per-image bootloader lock with filesystem faults.""" import os from pathlib import Path import shutil import subprocess import tempfile import unittest ROOT = Path(__file__).resolve().parents[1] FILESYSTEM = r''' #pragma once #include #include #include #include #include #include #include #include struct lfs_info {}; static const int LFS_ERR_NOENT = -2; static const uint8_t FILE_O_READ = 0, FILE_O_WRITE = 1; class FakeFilesystem; class File { FakeFilesystem* fs; std::string path; size_t position = 0; bool opened = false, writing = false; public: explicit File(FakeFilesystem& filesystem) : fs(&filesystem) {} bool open(const char*, uint8_t); size_t write(const uint8_t*, size_t); int read(void*, uint16_t); uint32_t size() const; bool isDirectory() const; void flush() {} void close() { opened = false; } explicit operator bool() const { return opened; } }; class FakeFilesystem { public: std::map> files; std::set directories; std::string stat_error; bool fail_read = false, fail_write_open = false, fail_rename = false; bool false_success_rename = false, error_after_publish = false; bool corrupt_after_publish = false, fail_reads_after_publish = false; size_t max_write = std::numeric_limits::max(); size_t max_read = std::numeric_limits::max(); unsigned writes = 0, renames = 0; bool exists(const char* path) const { return files.count(path) || directories.count(path); } bool remove(const char* path) { return files.erase(path) != 0; } bool rename(const char* from, const char* to) { ++renames; if (fail_rename) return false; if (false_success_rename) return true; auto found = files.find(from); if (found == files.end() || directories.count(to)) return false; files[to] = found->second; files.erase(found); if (corrupt_after_publish && !files[to].empty()) files[to][0] ^= 1; if (fail_reads_after_publish) fail_read = true; return !error_after_publish; } void _lockFS() {} void _unlockFS() {} FakeFilesystem* _getFS() { return this; } }; inline int lfs_stat(FakeFilesystem* fs, const char* path, lfs_info*) { if (fs->stat_error == path) return -5; return fs->exists(path) ? 0 : LFS_ERR_NOENT; } inline bool File::open(const char* filename, uint8_t mode) { path = filename; writing = mode == FILE_O_WRITE; if (fs->directories.count(path)) return false; if (writing) { if (fs->fail_write_open) return false; position = fs->files[path].size(); } else { if (fs->fail_read || !fs->files.count(path)) return false; position = 0; } opened = true; return true; } inline size_t File::write(const uint8_t* data, size_t size) { if (!opened || !writing) return 0; ++fs->writes; const size_t count = std::min(size, fs->max_write); auto& contents = fs->files[path]; contents.insert(contents.end(), data, data + count); position += count; return count; } inline int File::read(void* data, uint16_t size) { if (!opened || writing || fs->fail_read) return -1; const auto& contents = fs->files[path]; const size_t count = std::min( std::min(size, fs->max_read), contents.size() - std::min(position, contents.size())); if (count) memcpy(data, contents.data() + position, count); position += count; return static_cast(count); } inline uint32_t File::size() const { auto found = fs->files.find(path); return found == fs->files.end() ? 0 : found->second.size(); } inline bool File::isDirectory() const { return fs->directories.count(path); } #define FILESYSTEM FakeFilesystem ''' SOURCE = r''' #include #include #define ATOMIC_FILE_WRITER_IMPLEMENTATION #include #include #include using namespace mesh::ota; static const char* Path = "/bl_auto"; static const char* Temp = "/bl_auto.tmp"; int main(int argc, char** argv) { assert(argc == 2); const std::string scenario = argv[1]; uint8_t a[32], b[32]; memset(a, 0x31, sizeof(a)); memcpy(b, a, sizeof(b)); b[31] ^= 1; // Deliberately preserve the 8-byte EndF hash prefix. FakeFilesystem fs; if (scenario == "fresh") { assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Eligible); assert(bootloader_migration_auto_lock(nullptr, a) == MigrationAutoLock::Unavailable); assert(bootloader_migration_auto_lock(&fs, nullptr) == MigrationAutoLock::Unavailable); assert(!bootloader_migration_lock_auto(nullptr, a)); assert(!bootloader_migration_lock_auto(&fs, nullptr)); } else if (scenario == "persist") { assert(bootloader_migration_lock_auto(&fs, a)); assert(fs.files.at(Path).size() == 40 && !fs.exists(Temp)); FakeFilesystem rebooted; rebooted.files = fs.files; assert(bootloader_migration_auto_lock(&rebooted, a) == MigrationAutoLock::Locked); assert(bootloader_migration_auto_lock(&rebooted, b) == MigrationAutoLock::PreviousImage); assert(bootloader_migration_lock_auto(&rebooted, b)); assert(bootloader_migration_auto_lock(&rebooted, b) == MigrationAutoLock::Locked); assert(bootloader_migration_auto_lock(&rebooted, a) == MigrationAutoLock::PreviousImage); } else if (scenario == "locked") { assert(bootloader_migration_lock_auto(&fs, a)); const auto original = fs.files; const unsigned writes = fs.writes, renames = fs.renames; fs.max_write = 0; fs.fail_rename = true; assert(bootloader_migration_lock_auto(&fs, a)); assert(fs.files == original && fs.writes == writes && fs.renames == renames); } else if (scenario == "malformed") { assert(bootloader_migration_lock_auto(&fs, a)); const auto good = fs.files.at(Path); for (unsigned kind = 0; kind < 4; ++kind) { fs.files[Path] = good; if (kind == 0) fs.files[Path].clear(); if (kind == 1) fs.files[Path].push_back(0); if (kind == 2) { fs.files[Path][0] ^= 1; mesh::storage::writeLE32(fs.files[Path].data() + 36, mesh::storage::updateCRC32(0xffffffffU, fs.files[Path].data(), 36)); } if (kind == 3) fs.files[Path][39] ^= 1; const auto before = fs.files; assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); assert(!bootloader_migration_lock_auto(&fs, b)); assert(fs.files == before); } } else if (scenario == "directory") { fs.directories.insert(Path); assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); assert(!bootloader_migration_lock_auto(&fs, a)); } else if (scenario == "stat") { for (const auto path : {Path, Temp}) { fs.stat_error = path; assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); assert(!bootloader_migration_lock_auto(&fs, a)); assert(fs.writes == 0 && fs.renames == 0); } } else if (scenario == "read") { assert(bootloader_migration_lock_auto(&fs, a)); const auto before = fs.files; fs.fail_read = true; assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); assert(!bootloader_migration_lock_auto(&fs, b)); fs.fail_read = false; fs.max_read = 7; assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); assert(!bootloader_migration_lock_auto(&fs, b)); assert(fs.files == before); } else if (scenario == "temporary") { assert(bootloader_migration_lock_auto(&fs, a)); const auto complete = fs.files.at(Path); for (bool primary : {false, true}) { fs = FakeFilesystem(); if (primary) assert(bootloader_migration_lock_auto(&fs, a)); for (size_t size : {size_t(0), size_t(7), size_t(40)}) { fs.files[Temp] = std::vector(size, 0x31); const auto before = fs.files; assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); assert(!bootloader_migration_lock_auto(&fs, b)); assert(fs.files == before); } fs.files[Temp] = complete; const auto before = fs.files; assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); assert(bootloader_migration_auto_lock(&fs, b) == MigrationAutoLock::Unavailable); assert(!bootloader_migration_lock_auto(&fs, b)); assert(fs.files == before); } } else if (scenario == "short_write" || scenario == "write_open") { assert(bootloader_migration_lock_auto(&fs, a)); const auto before = fs.files; if (scenario == "short_write") fs.max_write = 7; else fs.fail_write_open = true; assert(!bootloader_migration_lock_auto(&fs, b)); assert(fs.files == before); assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Locked); } else if (scenario == "rename_fail" || scenario == "rename_false_success") { assert(bootloader_migration_lock_auto(&fs, a)); const auto before = fs.files; fs.fail_rename = scenario == "rename_fail"; fs.false_success_rename = scenario == "rename_false_success"; assert(!bootloader_migration_lock_auto(&fs, b)); assert(fs.files == before); assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Locked); } else if (scenario == "rename_late_error") { fs.error_after_publish = true; assert(bootloader_migration_lock_auto(&fs, a)); assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Locked); } else if (scenario == "publish_corrupt") { fs.corrupt_after_publish = true; assert(!bootloader_migration_lock_auto(&fs, a)); assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); } else if (scenario == "publish_unreadable") { fs.fail_reads_after_publish = true; assert(!bootloader_migration_lock_auto(&fs, a)); assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); fs.fail_read = false; assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Locked); } else if (scenario == "settings") { fs.files["/node_prefs"] = {1, 2, 3, 4}; fs.files["/_main"] = {9, 8, 7}; const auto before = fs.files; assert(bootloader_migration_lock_auto(&fs, a)); for (const auto& file : before) assert(fs.files.at(file.first) == file.second); assert(fs.files.size() == before.size() + 1); } else assert(false); } ''' class BootloaderMigrationStoreTest(unittest.TestCase): @classmethod def setUpClass(cls): compiler = shutil.which("g++") or shutil.which("clang++") if compiler is None: raise unittest.SkipTest("a host C++17 compiler is required") cls.temporary = tempfile.TemporaryDirectory(prefix="bootloader-lock-store-") cls.addClassCleanup(cls.temporary.cleanup) directory = Path(cls.temporary.name) headers = directory / "helpers" headers.mkdir() (headers / "IdentityStore.h").write_text(FILESYSTEM, encoding="ascii") source = directory / "test.cpp" source.write_text(SOURCE, encoding="ascii") cls.binary = directory / ("test.exe" if os.name == "nt" else "test") sanitize = [] if os.name == "nt" else [ "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] built = subprocess.run([ compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", "-DNRF52_PLATFORM=1", "-DOTA_BOOTLOADER_MIGRATION=1", *sanitize, "-I", str(directory), "-I", str(ROOT / "src"), str(source), str(ROOT / "src/helpers/ota/BootloaderMigrationStore.cpp"), "-o", str(cls.binary), ], capture_output=True, text=True, timeout=60) if built.returncode: raise AssertionError(built.stdout + built.stderr) def scenario(self, name): tested = subprocess.run([str(self.binary), name], capture_output=True, text=True, timeout=30) self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) def test_missing_store_and_volatile_storage(self): self.scenario("fresh") def test_power_cycle_and_new_firmware_compare_all_32_hash_bytes(self): self.scenario("persist") def test_matching_image_never_rewrites_its_lock(self): self.scenario("locked") def test_malformed_records_are_preserved_and_block_install(self): self.scenario("malformed") def test_directory_cannot_authorize_install(self): self.scenario("directory") def test_metadata_errors_are_not_treated_as_absence(self): self.scenario("stat") def test_unreadable_and_partial_records_are_preserved(self): self.scenario("read") def test_interrupted_transaction_never_authorizes_another_attempt(self): self.scenario("temporary") def test_partial_write_cannot_authorize_install(self): self.scenario("short_write") def test_write_open_failure_cannot_authorize_install(self): self.scenario("write_open") def test_failed_rename_preserves_previous_firmware_lock(self): self.scenario("rename_fail") def test_false_success_rename_cannot_authorize_install(self): self.scenario("rename_false_success") def test_verified_public_record_survives_late_rename_error(self): self.scenario("rename_late_error") def test_corruption_after_publication_fails_closed(self): self.scenario("publish_corrupt") def test_unreadable_publication_fails_closed_and_later_remains_locked(self): self.scenario("publish_unreadable") def test_settings_and_identity_are_untouched(self): self.scenario("settings") if __name__ == "__main__": unittest.main()