Zarchive Loading (#724)

* Simplify archive loading and harden filesystem integration

* Restore directory entry name length assertion

* Keep graphics-dependent filesystem tests out of CI

---------

Co-authored-by: nmzik <Nmzik@mail.ru>
This commit is contained in:
Aspen
2026-09-28 16:06:44 -04:00
committed by GitHub
parent 8e61798bfd
commit 4b0de1b672
29 changed files with 1956 additions and 262 deletions
+400
View File
@@ -0,0 +1,400 @@
#include "common/file.h"
#include "common/archive.h"
#include "common/stringUtils.h"
#include "ArchiveTestFixture.h"
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <limits>
#include <string>
#include <thread>
#include <vector>
#include <zarchive/zarchivecommon.h>
#if !defined(_WIN32) || defined(_GLIBCXX_GCC_GTHR_POSIX_H)
#include <pthread.h>
#endif
namespace {
void Check(bool value, const char *text) {
if (!value) {
std::fprintf(stderr, "ArchiveFileTests: failed: %s\n", text);
std::abort();
}
}
class TempDirectory {
public:
TempDirectory() {
const auto unique =
std::chrono::steady_clock::now().time_since_epoch().count();
m_path = std::filesystem::temp_directory_path() /
("kyty_zarchive_test_!_" + std::to_string(unique));
Check(std::filesystem::create_directories(m_path),
"create temporary directory");
}
~TempDirectory() {
std::error_code error;
std::filesystem::remove_all(m_path, error);
}
[[nodiscard]] const std::filesystem::path &Path() const { return m_path; }
KYTY_CLASS_NO_COPY(TempDirectory);
private:
std::filesystem::path m_path;
};
template <typename T>
void SetValue(std::vector<std::byte> &bytes, size_t offset, T value) {
value = _ZARCHIVE::_store(value);
Check(offset <= bytes.size() && sizeof(value) <= bytes.size() - offset,
"patch lies inside fixture");
std::memcpy(bytes.data() + offset, &value, sizeof(value));
}
void SetMemberNameOffset(std::vector<std::byte> &bytes,
const _ZARCHIVE::Footer &footer, uint32_t name_offset) {
const auto entry_offset = footer.sectionFileTree.offset + sizeof(_ZARCHIVE::FileDirectoryEntry);
uint32_t original = 0;
std::memcpy(&original, bytes.data() + entry_offset, sizeof(original));
SetValue(bytes, entry_offset, (_ZARCHIVE::_load(original) & 0x80000000u) | name_offset);
}
template <typename Edit>
std::filesystem::path MutateArchive(const std::filesystem::path &source,
const char *name, Edit edit) {
Common::File input(source, Common::File::Mode::Read);
auto bytes = input.ReadWholeBuffer();
_ZARCHIVE::Footer footer{};
Check(bytes.size() >= sizeof(footer), "fixture has a footer");
std::memcpy(&footer, bytes.data() + bytes.size() - sizeof(footer), sizeof(footer));
_ZARCHIVE::Footer::Deserialize(&footer, &footer);
edit(bytes, footer);
_ZARCHIVE::Footer::Serialize(&footer, &footer);
std::memcpy(bytes.data() + bytes.size() - sizeof(footer), &footer, sizeof(footer));
const auto destination = source.parent_path() / name;
Common::File output;
Check(output.Create(destination), "create malformed archive fixture");
uint32_t written = 0;
output.Write(bytes.data(), static_cast<uint32_t>(bytes.size()), &written);
Check(written == bytes.size(), "write malformed archive fixture");
return destination;
}
void CheckMalformedArchives(const std::filesystem::path &source,
const std::vector<uint8_t> &payload) {
const auto bad_name = MutateArchive(source, "bad-name.zar", [](auto &bytes, auto &footer) {
SetMemberNameOffset(bytes, footer, static_cast<uint32_t>(footer.sectionNames.size));
});
Check(!Common::OpenArchive(bad_name), "reject name offset at the end of name table");
const auto bad_length = MutateArchive(source, "bad-length.zar", [](auto &bytes, auto &footer) {
SetMemberNameOffset(bytes, footer, static_cast<uint32_t>(footer.sectionNames.size - 1));
bytes[footer.sectionNames.offset + footer.sectionNames.size - 1] = std::byte{0x80};
});
Check(!Common::OpenArchive(bad_length), "reject truncated extended name length");
const auto bad_children = MutateArchive(source, "bad-children.zar", [](auto &bytes, auto &footer) {
SetValue(bytes, footer.sectionFileTree.offset + sizeof(uint32_t), UINT32_MAX);
});
Check(!Common::OpenArchive(bad_children), "reject invalid directory children without throwing");
const auto bad_range = MutateArchive(source, "bad-range.zar", [](auto &, auto &footer) {
footer.sectionCompressedData.offset = std::numeric_limits<uint64_t>::max();
});
Check(!Common::OpenArchive(bad_range), "reject overflowing footer section range");
const auto truncated = source.parent_path() / "truncated.zar";
Check(std::filesystem::copy_file(source, truncated), "copy truncated archive fixture");
std::filesystem::resize_file(truncated, std::filesystem::file_size(truncated) - 1);
Check(!Common::OpenArchive(truncated), "reject missing footer byte");
const auto corrupted = MutateArchive(source, "corrupt-data.zar", [](auto &bytes, auto &footer) {
_ZARCHIVE::CompressionOffsetRecord record{};
std::memcpy(&record, bytes.data() + footer.sectionOffsetRecords.offset, sizeof(record));
_ZARCHIVE::CompressionOffsetRecord::Deserialize(&record, 1, &record);
const auto second_block = footer.sectionCompressedData.offset + record.baseOffset + record.size[0] + 1;
std::fill_n(bytes.data() + second_block, record.size[1] + 1, std::byte{});
});
Common::File damaged(Common::MakeArchivePath(corrupted, "assets/subdir/data.bin"),
Common::File::Mode::Read);
Check(!damaged.IsInvalid(), "open archive with damaged compressed data");
std::vector<uint8_t> bytes(payload.size());
uint32_t read = 0;
damaged.Read(bytes.data(), static_cast<uint32_t>(bytes.size()), &read);
Check(read > 0 && read < 64 * 1024 && damaged.Tell() == read &&
std::equal(bytes.begin(), bytes.begin() + read, payload.begin()),
"report the valid prefix before a corrupt compressed block");
Check(damaged.Seek(2 * 64 * 1024), "seek past corrupt compressed block");
damaged.Read(bytes.data(), 97, &read);
Check(read == 97 && std::equal(bytes.begin(), bytes.begin() + read,
payload.begin() + 2 * 64 * 1024),
"recover by reading an unaffected compressed block");
}
#if !defined(_WIN32) || defined(_GLIBCXX_GCC_GTHR_POSIX_H)
void CheckCancellation(const std::filesystem::path &path,
const std::vector<uint8_t> &payload) {
// Keep the file and destination on the parent thread: cancellation must only release the
// reader thread's request and staging buffer after the worker has finished using them.
struct CancelledRead {
Common::File file;
std::vector<uint8_t> data;
std::atomic_bool ready = false;
std::atomic_bool start = false;
uint32_t bytes = 0;
} read;
Check(read.file.Open(path, Common::File::Mode::Read), "open cancellation fixture");
read.data.resize(payload.size());
pthread_t thread;
Check(pthread_create(&thread, nullptr, [](void *opaque) -> void * {
auto &read = *static_cast<CancelledRead *>(opaque);
read.ready = true;
while (!read.start.load()) {}
read.file.Read(read.data.data(), static_cast<uint32_t>(read.data.size()), &read.bytes);
pthread_testcancel();
return nullptr;
}, &read) == 0, "start cancellable archive reader");
while (!read.ready.load()) {}
Check(pthread_cancel(thread) == 0, "queue cancellation before the archive worker wait");
read.start = true;
void *result = nullptr;
Check(pthread_join(thread, &result) == 0 && result == PTHREAD_CANCELED,
"restore cancellation after the archive read");
Check(read.bytes == payload.size() && read.data == payload,
"finish worker reads and copies before releasing a cancelled caller");
int previous = PTHREAD_CANCEL_ENABLE;
int observed = PTHREAD_CANCEL_ENABLE;
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &previous);
read.file.Seek(0);
read.file.Read(read.data.data(), 97, &read.bytes);
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &observed);
pthread_setcancelstate(previous, nullptr);
Check(observed == PTHREAD_CANCEL_DISABLE && read.bytes == 97,
"preserve a caller's disabled cancellation state");
}
#endif
} // namespace
int main() {
TempDirectory temporary;
const auto archive_path =
temporary.Path() / std::filesystem::path(u8"game-テスト.zar");
std::vector<uint8_t> payload(3 * 1024 * 1024 + 37);
for (size_t index = 0; index < payload.size(); index++) {
payload[index] = static_cast<uint8_t>((index * 37 + 11) & 0xff);
}
Check(ArchiveTests::CreateArchive(archive_path, payload), "create archive fixture");
const auto native_file = Common::File::GetInfo(archive_path);
const auto native_directory = Common::File::GetInfo(temporary.Path() / "");
Check(native_file && native_file->is_file &&
native_file->size == std::filesystem::file_size(archive_path),
"query native file type and size together");
Check(native_directory && !native_directory->is_file && native_directory->size == 0,
"query native directory with trailing separator");
Check(!Common::File::GetInfo(archive_path / "") && Common::File::Size(archive_path / "") == 0 &&
!Common::File::GetInfo(temporary.Path() / "missing.bin"),
"reject missing native paths and files with a trailing separator");
const auto root = Common::MakeArchivePath(archive_path);
auto reader_pin = Common::OpenArchive(archive_path);
Check(reader_pin && Common::OpenArchive(root) == reader_pin,
"mounted archive pin reuses the same reader and index");
const auto archive_file = Common::File::GetInfo(root / "assets/subdir/data.bin");
const auto archive_directory = Common::File::GetInfo(root / "assets/");
Check(archive_file && archive_file->is_file && archive_file->size == payload.size(),
"query archive member type and size together");
Check(archive_directory && !archive_directory->is_file && archive_directory->size == 0 &&
!Common::File::GetInfo(root / "missing.bin"),
"distinguish archive directories from missing members");
Check(Common::IsSupportedArchive(archive_path) &&
Common::IsSupportedArchive("GAME.ZAR") &&
!Common::IsSupportedArchive("game.zip"),
"select an archive backend by supported extension");
Check(!Common::IsArchivePath(temporary.Path() / "ordinary!" / "asset.bin"),
"ordinary host paths with exclamation marks stay host paths");
Check(Common::IsArchivePath(root), "recognize virtual archive root");
Check(Common::GetArchiveHostPath(root) == archive_path,
"recover host archive path");
Check(Common::File::IsDirectoryExisting(root), "find archive root directory");
Check(Common::File::IsFileExisting(root / "EBOOT.BIN"),
"lookup is case insensitive");
Check(!Common::File::IsFileExisting(root / "missing.bin"),
"reject missing member");
Check(Common::IsArchivePath(root / "../outside.bin"),
"retain archive classification for invalid traversal");
Check(!Common::File::IsFileExisting(root / "../outside.bin"),
"reject leading parent traversal");
Check(Common::File::IsFileExisting(root / "assets/../eboot.bin"),
"normalize internal parent traversal");
Check(Common::File::IsFileExisting(root / ArchiveTests::LongFilename),
"decode extended-length archive names");
Check(Common::File::IsFileExisting(root / Common::PathFromUtf8(ArchiveTests::UnicodeFilename)),
"lookup Unicode archive member");
const auto entries = Common::File::GetDirEntries(root);
Check(std::ranges::any_of(entries,
[](const auto &entry) {
return entry.is_file && entry.name == "eboot.bin";
}),
"enumerate root file");
Check(std::ranges::any_of(entries,
[](const auto &entry) {
return !entry.is_file && entry.name == "assets";
}),
"enumerate root directory");
Check(std::ranges::any_of(entries, [](const auto &entry) {
return entry.is_file && entry.name == ArchiveTests::UnicodeFilename;
}), "enumerate Unicode member names as UTF-8");
const auto data_path = root / "assets/subdir/data.bin";
Check(Common::File::Size(data_path) == payload.size(), "report member size");
Common::File file(data_path, Common::File::Mode::Read);
Check(!file.IsInvalid(), "open archive member");
Check(file.Remaining() == payload.size(), "report initial remaining bytes");
Check(file.Seek(64 * 1024 - 19), "seek before compression block boundary");
std::vector<uint8_t> slice(97);
uint32_t bytes_read = 0;
file.Read(slice.data(), static_cast<uint32_t>(slice.size()), &bytes_read);
Check(bytes_read == slice.size(), "read across compression block boundary");
Check(
std::equal(slice.begin(), slice.end(), payload.begin() + 64 * 1024 - 19),
"preserve data across compression block boundary");
Check(file.Seek(payload.size() - 9), "seek near member end");
file.Read(slice.data(), static_cast<uint32_t>(slice.size()), &bytes_read);
Check(bytes_read == 9, "truncate read at member end");
Check(file.IsEOF(), "report member end of file");
Check(file.Seek(payload.size() + 100), "allow seek past member end");
Check(file.Remaining() == 0, "report no bytes remaining past member end");
file.Read(slice.data(), static_cast<uint32_t>(slice.size()), &bytes_read);
Check(bytes_read == 0, "read past member end returns no data");
Check(file.Seek(0), "seek to member start");
std::vector<uint8_t> full(payload.size());
file.Read(full.data(), static_cast<uint32_t>(full.size()), &bytes_read);
Check(bytes_read == full.size(), "read a multi-chunk member");
Check(full == payload, "preserve a multi-chunk member");
std::atomic_bool concurrent_reads_ok = true;
std::vector<std::thread> readers;
for (uint32_t worker = 0; worker < 8; worker++) {
readers.emplace_back([&, worker] {
Common::File concurrent_file(data_path, Common::File::Mode::Read);
std::array<uint8_t, 4096> concurrent_data{};
for (uint32_t iteration = 0; iteration < 16; iteration++) {
const auto offset =
static_cast<size_t>(worker * 7919 + iteration * 12347) %
(payload.size() - concurrent_data.size());
uint32_t concurrent_bytes_read = 0;
if (concurrent_file.IsInvalid() || !concurrent_file.Seek(offset)) {
concurrent_reads_ok = false;
return;
}
concurrent_file.Read(concurrent_data.data(), concurrent_data.size(),
&concurrent_bytes_read);
if (concurrent_bytes_read != concurrent_data.size() ||
!std::equal(concurrent_data.begin(), concurrent_data.end(),
payload.begin() +
static_cast<std::ptrdiff_t>(offset))) {
concurrent_reads_ok = false;
return;
}
}
});
}
for (auto &reader : readers) {
reader.join();
}
Check(concurrent_reads_ok, "read concurrently through independent file handles");
#if !defined(_WIN32) || defined(_GLIBCXX_GCC_GTHR_POSIX_H)
CheckCancellation(data_path, payload);
#endif
#if !defined(_WIN32)
// Guest threads have much smaller stacks than std::thread's default stack.
pthread_attr_t attributes;
Check(pthread_attr_init(&attributes) == 0 &&
pthread_attr_setstacksize(&attributes, 64 * 1024) == 0,
"configure a guest-sized stack");
struct SmallStackRead {
const std::filesystem::path &path;
const std::vector<uint8_t> &expected;
bool passed = false;
} small_stack{data_path, payload};
pthread_t thread;
Check(pthread_create(&thread, &attributes, [](void *opaque) -> void * {
auto &test = *static_cast<SmallStackRead *>(opaque);
Common::File input(test.path, Common::File::Mode::Read);
auto data = input.ReadWholeBuffer();
test.passed = data.size() == test.expected.size() &&
std::memcmp(data.data(), test.expected.data(), data.size()) == 0;
return nullptr;
}, &small_stack) == 0, "start guest-sized-stack reader");
pthread_attr_destroy(&attributes);
Check(pthread_join(thread, nullptr) == 0 && small_stack.passed,
"decompress a multi-block file on a guest-sized stack");
#endif
Common::File metadata(root / "sce_sys/param.json", Common::File::Mode::Read);
const auto metadata_data = metadata.ReadWholeBuffer();
const std::string metadata_text(
reinterpret_cast<const char *>(metadata_data.data()),
metadata_data.size());
Check(metadata_text.find("Archive Test") != std::string::npos,
"read metadata member");
Common::File writable;
Check(!writable.Open(data_path, Common::File::Mode::ReadWrite),
"reject writable member open");
Check(!Common::File::DeleteFile(data_path), "reject member deletion");
Check(!Common::File::CreateDirectory(root / "new-dir"),
"reject member directory creation");
Check(!file.Truncate(0) && !file.Unlink(), "reject descriptor mutations");
const auto copied = temporary.Path() / "copied.bin";
Common::File host_fixture;
Check(host_fixture.Create(copied), "create host mutation fixture");
host_fixture.Close();
Check(!Common::File::CopyFile(data_path, copied) &&
!Common::File::CopyFile(copied, data_path) &&
!Common::File::RenameFile(data_path, copied) &&
!Common::File::RenameFile(copied, data_path),
"reject unsupported copy and rename operations involving archives");
CheckMalformedArchives(archive_path, payload);
file.Close();
metadata.Close();
Check(Common::OpenArchive(archive_path) == reader_pin,
"mount pin retains the archive reader after the last member closes");
reader_pin.reset();
std::error_code remove_error;
Check(std::filesystem::remove(archive_path, remove_error) && !remove_error,
"release archive handle after the final reader closes");
const std::array<uint8_t, 3> replacement{42, 7, 19};
Check(ArchiveTests::CreateArchive(archive_path, replacement), "replace a closed archive");
Common::File replaced(data_path, Common::File::Mode::Read);
const auto replaced_data = replaced.ReadWholeBuffer();
Check(replaced_data.size() == replacement.size() &&
std::memcmp(replaced_data.data(), replacement.data(), replacement.size()) == 0,
"reopening after the final reader closes sees replacement contents");
std::printf("ArchiveFileTests: all cases passed\n");
return 0;
}
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <span>
#include <string>
#include <string_view>
#include <zarchive/zarchivewriter.h>
namespace ArchiveTests {
inline constexpr std::string_view Eboot = "ELF fixture";
inline constexpr std::string_view Param =
R"({"titleId":"TEST00001","titleName":"Archive Test"})";
inline constexpr std::string_view UnicodeFilename =
"asset-\xc3\xa9-\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e.bin";
inline const std::string LongFilename = std::string(160, 'a') + ".bin";
// Both Common::File and guest filesystem tests read the same archive layout.
inline bool CreateArchive(const std::filesystem::path &path,
std::span<const uint8_t> payload) {
struct Output {
std::filesystem::path path;
std::ofstream stream;
bool good = true;
} output{.path = path};
{
ZArchiveWriter writer(
[](int32_t, void *opaque) {
auto &out = *static_cast<Output *>(opaque);
out.stream.open(out.path, std::ios::binary | std::ios::trunc);
out.good = out.stream.is_open();
},
[](const void *data, size_t size, void *opaque) {
auto &out = *static_cast<Output *>(opaque);
out.stream.write(static_cast<const char *>(data),
static_cast<std::streamsize>(size));
out.good = out.good && out.stream.good();
},
&output);
if (!output.good || !writer.MakeDir("sce_sys", true) ||
!writer.MakeDir("assets/subdir", true)) {
return false;
}
const auto add = [&](std::string_view name, const void *data, size_t size) {
if (!writer.StartNewFile(name.data())) {
return false;
}
writer.AppendData(data, size);
return true;
};
if (!add("eboot.bin", Eboot.data(), Eboot.size()) ||
!add("sce_sys/param.json", Param.data(), Param.size()) ||
!add("assets/subdir/data.bin", payload.data(), payload.size()) ||
!add(UnicodeFilename, Eboot.data(), Eboot.size()) ||
!add(LongFilename, Eboot.data(), Eboot.size())) {
return false;
}
writer.Finalize();
}
output.stream.close();
return output.good && !output.stream.fail();
}
} // namespace ArchiveTests
+134
View File
@@ -0,0 +1,134 @@
#include "libs/avPlayer.cpp"
#include "common/archive.h"
#include "ArchiveTestFixture.h"
#include <array>
#include <chrono>
namespace {
std::filesystem::path content_root;
}
namespace Libs::LibKernel {
int KYTY_SYSV_ABI PthreadCreate(Pthread*, const PthreadAttr*, pthread_entry_func_t, void*, const char*) {
std::abort();
}
int KYTY_SYSV_ABI PthreadJoin(Pthread, void**) { std::abort(); }
namespace FileSystem {
std::filesystem::path GetRealFilename(const std::string& path) {
return path.starts_with("/app0/") ? content_root / path.substr(6) : std::filesystem::path {};
}
}
}
namespace {
using namespace Libs::Audio::AvPlayer;
void Check(bool condition, const char* message) {
if (!condition) {
std::fprintf(stderr, "AvPlayerFileTests: %s\n", message);
std::abort();
}
}
void CheckStream(FileStreamer& stream, const std::vector<uint8_t>& data) {
auto* io = stream.Context();
Check(io->seek(io->opaque, 0, AVSEEK_SIZE) == static_cast<int64_t>(data.size()), "query size");
Check(io->seek(io->opaque, 65531, SEEK_SET | AVSEEK_FORCE) == 65531, "seek across block boundary");
std::array<uint8_t, 32> bytes {};
Check(io->read_packet(io->opaque, bytes.data(), bytes.size()) == bytes.size() &&
std::equal(bytes.begin(), bytes.end(), data.begin() + 65531), "read across block boundary");
Check(io->seek(io->opaque, -16, SEEK_CUR) == 65547, "seek relative to cursor");
Check(io->seek(io->opaque, INT64_MIN, SEEK_CUR) < 0 &&
io->seek(io->opaque, INT64_MAX, SEEK_END) < 0 &&
io->seek(io->opaque, 0, 12345) < 0, "reject invalid and overflowing seeks");
Check(io->seek(io->opaque, 0, SEEK_CUR) == 65547, "rejected seeks preserve the cursor");
Check(io->seek(io->opaque, -7, SEEK_END) == static_cast<int64_t>(data.size() - 7), "seek near end");
Check(io->read_packet(io->opaque, bytes.data(), bytes.size()) == 7 &&
std::equal(bytes.begin(), bytes.begin() + 7, data.end() - 7), "short read at end");
Check(io->read_packet(io->opaque, bytes.data(), bytes.size()) == AVERROR_EOF, "report EOF");
Check(io->seek(io->opaque, 11, SEEK_END) == static_cast<int64_t>(data.size() + 11) &&
io->read_packet(io->opaque, bytes.data(), bytes.size()) == AVERROR_EOF, "read beyond end");
Check(io->seek(io->opaque, 0, SEEK_SET) == 0, "rewind");
std::vector<uint8_t> buffered(data.size());
Check(avio_read(io, buffered.data(), static_cast<int>(buffered.size())) == buffered.size() &&
buffered == data, "FFmpeg buffered reads preserve content");
Check(avio_seek(io, 123, SEEK_SET) == 123 &&
avio_read(io, bytes.data(), bytes.size()) == bytes.size() &&
std::equal(bytes.begin(), bytes.end(), data.begin() + 123), "FFmpeg seeks reset buffered reads");
}
struct Callbacks {
const std::vector<uint8_t>& data;
unsigned opens = 0;
unsigned closes = 0;
bool premature_eof = false;
};
int KYTY_SYSV_ABI Open(void* object, const char*) {
++static_cast<Callbacks*>(object)->opens;
return 0;
}
int KYTY_SYSV_ABI Close(void* object) {
++static_cast<Callbacks*>(object)->closes;
return 0;
}
int KYTY_SYSV_ABI Read(void* object, uint8_t* buffer, uint64_t offset, uint32_t size) {
auto& state = *static_cast<Callbacks*>(object);
if (state.premature_eof) {
return 0;
}
const auto bytes = std::min<uint64_t>(size, state.data.size() - offset);
std::memcpy(buffer, state.data.data() + offset, bytes);
return static_cast<int>(bytes);
}
uint64_t KYTY_SYSV_ABI Size(void* object) { return static_cast<Callbacks*>(object)->data.size(); }
}
int main() {
const auto root = std::filesystem::temp_directory_path() /
("kyty_avio_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
Check(std::filesystem::create_directories(root), "create fixture directory");
std::vector<uint8_t> payload(128 * 1024 + 37);
for (size_t i = 0; i < payload.size(); ++i) {
payload[i] = static_cast<uint8_t>(i * 37);
}
{
Common::File output;
Check(output.Create(root / "movie.bin"), "create native fixture");
output.Write(payload.data(), static_cast<uint32_t>(payload.size()));
}
Check(ArchiveTests::CreateArchive(root / "game.zar", payload), "create archive fixture");
content_root = root;
{
FileStreamer native({});
Check(native.Init("/app0/movie.bin"), "open native stream");
CheckStream(native, payload);
}
content_root = Common::MakeArchivePath(root / "game.zar");
{
FileStreamer archive({});
Check(archive.Init("/app0/assets/subdir/data.bin"), "open archived stream");
CheckStream(archive, payload);
}
Callbacks callbacks {payload};
const AvPlayerFileReplacement replacement {&callbacks, Open, Close, Read, Size};
{
FileStreamer callback(replacement);
Check(callback.Init("callback"), "open guest callback stream");
CheckStream(callback, payload);
callbacks.premature_eof = true;
auto* io = callback.Context();
Check(io->seek(io->opaque, 0, SEEK_SET) == 0, "rewind callback");
uint8_t byte = 0;
Check(io->read_packet(io->opaque, &byte, 1) == AVERROR(EIO), "reject premature EOF");
}
Check(callbacks.opens == 1 && callbacks.closes == 1, "close callback exactly once");
{
FileStreamer missing({});
Check(!missing.Init("/unmounted/movie.bin"), "reject unmapped media");
}
std::error_code error;
std::filesystem::remove_all(root, error);
Check(!error, "release all archive handles");
std::puts("AvPlayerFileTests: all cases passed");
}
+117 -1
View File
@@ -2,7 +2,9 @@
#include <SDL3/SDL_main.h>
#include "common/emulatorConfig.h"
#include "common/archive.h"
#include "common/file.h"
#include "ArchiveTestFixture.h"
#include "common/logging/log.h"
#include "common/subsystems.h"
#include "common/threads.h"
@@ -214,6 +216,119 @@ void CheckUnmappedPaths(const std::filesystem::path &root) {
FileSystem::Umount("/app0");
}
void CheckArchiveMount(const std::filesystem::path &root) {
const auto archive = root / u8"game-日本語.zar";
std::vector<uint8_t> payload(2 * 64 * 1024 + 33);
for (size_t i = 0; i < payload.size(); ++i) {
payload[i] = static_cast<uint8_t>(i * 37 + 11);
}
Check(ArchiveTests::CreateArchive(archive, payload), "create mounted archive fixture");
const auto archive_root = Common::MakeArchivePath(archive);
const auto host_member = archive_root / "assets/subdir/data.bin";
constexpr char GuestMember[] = "/app0/assets/subdir/data.bin";
FileSystem::Mount(archive_root, "/app0");
Check(FileSystem::GetRealFilename(GuestMember) == host_member &&
FileSystem::GetRealFilename("/app01/eboot.bin").empty(),
"resolve archive mounts at path-component boundaries");
Check(FileSystem::KernelOpen("/app0/../outside.bin", 0, 0) ==
Libs::LibKernel::KERNEL_ERROR_ENOENT &&
FileSystem::KernelOpen("/app0/missing.bin", 0, 0) ==
Libs::LibKernel::KERNEL_ERROR_ENOENT,
"reject traversal outside an archive and missing members");
FileSystem::FileStat path_stat{}, descriptor_stat{};
Check(FileSystem::KernelStat(GuestMember, &path_stat) == OK &&
path_stat.st_size == payload.size() &&
path_stat.st_size == Common::File::Size(host_member) &&
FileSystem::KernelCheckReachability(GuestMember) == OK,
"archive path stat and reachability agree with Common::File");
const int fd = FileSystem::KernelOpen("/app0/ASSETS/subdir/DATA.BIN", 0, 0);
Check(fd >= 3 && FileSystem::KernelFstat(fd, &descriptor_stat) == OK &&
descriptor_stat.st_size == path_stat.st_size &&
descriptor_stat.st_mode == path_stat.st_mode,
"case-insensitive archive open and descriptor stat agree");
std::array<uint8_t, 97> bytes{};
constexpr int64_t Offset = 64 * 1024 - 19;
Check(FileSystem::KernelPread(fd, bytes.data(), bytes.size(), Offset) == bytes.size() &&
std::equal(bytes.begin(), bytes.end(), payload.begin() + Offset) &&
FileSystem::KernelLseek(fd, 0, 1) == 0,
"archive pread crosses a compression block without moving position");
Check(FileSystem::KernelLseek(fd, Offset, 0) == Offset &&
FileSystem::KernelRead(fd, bytes.data(), bytes.size()) == bytes.size() &&
std::equal(bytes.begin(), bytes.end(), payload.begin() + Offset) &&
FileSystem::KernelLseek(fd, 0, 1) == Offset + bytes.size(),
"archive seek and sequential read share descriptor position");
Check(FileSystem::KernelLseek(fd, -9, 2) == payload.size() - 9 &&
FileSystem::KernelRead(fd, bytes.data(), bytes.size()) == 9 &&
std::equal(bytes.begin(), bytes.begin() + 9, payload.end() - 9) &&
FileSystem::KernelRead(fd, bytes.data(), bytes.size()) == 0,
"archive reads stop at the member boundary");
Check(FileSystem::KernelWrite(fd, bytes.data(), 1) ==
Libs::LibKernel::KERNEL_ERROR_EBADF &&
FileSystem::KernelPwrite(fd, bytes.data(), 1, 0) ==
Libs::LibKernel::KERNEL_ERROR_EBADF &&
FileSystem::KernelFtruncate(fd, 0) ==
Libs::LibKernel::KERNEL_ERROR_EBADF,
"archive read-only descriptors reject write and truncate");
const auto unicode_guest = std::string("/app0/") + std::string(ArchiveTests::UnicodeFilename);
const int unicode = FileSystem::KernelOpen(unicode_guest.c_str(), 0, 0);
Check(unicode >= 3 && FileSystem::KernelRead(unicode, bytes.data(), bytes.size()) ==
ArchiveTests::Eboot.size() &&
std::memcmp(bytes.data(), ArchiveTests::Eboot.data(), ArchiveTests::Eboot.size()) == 0,
"open and read a Unicode archive member");
Check(FileSystem::KernelClose(unicode) == OK, "close Unicode archive member");
const int directory = FileSystem::KernelOpen("/app0/", 0x00020000, 0);
Check(directory >= 3, "open mounted archive directory");
std::array<char, 512> block{};
const auto expected_entries = Common::File::GetDirEntries(archive_root);
size_t entries_seen = 0;
for (;;) {
const int length = FileSystem::KernelGetdents(directory, block.data(), block.size());
Check(length >= 0 && length <= block.size(), "read archive directory records");
if (length == 0) {
break;
}
for (size_t offset = 0; offset < static_cast<size_t>(length);) {
uint16_t record_length = 0;
Check(length - offset >= 8, "archive directory record header fits");
std::memcpy(&record_length, block.data() + offset + 4, sizeof(record_length));
const auto name_length = static_cast<uint8_t>(block[offset + 7]);
Check(record_length >= 9 + name_length && record_length <= length - offset,
"archive directory record fits");
const std::string_view name(block.data() + offset + 8, name_length);
Check(std::any_of(expected_entries.begin(), expected_entries.end(), [&](const auto &entry) {
return entry.name == name && block[offset + 6] == (entry.is_file ? 8 : 4);
}), "guest directory entry matches Common::File name and type");
++entries_seen;
offset += record_length;
}
}
Check(entries_seen == expected_entries.size(), "guest enumerates every archive directory entry");
Check(FileSystem::KernelClose(directory) == OK, "close archive directory");
for (const int flags : {1, 2, 0x0200, 0x0400}) {
Check(FileSystem::KernelOpen(GuestMember, flags, 0777) ==
Libs::LibKernel::KERNEL_ERROR_EROFS,
"archive rejects write, create and truncate open flags");
}
Check(FileSystem::KernelUnlink(GuestMember) == Libs::LibKernel::KERNEL_ERROR_EROFS &&
FileSystem::KernelMkdir("/app0/new-dir", 0777) == Libs::LibKernel::KERNEL_ERROR_EROFS &&
FileSystem::KernelRmdir("/app0/assets") == Libs::LibKernel::KERNEL_ERROR_EROFS &&
FileSystem::KernelRename(GuestMember, "/app0/renamed.bin") ==
Libs::LibKernel::KERNEL_ERROR_EROFS,
"archive mount rejects path mutations");
FileSystem::Umount("/app0");
Check(FileSystem::GetRealFilename(GuestMember).empty() &&
FileSystem::KernelOpen(GuestMember, 0, 0) == Libs::LibKernel::KERNEL_ERROR_ENOENT &&
FileSystem::KernelLseek(fd, 0, 0) == 0 &&
FileSystem::KernelRead(fd, bytes.data(), bytes.size()) == bytes.size() &&
std::equal(bytes.begin(), bytes.end(), payload.begin()),
"unmount hides archive paths while open descriptors retain the reader");
Check(FileSystem::KernelClose(fd) == OK, "close last archive descriptor");
}
void CheckUnicodePaths(const std::filesystem::path &root) {
constexpr std::string_view HostDirectory =
"Test\xc3\xa9-\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e";
@@ -567,7 +682,7 @@ void CheckSocketWakeup() {
} // namespace
int main() {
int main(int, char**) {
Common::InitializeThreads();
Common::Subsystems subsystems;
subsystems.Initialize<Config::Lifecycle>();
@@ -589,6 +704,7 @@ int main() {
FileSystem::Initialize();
CheckMountRoot(temporary.Path());
CheckUnmappedPaths(temporary.Path());
CheckArchiveMount(temporary.Path());
CheckUnicodePaths(temporary.Path());
CheckUnicodeLogPath(temporary.Path());
CheckDirectoryStream(temporary.Path());