mirror of
https://github.com/KytyPS5/KytyPS5.git
synced 2026-10-28 05:46:56 +01:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user