IOS: persist Starlet NAND writes across restarts
Keep the source dump immutable and replay checksummed copy-on-write transactions from the active user profile. Persist program, erase, pairing and restored snapshot writes. Preserve interrupted tails before recovery and reject corrupt journals or concurrent writers. Validated in the current build with 127 targeted tests and a user-confirmed channel deletion surviving process exit and restart; 2944 saved NAND pages reloaded successfully.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
// Copyright 2026 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#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)}}));
|
||||
}
|
||||
Reference in New Issue
Block a user