// 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)}})); }