diff --git a/Source/Core/Core/CMakeLists.txt b/Source/Core/Core/CMakeLists.txt index a73b84bcf0..00c4ca9969 100644 --- a/Source/Core/Core/CMakeLists.txt +++ b/Source/Core/Core/CMakeLists.txt @@ -404,6 +404,8 @@ add_library(core IOS/MIOS.h IOS/Starlet/ARMCore.cpp IOS/Starlet/ARMCore.h + IOS/Starlet/NANDJournal.cpp + IOS/Starlet/NANDJournal.h IOS/Starlet/Starlet.cpp IOS/Starlet/Starlet.h IOS/Starlet/StarletMemory.cpp diff --git a/Source/Core/Core/IOS/Starlet/NANDJournal.cpp b/Source/Core/Core/IOS/Starlet/NANDJournal.cpp new file mode 100644 index 0000000000..cce046c342 --- /dev/null +++ b/Source/Core/Core/IOS/Starlet/NANDJournal.cpp @@ -0,0 +1,167 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "Core/IOS/Starlet/NANDJournal.h" + +#include +#include +#include + +#ifndef _WIN32 +#include +#endif + +#include "Common/FileUtil.h" +#include "Common/Logging/Log.h" + +namespace IOS::LLE +{ +namespace +{ +constexpr std::array MAGIC{'D', 'L', 'N', 'A', 'N', 'D', '0', '1'}; +constexpr u32 TRANSACTION_MAGIC = 0x314e5854; // TXN1, little endian +constexpr size_t HEADER_SIZE = 32; +constexpr size_t TRANSACTION_HEADER_SIZE = 12; +constexpr size_t ENTRY_SIZE = 4 + NANDJournal::PAGE_SIZE; + +void Put32(std::vector& bytes, u32 value) +{ + for (unsigned shift = 0; shift < 32; shift += 8) + bytes.push_back(static_cast(value >> shift)); +} + +u32 Get32(std::span bytes) +{ + return u32(bytes[0]) | (u32(bytes[1]) << 8) | (u32(bytes[2]) << 16) | (u32(bytes[3]) << 24); +} + +std::vector MakeHeader(const Common::SHA1::Digest& digest) +{ + std::vector header(MAGIC.begin(), MAGIC.end()); + header.insert(header.end(), digest.begin(), digest.end()); + Put32(header, NANDJournal::PAGE_COUNT); + return header; +} +} // namespace + +bool NANDJournal::Open(const std::string& path, const Common::SHA1::Digest& source_digest, + Pages* pages, std::string* error) +{ + const auto fail = [&](const std::string& message) { + if (error) + *error = message + ": " + path; + m_file.Close(); + return false; + }; + const auto header = MakeHeader(source_digest); + // Exclusive creation avoids truncating a journal created concurrently by another instance. + if (!File::Exists(path)) + { + File::IOFile created(path, "wbx"); + if (created && (!created.WriteArray(header.data(), header.size()) || !created.Flush())) + return fail("Could not initialize the persistent NAND journal"); + } + if (!m_file.Open(path, "r+b", File::SharedAccess::Read)) + return fail("Could not open the NAND journal for writing (another instance may be using it)"); +#ifndef _WIN32 + if (flock(fileno(m_file.GetHandle()), LOCK_EX | LOCK_NB) != 0) + return fail("Another instance is using this NAND journal"); +#endif + + const u64 size = m_file.GetSize(); + std::array actual_header{}; + if (size < HEADER_SIZE || !m_file.ReadArray(&actual_header) || + !std::ranges::equal(actual_header, header)) + return fail("NAND journal header is invalid or belongs to a different source dump"); + + Pages loaded; + u64 position = HEADER_SIZE; + while (position < size) + { + if (size - position < TRANSACTION_HEADER_SIZE) + break; // An interrupted final transaction is never applied. + std::array transaction_header{}; + if (!m_file.ReadArray(&transaction_header)) + return fail("Could not read the NAND journal"); + const u32 flags = Get32(std::span(transaction_header).subspan(4)); + const u32 count = Get32(std::span(transaction_header).subspan(8)); + if (Get32(transaction_header) != TRANSACTION_MAGIC || flags > 1 || count > PAGE_COUNT) + return fail("NAND journal contains an invalid transaction header"); + const u64 record_size = + TRANSACTION_HEADER_SIZE + u64(count) * ENTRY_SIZE + Common::SHA1::DIGEST_LEN; + if (record_size > size - position) + break; + std::vector record(transaction_header.begin(), transaction_header.end()); + record.resize(static_cast(record_size - Common::SHA1::DIGEST_LEN)); + Common::SHA1::Digest checksum{}; + if (!m_file.ReadBytes(record.data() + TRANSACTION_HEADER_SIZE, + record.size() - TRANSACTION_HEADER_SIZE) || + !m_file.ReadArray(&checksum)) + return fail("Could not read a NAND journal transaction"); + if (Common::SHA1::CalculateDigest(record) != checksum) + return fail("NAND journal checksum mismatch; the file has been preserved"); + Pages transaction; + for (u32 i = 0; i < count; ++i) + { + const size_t offset = TRANSACTION_HEADER_SIZE + size_t(i) * ENTRY_SIZE; + const u32 page = Get32(std::span(record).subspan(offset)); + if (page >= PAGE_COUNT || transaction.contains(page)) + return fail("NAND journal contains an invalid or duplicate page"); + Page data; + std::copy_n(record.data() + offset + 4, PAGE_SIZE, data.begin()); + transaction.emplace(page, std::move(data)); + } + if (flags == 1) + loaded.clear(); + for (auto& [page, data] : transaction) + loaded.insert_or_assign(page, std::move(data)); + position += record_size; + } + if (position != size) + { + // Preserve the interrupted bytes for recovery before removing only the incomplete tail. + std::string backup = path + ".interrupted"; + for (unsigned i = 1; File::Exists(backup); ++i) + backup = path + ".interrupted." + std::to_string(i); + if (!File::Copy(path, backup) || !m_file.Resize(position)) + return fail("Could not preserve and recover an interrupted NAND transaction"); + WARN_LOG_FMT(IOS, "Recovered NAND journal through byte {}; interrupted file preserved at {}", + position, backup); + } + if (!m_file.Seek(static_cast(position), File::SeekOrigin::Begin)) + return fail("Could not seek the NAND journal"); + *pages = std::move(loaded); + INFO_LOG_FMT(IOS, "Loaded {} persistent NAND pages from {}", pages->size(), path); + return true; +} + +bool NANDJournal::Append(const Pages& pages, bool replace) +{ + if (!m_file || pages.size() > PAGE_COUNT) + return false; + if (pages.empty() && !replace) + return true; + std::vector record; + record.reserve(TRANSACTION_HEADER_SIZE + pages.size() * ENTRY_SIZE); + Put32(record, TRANSACTION_MAGIC); + Put32(record, replace ? 1 : 0); + Put32(record, static_cast(pages.size())); + for (const auto& [page, data] : pages) + { + if (page >= PAGE_COUNT) + return false; + Put32(record, page); + record.insert(record.end(), data.begin(), data.end()); + } + const auto checksum = Common::SHA1::CalculateDigest(record); + if (!m_file.WriteArray(record.data(), record.size()) || !m_file.WriteArray(checksum) || + !m_file.Flush()) + { + // Never acknowledge volatile writes as successful after a storage error. + ERROR_LOG_FMT(IOS, "Persistent NAND write failed; subsequent NAND writes are disabled"); + m_file.Close(); + return false; + } + return true; +} +} // namespace IOS::LLE diff --git a/Source/Core/Core/IOS/Starlet/NANDJournal.h b/Source/Core/Core/IOS/Starlet/NANDJournal.h new file mode 100644 index 0000000000..7aff1df014 --- /dev/null +++ b/Source/Core/Core/IOS/Starlet/NANDJournal.h @@ -0,0 +1,33 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +#include "Common/CommonTypes.h" +#include "Common/Crypto/SHA1.h" +#include "Common/IOFile.h" + +namespace IOS::LLE +{ +// Persistent copy-on-write pages. The console's source dump is never opened for writing. +// A complete checksummed transaction is flushed before a NAND command reports success. +class NANDJournal final +{ +public: + static constexpr u32 PAGE_SIZE = 0x840; + static constexpr u32 PAGE_COUNT = 0x40000; + using Page = std::array; + using Pages = std::map; + + bool Open(const std::string& path, const Common::SHA1::Digest& source_digest, Pages* pages, + std::string* error); + bool Append(const Pages& pages, bool replace = false); + +private: + File::IOFile m_file; +}; +} // namespace IOS::LLE diff --git a/Source/Core/Core/IOS/Starlet/StarletMemory.cpp b/Source/Core/Core/IOS/Starlet/StarletMemory.cpp index c45ec865b9..4044b2cb93 100644 --- a/Source/Core/Core/IOS/Starlet/StarletMemory.cpp +++ b/Source/Core/Core/IOS/Starlet/StarletMemory.cpp @@ -14,6 +14,7 @@ #include "Common/Crypto/HMAC.h" #include "Common/FileUtil.h" #include "Common/Logging/Log.h" +#include "Common/MsgHandler.h" #include "Common/SDCardUtil.h" #include "Common/StringUtil.h" #include "Core/Config/MainSettings.h" @@ -467,6 +468,8 @@ StarletMemory::StarletMemory(Core::System& system) : m_system(system) bool StarletMemory::Init(const std::string& dump_directory, std::string* error) { + m_nand_journal.reset(); + m_nand_overlay.clear(); const std::string boot_path = PathInDirectory(dump_directory, "boot0.bin"); const std::string keys_path = PathInDirectory(dump_directory, "keys.bin"); const std::string nand_path = PathInDirectory(dump_directory, "nand.bin"); @@ -522,6 +525,36 @@ bool StarletMemory::Init(const std::string& dump_directory, std::string* error) } } + // Bind saved pages to the complete immutable source dump. Different profiles and different + // source dumps must not silently share or replay each other's NAND writes. + auto hash = Common::SHA1::CreateContext(); + std::array buffer{}; + if (!m_nand.Seek(0, File::SeekOrigin::Begin)) + return false; + for (u64 offset = 0; offset < nand_size; offset += buffer.size()) + { + const size_t count = static_cast(std::min(buffer.size(), nand_size - offset)); + if (!m_nand.ReadArray(buffer.data(), count)) + { + if (error) + *error = "could not fingerprint the source NAND dump"; + return false; + } + hash->Update(buffer.data(), count); + } + const auto source_digest = hash->Finish(); + const std::string journal_path = File::GetUserPath(D_USER_IDX) + "Starlet/" + + Common::SHA1::DigestToString(source_digest) + ".nand-journal"; + if (!File::CreateFullPath(journal_path)) + { + if (error) + *error = "could not create the persistent Starlet NAND directory"; + return false; + } + m_nand_journal = std::make_unique(); + if (!m_nand_journal->Open(journal_path, source_digest, &m_nand_overlay, error)) + return false; + // Pairing identities must be selected before WiimoteDevice installs callbacks // on the input sources. Constructing the synthetic devices in Reset and // replacing them immediately after reading BT.DINF leaves a short-lived @@ -582,7 +615,7 @@ void StarletMemory::Reset() m_sram.fill(0); m_registers.clear(); ClearRegisterCache(); - m_nand_overlay.clear(); + // A hardware reset does not erase nonvolatile NAND pages. m_nand_control_before_write = 0; ResetNANDOperationState(); m_aes_key.fill(0); @@ -756,6 +789,12 @@ void StarletMemory::DoState(PointerWrap& p) p.Do(m_initialized); p.Do(m_boot0_mapped); p.Do(m_sram_split_mode); + if (p.IsReadMode() && m_nand_journal && !m_nand_journal->Append(m_nand_overlay, true)) + { + PanicAlertFmtT("Could not persist the NAND restored from the savestate. " + "Further NAND writes are disabled; check disk space and permissions."); + p.SetVerifyMode(); + } } u32 StarletMemory::GetTimer() const @@ -3750,6 +3789,15 @@ bool StarletMemory::ReadRawNANDPage(u32 page, NANDPage* raw) m_nand.ReadArray(raw); } +bool StarletMemory::CommitNANDPages(const NANDJournal::Pages& pages) +{ + if (m_nand_journal && !m_nand_journal->Append(pages)) + return false; + for (const auto& [page, data] : pages) + m_nand_overlay.insert_or_assign(page, data); + return true; +} + bool StarletMemory::InstallEmulatedWiimotePairings() { using NANDSuperblock = DiscIO::NANDImporter::NANDSuperblock; @@ -3961,9 +4009,10 @@ bool StarletMemory::InstallEmulatedWiimotePairings() std::ranges::copy(pairing, sysconf.begin() + static_cast(*pairing_offset)); // Re-encrypt only the file's SFFS clusters and place their raw pages in the - // session overlay. Preserve all spare metadata from the dump and replace only + // persistent overlay. Preserve all spare metadata from the dump and replace only // the ECC for the modified ciphertext. size_t file_offset = 0; + NANDJournal::Pages pairing_pages; for (size_t cluster_number = 0; cluster_number < cluster_indices.size(); ++cluster_number) { auto& plaintext = decrypted_clusters[cluster_number]; @@ -4017,11 +4066,13 @@ bool StarletMemory::InstallEmulatedWiimotePairings() std::ranges::copy(hmac, raw.begin() + NAND_PAGE_DATA_SIZE + 0x0c); const auto ecc = CalculateNANDECC(raw.data()); std::ranges::copy(ecc, raw.begin() + NAND_PAGE_DATA_SIZE + 0x30); - m_nand_overlay.insert_or_assign(page, raw); + pairing_pages.insert_or_assign(page, raw); } } - INFO_LOG_FMT(IOS_WIIMOTE, "Installed emulated Wii Remote pairings in the raw " + if (!CommitNANDPages(pairing_pages)) + return false; + INFO_LOG_FMT(IOS_WIIMOTE, "Installed emulated Wii Remote pairings in the persistent " "NAND copy-on-write overlay"); return true; } @@ -4144,8 +4195,8 @@ bool StarletMemory::CommitNANDProgram() return false; NANDPage raw{}; - const bool read_succeeded = ReadRawNANDPage(m_nand_program_page, &raw); - if (read_succeeded) + bool succeeded = ReadRawNANDPage(m_nand_program_page, &raw); + if (succeeded) { // ECC-enabled page programming calculates the syndrome from the page data // and places it in the final 16 bytes of the NAND spare area. IOS supplies @@ -4160,7 +4211,10 @@ bool StarletMemory::CommitNANDProgram() // to restore it. for (u32 i = 0; i < NAND_RAW_PAGE_SIZE; ++i) raw[i] &= m_nand_program_data[i]; - m_nand_overlay.insert_or_assign(m_nand_program_page, raw); + succeeded = CommitNANDPages({{m_nand_program_page, raw}}); + } + if (succeeded) + { m_nand_status &= ~NAND_STATUS_FAIL; } else @@ -4170,7 +4224,7 @@ bool StarletMemory::CommitNANDProgram() m_nand_program_pending = false; m_nand_program_ecc_enabled = false; - return read_succeeded; + return succeeded; } bool StarletMemory::StageNANDErase() @@ -4190,10 +4244,16 @@ bool StarletMemory::CommitNANDErase() const u32 block_start = m_nand_erase_page & ~(NAND_PAGES_PER_BLOCK - 1); NANDPage erased_page{}; erased_page.fill(0xff); + NANDJournal::Pages erased_pages; for (u32 page = block_start; page < block_start + NAND_PAGES_PER_BLOCK; ++page) - m_nand_overlay.insert_or_assign(page, erased_page); + erased_pages.emplace(page, erased_page); m_nand_erase_pending = false; + if (!CommitNANDPages(erased_pages)) + { + m_nand_status |= NAND_STATUS_FAIL; + return false; + } m_nand_status &= ~NAND_STATUS_FAIL; return true; } diff --git a/Source/Core/Core/IOS/Starlet/StarletMemory.h b/Source/Core/Core/IOS/Starlet/StarletMemory.h index 6b50a3459c..64816a32db 100644 --- a/Source/Core/Core/IOS/Starlet/StarletMemory.h +++ b/Source/Core/Core/IOS/Starlet/StarletMemory.h @@ -17,6 +17,7 @@ #include "Common/CommonTypes.h" #include "Common/IOFile.h" #include "Core/IOS/Starlet/ARMCore.h" +#include "Core/IOS/Starlet/NANDJournal.h" #include "Core/IOS/USB/Bluetooth/WiimoteDevice.h" class PointerWrap; @@ -29,8 +30,8 @@ class System; namespace IOS::LLE { // Starlet's physical address space and the first hardware devices required by the immutable Wii -// boot ROM. NAND is deliberately opened read-only; program/erase commands use a copy-on-write -// overlay so an experimental LLE session can never modify the user's console backup. +// boot ROM. The source NAND stays read-only; program/erase commands are persisted in a +// checksummed copy-on-write journal under the active user directory. class StarletMemory final : public ARMBus, public IOS::HLE::WiimoteDeviceHost { public: @@ -168,6 +169,7 @@ private: void HandleNANDCommand(u32 command); void CompleteNANDCommand(u32 command); bool ReadRawNANDPage(u32 page, NANDPage* raw); + bool CommitNANDPages(const NANDJournal::Pages& pages); bool InstallEmulatedWiimotePairings(); bool ReadNANDPage(u32 command); bool ReadNANDID(u32 command); @@ -303,5 +305,7 @@ private: bool m_initialized = false; bool m_boot0_mapped = true; bool m_sram_split_mode = false; + // Host persistence is not serialized; savestates contain the complete overlay above. + std::unique_ptr m_nand_journal; }; } // namespace IOS::LLE diff --git a/Source/UnitTests/Core/CMakeLists.txt b/Source/UnitTests/Core/CMakeLists.txt index 9770adac5a..4fbc7492d0 100644 --- a/Source/UnitTests/Core/CMakeLists.txt +++ b/Source/UnitTests/Core/CMakeLists.txt @@ -15,6 +15,7 @@ add_dolphin_test(DSPAssemblyTest add_dolphin_test(ESFormatsTest IOS/ES/FormatsTest.cpp) add_dolphin_test(StarletARMCoreTest IOS/Starlet/ARMCoreTest.cpp) +add_dolphin_test(StarletNANDJournalTest IOS/Starlet/NANDJournalTest.cpp) add_dolphin_test(FileSystemTest IOS/FS/FileSystemTest.cpp) diff --git a/Source/UnitTests/Core/IOS/Starlet/ARMCoreTest.cpp b/Source/UnitTests/Core/IOS/Starlet/ARMCoreTest.cpp index f42124a728..da1b7dd009 100644 --- a/Source/UnitTests/Core/IOS/Starlet/ARMCoreTest.cpp +++ b/Source/UnitTests/Core/IOS/Starlet/ARMCoreTest.cpp @@ -394,6 +394,38 @@ TEST(StarletTimer, ZeroDelayAlarmMatchesImmediatelyAndUsesIRQW1C) EXPECT_EQ(system.GetWiiIPC().ReadStarletRegister(0x38) & INT_CAUSE_TIMER, INT_CAUSE_TIMER); } +TEST(StarletNAND, HardwareResetPreservesProgrammedFlash) +{ + Core::DeclareAsCPUThread(); + auto& system = Core::System::GetInstance(); + StarletMemory memory(system); + memory.Reset(); + constexpr u32 control = 0x0d010000; + constexpr u32 addr2 = 0x0d01000c; + constexpr u32 data = 0x0d010010; + constexpr u32 sram = StarletMemory::SRAM_BASE; + memory.Write32(addr2, 64); + memory.Write32(control, 0x80600000); // Erase setup. + memory.Write32(control, 0x80d00000); + memory.AdvanceCycles(400000); + ASSERT_EQ(memory.Read32(control) & (1u << 29), 0u); + memory.Write32(sram, 0x12345678); + memory.Write32(data, sram); + memory.Write32(control, 0x80804004); // Stage four bytes. + memory.Write32(control, 0x80100000); + memory.AdvanceCycles(100000); + ASSERT_EQ(memory.Read32(control) & (1u << 29), 0u); + + memory.Reset(); + memory.Write32(addr2, 64); + memory.Write32(data, sram); + memory.Write32(control, 0x80000000); + memory.Write32(control, 0x80302004); + memory.AdvanceCycles(10000); + EXPECT_EQ(memory.Read32(control) & (1u << 29), 0u); + EXPECT_EQ(memory.Read32(sram), 0x12345678u); +} + TEST(StarletGPIO, InterruptFlagIsWriteOneToClear) { constexpr u32 hardware_base = 0x0d800000; diff --git a/Source/UnitTests/Core/IOS/Starlet/NANDJournalTest.cpp b/Source/UnitTests/Core/IOS/Starlet/NANDJournalTest.cpp new file mode 100644 index 0000000000..455bd8aab9 --- /dev/null +++ b/Source/UnitTests/Core/IOS/Starlet/NANDJournalTest.cpp @@ -0,0 +1,207 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include + +#include "Common/FileUtil.h" +#include "Common/IOFile.h" +#include "Core/IOS/Starlet/NANDJournal.h" + +using IOS::LLE::NANDJournal; + +class StarletNANDJournal : public testing::Test +{ +protected: + void SetUp() override + { + m_directory = File::CreateTempDir(); + ASSERT_FALSE(m_directory.empty()); + m_path = m_directory + "/nand.journal"; + } + void TearDown() override + { + // Only the unique directory created by this fixture is removed. + if (!m_directory.empty()) + File::DeleteDirRecursively(m_directory); + } + static NANDJournal::Page Page(u8 fill) + { + NANDJournal::Page page; + page.fill(fill); + return page; + } + std::string m_directory; + std::string m_path; + const Common::SHA1::Digest m_digest = Common::SHA1::CalculateDigest("test source NAND"); +}; + +TEST_F(StarletNANDJournal, ProgramAndEraseSurviveReopen) +{ + NANDJournal::Pages expected{{7, Page(0xa5)}, {9, Page(0xff)}}; + NANDJournal::Pages loaded; + std::string error; + { + NANDJournal journal; + ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error)) << error; + EXPECT_TRUE(loaded.empty()); + ASSERT_TRUE(journal.Append({{7, Page(0xfe)}, {9, Page(0x55)}})); + ASSERT_TRUE(journal.Append(expected)); + } + NANDJournal reopened; + ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error)) << error; + EXPECT_EQ(loaded, expected); +} + +TEST_F(StarletNANDJournal, SpareBytesAndHighestPagePersist) +{ + auto raw = Page(0xff); + raw[0x800] = 0x13; + raw.back() = 0x42; + NANDJournal::Pages loaded; + std::string error; + { + NANDJournal journal; + ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error)); + ASSERT_TRUE(journal.Append({{NANDJournal::PAGE_COUNT - 1, raw}})); + } + NANDJournal reopened; + ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error)) << error; + ASSERT_EQ(loaded.size(), 1u); + EXPECT_EQ(loaded.at(NANDJournal::PAGE_COUNT - 1), raw); +} + +TEST_F(StarletNANDJournal, SnapshotReplacesRatherThanMergesLaterWrites) +{ + NANDJournal::Pages snapshot{{8, Page(0x12)}}; + NANDJournal::Pages loaded; + std::string error; + { + NANDJournal journal; + ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error)); + ASSERT_TRUE(journal.Append({{8, Page(0x77)}, {10, Page(0x88)}})); + ASSERT_TRUE(journal.Append(snapshot, true)); + } + NANDJournal reopened; + ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error)); + EXPECT_EQ(loaded, snapshot); +} + +TEST_F(StarletNANDJournal, EmptySnapshotRestoresSourceView) +{ + NANDJournal::Pages loaded; + std::string error; + { + NANDJournal journal; + ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error)); + ASSERT_TRUE(journal.Append({{0, Page(0)}})); + ASSERT_TRUE(journal.Append({}, true)); + } + NANDJournal reopened; + ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error)); + EXPECT_TRUE(loaded.empty()); +} + +TEST_F(StarletNANDJournal, RejectsDifferentSourceWithoutChangingData) +{ + NANDJournal::Pages loaded; + std::string error; + { + NANDJournal journal; + ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error)); + ASSERT_TRUE(journal.Append({{0, Page(0x12)}})); + } + const auto size = File::GetSize(m_path); + NANDJournal wrong_source; + EXPECT_FALSE(wrong_source.Open(m_path, {}, &loaded, &error)); + EXPECT_FALSE(error.empty()); + EXPECT_EQ(File::GetSize(m_path), size); + NANDJournal correct_source; + ASSERT_TRUE(correct_source.Open(m_path, m_digest, &loaded, &error)); + EXPECT_EQ(loaded.at(0), Page(0x12)); +} + +TEST_F(StarletNANDJournal, RejectsInvalidPageWithoutAppending) +{ + NANDJournal journal; + NANDJournal::Pages loaded; + std::string error; + ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error)); + const auto size = File::GetSize(m_path); + EXPECT_FALSE(journal.Append({{NANDJournal::PAGE_COUNT, Page(0)}})); + EXPECT_EQ(File::GetSize(m_path), size); + EXPECT_TRUE(journal.Append({{1, Page(0)}})); +} + +TEST_F(StarletNANDJournal, RejectsSimultaneousWriter) +{ + NANDJournal first; + NANDJournal second; + NANDJournal::Pages loaded; + std::string error; + ASSERT_TRUE(first.Open(m_path, m_digest, &loaded, &error)); + EXPECT_FALSE(second.Open(m_path, m_digest, &loaded, &error)); + EXPECT_TRUE(first.Append({{1, Page(0)}})); +} + +TEST_F(StarletNANDJournal, PreservesInterruptedTailAndReplaysOnlyCommittedPages) +{ + NANDJournal::Pages loaded; + std::string error; + u64 committed_size; + { + NANDJournal journal; + ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error)); + ASSERT_TRUE(journal.Append({{1, Page(0x11)}})); + committed_size = File::GetSize(m_path); + ASSERT_TRUE(journal.Append({{2, Page(0x22)}, {3, Page(0x33)}})); + } + { + File::IOFile interrupted(m_path, "r+b"); + ASSERT_TRUE(interrupted.Resize(interrupted.GetSize() - 5)); + } + const auto interrupted_size = File::GetSize(m_path); + { + NANDJournal recovered; + ASSERT_TRUE(recovered.Open(m_path, m_digest, &loaded, &error)) << error; + EXPECT_EQ(loaded, (NANDJournal::Pages{{1, Page(0x11)}})); + EXPECT_EQ(File::GetSize(m_path), committed_size); + EXPECT_EQ(File::GetSize(m_path + ".interrupted"), interrupted_size); + ASSERT_TRUE(recovered.Append({{4, Page(0x44)}})); + } + NANDJournal reopened; + ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error)); + EXPECT_EQ(loaded, (NANDJournal::Pages{{1, Page(0x11)}, {4, Page(0x44)}})); +} + +TEST_F(StarletNANDJournal, RejectsCorruptionWithoutReplacingExistingView) +{ + NANDJournal::Pages loaded; + std::string error; + { + NANDJournal journal; + ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error)); + ASSERT_TRUE(journal.Append({{1, Page(0x11)}})); + } + { + File::IOFile corrupt(m_path, "r+b"); + ASSERT_TRUE(corrupt.Seek(32 + 12 + 4, File::SeekOrigin::Begin)); + const u8 wrong_byte = 0x99; + ASSERT_TRUE(corrupt.WriteArray(&wrong_byte, 1)); + } + loaded = {{99, Page(0x55)}}; + const auto size = File::GetSize(m_path); + NANDJournal rejected; + EXPECT_FALSE(rejected.Open(m_path, m_digest, &loaded, &error)); + EXPECT_EQ(loaded, (NANDJournal::Pages{{99, Page(0x55)}})); + EXPECT_EQ(File::GetSize(m_path), size); +} + +TEST_F(StarletNANDJournal, UnopenedJournalDoesNotAcceptVolatileWrites) +{ + NANDJournal journal; + EXPECT_FALSE(journal.Append({{0, Page(0)}})); +}