diff --git a/apps/components_tests/CMakeLists.txt b/apps/components_tests/CMakeLists.txt index 7595681313..a92fbfe5f0 100644 --- a/apps/components_tests/CMakeLists.txt +++ b/apps/components_tests/CMakeLists.txt @@ -88,6 +88,8 @@ file(GLOB UNITTEST_SRC_FILES vfs/testpathutil.cpp sceneutil/osgacontroller.cpp + + bsa/testcompressedbsafile.cpp ) source_group(apps\\components-tests FILES ${UNITTEST_SRC_FILES}) diff --git a/apps/components_tests/bsa/testcompressedbsafile.cpp b/apps/components_tests/bsa/testcompressedbsafile.cpp new file mode 100644 index 0000000000..5a3aefc841 --- /dev/null +++ b/apps/components_tests/bsa/testcompressedbsafile.cpp @@ -0,0 +1,390 @@ +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Bsa +{ + namespace + { + auto makeTuple(const BSAFile::Hash& value) + { + return std::make_tuple(value.mLow, value.mHigh); + } + + auto makeTuple(const BSAFile::FileStruct& value) + { + return std::make_tuple(value.mFileSize, value.mOffset, makeTuple(value.mHash), value.mNameOffset, + value.mNameSize, value.name()); + } + } + + inline std::ostream& operator<<(std::ostream& stream, const BSAFile::Hash& value) + { + return stream << "Hash { .mLow = " << value.mLow << ", .mHigh = " << value.mHigh << "}"; + } + + inline std::ostream& operator<<(std::ostream& stream, const BSAFile::FileStruct& value) + { + return stream << "FileStruct { .mFileSize = " << value.mFileSize << ", .mOffset = " << value.mOffset + << ", .mHash = " << value.mHash << ", .mNameOffset = " << value.mNameOffset + << ", .mNameSize = " << value.mNameSize << ", .name() = " << value.name() << "}"; + } + + inline bool operator==(const BSAFile::FileStruct& lhs, const BSAFile::FileStruct& rhs) + { + return makeTuple(lhs) == makeTuple(rhs); + } + + namespace + { + using namespace ::testing; + + struct FileRecord + { + std::uint64_t mHash; + std::uint32_t mSize; + std::uint32_t mOffset; + std::string mName; + }; + + struct NonSSEFolderRecord + { + std::uint64_t mHash; + std::uint32_t mCount; + std::int32_t mOffset; + std::string mName; + std::vector mFiles; + }; + + struct Archive + { + CompressedBSAFile::Header mHeader; + std::vector mFolders; + }; + + void writeArchive(const Archive& value, std::ostream& stream) + { + stream.write(reinterpret_cast(&value.mHeader), sizeof(value.mHeader)); + + for (const NonSSEFolderRecord& folder : value.mFolders) + { + stream.write(reinterpret_cast(&folder.mHash), sizeof(folder.mHash)); + stream.write(reinterpret_cast(&folder.mCount), sizeof(folder.mCount)); + stream.write(reinterpret_cast(&folder.mOffset), sizeof(folder.mOffset)); + } + + for (const NonSSEFolderRecord& folder : value.mFolders) + { + const std::uint8_t folderNameSize = static_cast(folder.mName.size() + 1); + + stream.write(reinterpret_cast(&folderNameSize), sizeof(folderNameSize)); + stream.write(reinterpret_cast(folder.mName.data()), folder.mName.size()); + stream.put('\0'); + + for (const FileRecord& file : folder.mFiles) + { + stream.write(reinterpret_cast(&file.mHash), sizeof(file.mHash)); + stream.write(reinterpret_cast(&file.mSize), sizeof(file.mSize)); + stream.write(reinterpret_cast(&file.mOffset), sizeof(file.mOffset)); + } + } + + for (const NonSSEFolderRecord& folder : value.mFolders) + { + for (const FileRecord& file : folder.mFiles) + { + stream.write(reinterpret_cast(file.mName.data()), file.mName.size()); + stream.put('\0'); + } + } + } + + std::filesystem::path makeOutputPath() + { + const auto testInfo = UnitTest::GetInstance()->current_test_info(); + return TestingOpenMW::outputFilePath( + std::format("{}.{}.bsa", testInfo->test_suite_name(), testInfo->name())); + } + + TEST(CompressedBSAFileTest, shouldHandleEmpty) + { + const std::filesystem::path path = makeOutputPath(); + + { + std::ofstream stream; + stream.exceptions(std::ifstream::failbit | std::ifstream::badbit); + stream.open(path, std::ios::binary); + } + + CompressedBSAFile file; + EXPECT_THROW(file.open(path), std::runtime_error); + + EXPECT_THAT(file.getList(), IsEmpty()); + } + + TEST(CompressedBSAFileTest, shouldHandleSingleFile) + { + const std::filesystem::path path = makeOutputPath(); + + { + std::ofstream stream; + stream.exceptions(std::ifstream::failbit | std::ifstream::badbit); + + stream.open(path, std::ios::binary); + + const CompressedBSAFile::Header header{ + .mFormat = static_cast(BsaVersion::Compressed), + .mVersion = CompressedBSAFile::Version_TES4, + .mFoldersOffset = sizeof(CompressedBSAFile::Header), + .mFlags = CompressedBSAFile::ArchiveFlag_FolderNames | CompressedBSAFile::ArchiveFlag_FileNames, + .mFolderCount = 1, + .mFileCount = 1, + .mFolderNamesLength = 7, + .mFileNamesLength = 9, + .mFileFlags = 0, + }; + + const FileRecord file{ + .mHash = 0xfedcba9876543210, + .mSize = 42, + .mOffset = 0, + .mName = "filename", + }; + + const NonSSEFolderRecord folder{ + .mHash = 0xfedcba9876543210, + .mCount = 1, + .mOffset = 0, + .mName = "folder", + .mFiles = { file }, + }; + + const Archive archive{ + .mHeader = header, + .mFolders = { folder }, + }; + + writeArchive(archive, stream); + } + + CompressedBSAFile file; + file.open(path); + + std::vector namesBuffer; + constexpr std::string_view filePath = "folder\\filename"; + namesBuffer.assign(filePath.begin(), filePath.end()); + namesBuffer.push_back('\0'); + + EXPECT_THAT(file.getList(), + ElementsAre(BSAFile::FileStruct{ + .mFileSize = 42, + .mOffset = 0, + .mHash = BSAFile::Hash{ .mLow = 0, .mHigh = 0 }, + .mNameOffset = 0, + .mNameSize = 15, + .mNamesBuffer = &namesBuffer, + })); + } + + TEST(CompressedBSAFileTest, shouldHandleEmptyFileName) + { + const std::filesystem::path path = makeOutputPath(); + + { + std::ofstream stream; + stream.exceptions(std::ifstream::failbit | std::ifstream::badbit); + + stream.open(path, std::ios::binary); + + const CompressedBSAFile::Header header{ + .mFormat = static_cast(BsaVersion::Compressed), + .mVersion = CompressedBSAFile::Version_TES4, + .mFoldersOffset = sizeof(CompressedBSAFile::Header), + .mFlags = CompressedBSAFile::ArchiveFlag_FolderNames | CompressedBSAFile::ArchiveFlag_FileNames, + .mFolderCount = 1, + .mFileCount = 1, + .mFolderNamesLength = 7, + .mFileNamesLength = 1, + .mFileFlags = 0, + }; + + const FileRecord file{ + .mHash = 0xfedcba9876543210, + .mSize = 42, + .mOffset = 0, + .mName = "", + }; + + const NonSSEFolderRecord folder{ + .mHash = 0xfedcba9876543210, + .mCount = 1, + .mOffset = 0, + .mName = "folder", + .mFiles = { file }, + }; + + const Archive archive{ + .mHeader = header, + .mFolders = { folder }, + }; + + writeArchive(archive, stream); + } + + CompressedBSAFile file; + EXPECT_THROW(file.open(path), std::runtime_error); + } + + TEST(CompressedBSAFileTest, shouldHandleFoldersWithDuplicateHash) + { + const std::filesystem::path path = makeOutputPath(); + + { + std::ofstream stream; + stream.exceptions(std::ifstream::failbit | std::ifstream::badbit); + + stream.open(path, std::ios::binary); + + const CompressedBSAFile::Header header{ + .mFormat = static_cast(BsaVersion::Compressed), + .mVersion = CompressedBSAFile::Version_TES4, + .mFoldersOffset = sizeof(CompressedBSAFile::Header), + .mFlags = CompressedBSAFile::ArchiveFlag_FolderNames | CompressedBSAFile::ArchiveFlag_FileNames, + .mFolderCount = 2, + .mFileCount = 2, + .mFolderNamesLength = 16, + .mFileNamesLength = 18, + .mFileFlags = 0, + }; + + const FileRecord file{ + .mHash = 0xfedcba9876543210, + .mSize = 42, + .mOffset = 0, + .mName = "filename", + }; + + const NonSSEFolderRecord folder1{ + .mHash = 0xfedcba9876543210, + .mCount = 1, + .mOffset = 0, + .mName = "folder1", + .mFiles = { file }, + }; + + const NonSSEFolderRecord folder2{ + .mHash = 0xfedcba9876543210, + .mCount = 1, + .mOffset = 0, + .mName = "folder2", + .mFiles = { file }, + }; + + const Archive archive{ + .mHeader = header, + .mFolders = { folder1, folder2 }, + }; + + writeArchive(archive, stream); + } + + CompressedBSAFile file; + file.open(path); + + std::vector namesBuffer; + constexpr std::string_view filePath = "folder2\\filename"; + namesBuffer.assign(filePath.begin(), filePath.end()); + namesBuffer.push_back('\0'); + + EXPECT_THAT(file.getList(), + ElementsAre(BSAFile::FileStruct{ + .mFileSize = 42, + .mOffset = 0, + .mHash = BSAFile::Hash{ .mLow = 0, .mHigh = 0 }, + .mNameOffset = 0, + .mNameSize = 16, + .mNamesBuffer = &namesBuffer, + })); + } + + TEST(CompressedBSAFileTest, shouldHandleFilesWithDuplicateHash) + { + const std::filesystem::path path = makeOutputPath(); + + { + std::ofstream stream; + stream.exceptions(std::ifstream::failbit | std::ifstream::badbit); + + stream.open(path, std::ios::binary); + + const CompressedBSAFile::Header header{ + .mFormat = static_cast(BsaVersion::Compressed), + .mVersion = CompressedBSAFile::Version_TES4, + .mFoldersOffset = sizeof(CompressedBSAFile::Header), + .mFlags = CompressedBSAFile::ArchiveFlag_FolderNames | CompressedBSAFile::ArchiveFlag_FileNames, + .mFolderCount = 1, + .mFileCount = 2, + .mFolderNamesLength = 9, + .mFileNamesLength = 18, + .mFileFlags = 0, + }; + + const FileRecord file1{ + .mHash = 0xfedcba9876543210, + .mSize = 42, + .mOffset = 0, + .mName = "filename1", + }; + + const FileRecord file2{ + .mHash = 0xfedcba9876543210, + .mSize = 13, + .mOffset = 0, + .mName = "filename2", + }; + + const NonSSEFolderRecord folder{ + .mHash = 0xfedcba9876543210, + .mCount = 2, + .mOffset = 0, + .mName = "folder", + .mFiles = { file1, file2 }, + }; + + const Archive archive{ + .mHeader = header, + .mFolders = { folder }, + }; + + writeArchive(archive, stream); + } + + CompressedBSAFile file; + file.open(path); + + std::vector namesBuffer; + constexpr std::string_view filePath = "folder\\filename2"; + namesBuffer.assign(filePath.begin(), filePath.end()); + namesBuffer.push_back('\0'); + + EXPECT_THAT(file.getList(), + ElementsAre(BSAFile::FileStruct{ + .mFileSize = 13, + .mOffset = 0, + .mHash = BSAFile::Hash{ .mLow = 0, .mHigh = 0 }, + .mNameOffset = 0, + .mNameSize = 16, + .mNamesBuffer = &namesBuffer, + })); + } + } +} diff --git a/components/bsa/compressedbsafile.hpp b/components/bsa/compressedbsafile.hpp index 88c04614ed..b6c99a906e 100644 --- a/components/bsa/compressedbsafile.hpp +++ b/components/bsa/compressedbsafile.hpp @@ -36,7 +36,7 @@ namespace Bsa { class CompressedBSAFile : private BSAFile { - private: + public: enum ArchiveFlags { ArchiveFlag_FolderNames = 0x0001, @@ -89,8 +89,6 @@ namespace Bsa std::uint32_t mFileFlags; }; - Header mHeader; - struct FileRecord { std::uint64_t mHash; @@ -107,6 +105,8 @@ namespace Bsa std::map mFiles; }; + private: + Header mHeader; std::map mFolders; FileRecord getFileRecord(std::string_view str) const;