diff --git a/apps/components_tests/CMakeLists.txt b/apps/components_tests/CMakeLists.txt index a92fbfe5f0..864b9967d8 100644 --- a/apps/components_tests/CMakeLists.txt +++ b/apps/components_tests/CMakeLists.txt @@ -89,6 +89,7 @@ file(GLOB UNITTEST_SRC_FILES sceneutil/osgacontroller.cpp + bsa/testbsafile.cpp bsa/testcompressedbsafile.cpp ) diff --git a/apps/components_tests/bsa/operators.hpp b/apps/components_tests/bsa/operators.hpp new file mode 100644 index 0000000000..a5098b3814 --- /dev/null +++ b/apps/components_tests/bsa/operators.hpp @@ -0,0 +1,40 @@ +#ifndef COMPONETS_TESTS_BSA_OPERATORS_H +#define COMPONETS_TESTS_BSA_OPERATORS_H + +#include + +#include +#include + +namespace Bsa +{ + inline auto makeTuple(const BSAFile::Hash& value) + { + return std::make_tuple(value.mLow, value.mHigh); + } + + inline 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); + } +} + +#endif diff --git a/apps/components_tests/bsa/testbsafile.cpp b/apps/components_tests/bsa/testbsafile.cpp new file mode 100644 index 0000000000..23bff99331 --- /dev/null +++ b/apps/components_tests/bsa/testbsafile.cpp @@ -0,0 +1,302 @@ +#include "operators.hpp" + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace Bsa +{ + namespace + { + using namespace ::testing; + + struct Header + { + uint32_t mFormat; + uint32_t mDirSize; + uint32_t mFileCount; + }; + + struct Archive + { + Header mHeader; + std::vector mOffsets; + std::vector mStringBuffer; + std::vector mHashes; + std::size_t mTailSize; + }; + + struct TestBSAFile final : public BSAFile + { + void readHeader(std::istream& input) override { BSAFile::readHeader(input); } + + void writeHeader() override { throw std::logic_error("TestBSAFile::writeHeader is not implemented"); } + }; + + void writeArchive(const Archive& value, std::ostream& stream) + { + stream.write(reinterpret_cast(&value.mHeader), sizeof(value.mHeader)); + + if (!value.mOffsets.empty()) + stream.write(reinterpret_cast(value.mOffsets.data()), + value.mOffsets.size() * sizeof(std::uint32_t)); + + if (!value.mStringBuffer.empty()) + stream.write(reinterpret_cast(value.mStringBuffer.data()), value.mStringBuffer.size()); + + for (const BSAFile::Hash& hash : value.mHashes) + stream.write(reinterpret_cast(&hash), sizeof(BSAFile::Hash)); + + const std::size_t chunkSize = 4096; + std::vector chunk(chunkSize); + for (std::size_t i = 0; i < value.mTailSize; i += chunkSize) + stream.write(reinterpret_cast(chunk.data()), std::min(chunk.size(), value.mTailSize - i)); + } + + std::filesystem::path makeOutputPath() + { + const auto testInfo = UnitTest::GetInstance()->current_test_info(); + return TestingOpenMW::outputFilePath( + std::format("{}.{}.bsa", testInfo->test_suite_name(), testInfo->name())); + } + + std::string makeBsaBuffer(std::uint32_t fileSize, std::uint32_t fileOffset) + { + std::string buffer; + + buffer.reserve(static_cast(fileSize) + static_cast(fileOffset) + 34); + + std::ostringstream stream(std::move(buffer)); + + const Header header{ + .mFormat = static_cast(BsaVersion::Uncompressed), + .mDirSize = 14, + .mFileCount = 1, + }; + + const BSAFile::Hash hash{ + .mLow = 0xaaaabbbb, + .mHigh = 0xccccdddd, + }; + + const Archive archive{ + .mHeader = header, + .mOffsets = { fileSize, fileOffset, 0 }, + .mStringBuffer = { 'a', '\0' }, + .mHashes = { hash }, + .mTailSize = 0, + }; + + writeArchive(archive, stream); + + return std::move(stream).str(); + } + + TEST(BSAFileTest, shouldHandleEmpty) + { + const std::filesystem::path path = makeOutputPath(); + + { + std::ofstream stream; + stream.exceptions(std::ifstream::failbit | std::ifstream::badbit); + stream.open(path, std::ios::binary); + } + + BSAFile file; + EXPECT_THROW(file.open(path), std::runtime_error); + + EXPECT_THAT(file.getList(), IsEmpty()); + } + + TEST(BSAFileTest, shouldHandleZeroFiles) + { + const std::filesystem::path path = makeOutputPath(); + + { + std::ofstream stream; + stream.exceptions(std::ifstream::failbit | std::ifstream::badbit); + + stream.open(path, std::ios::binary); + + const Header header{ + .mFormat = static_cast(BsaVersion::Uncompressed), + .mDirSize = 0, + .mFileCount = 0, + }; + + const Archive archive{ + .mHeader = header, + .mOffsets = {}, + .mStringBuffer = {}, + .mHashes = {}, + .mTailSize = 0, + }; + + writeArchive(archive, stream); + } + + BSAFile file; + file.open(path); + + EXPECT_THAT(file.getList(), IsEmpty()); + } + + TEST(BSAFileTest, 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 Header header{ + .mFormat = static_cast(BsaVersion::Uncompressed), + .mDirSize = 14, + .mFileCount = 1, + }; + + const BSAFile::Hash hash{ + .mLow = 0xaaaabbbb, + .mHigh = 0xccccdddd, + }; + + const Archive archive{ + .mHeader = header, + .mOffsets = { 42, 0, 0 }, + .mStringBuffer = { 'a', '\0' }, + .mHashes = { hash }, + .mTailSize = 42, + }; + + writeArchive(archive, stream); + } + + BSAFile file; + file.open(path); + + std::vector namesBuffer = { 'a', '\0' }; + + EXPECT_THAT(file.getList(), + ElementsAre(BSAFile::FileStruct{ + .mFileSize = 42, + .mOffset = 34, + .mHash = BSAFile::Hash{ .mLow = 0xaaaabbbb, .mHigh = 0xccccdddd }, + .mNameOffset = 0, + .mNameSize = 1, + .mNamesBuffer = &namesBuffer, + })); + } + + TEST(BSAFileTest, shouldHandleTwoFiles) + { + const std::filesystem::path path = makeOutputPath(); + + { + std::ofstream stream; + stream.exceptions(std::ifstream::failbit | std::ifstream::badbit); + + stream.open(path, std::ios::binary); + + const std::uint32_t fileSize1 = 42; + const std::uint32_t fileSize2 = 13; + + const Header header{ + .mFormat = static_cast(BsaVersion::Uncompressed), + .mDirSize = 28, + .mFileCount = 2, + }; + + const BSAFile::Hash hash1{ + .mLow = 0xaaaabbbb, + .mHigh = 0xccccdddd, + }; + + const BSAFile::Hash hash2{ + .mLow = 0x11112222, + .mHigh = 0x33334444, + }; + + const Archive archive{ + .mHeader = header, + .mOffsets = { fileSize1, 0, fileSize2, fileSize1, 0, 2 }, + .mStringBuffer = { 'a', '\0', 'b', '\0' }, + .mHashes = { hash1, hash2 }, + .mTailSize = fileSize1 + fileSize2, + }; + + writeArchive(archive, stream); + } + + BSAFile file; + file.open(path); + + std::vector namesBuffer = { 'a', '\0', 'b', '\0' }; + + EXPECT_THAT(file.getList(), + ElementsAre( + BSAFile::FileStruct{ + .mFileSize = 42, + .mOffset = 56, + .mHash = BSAFile::Hash{ .mLow = 0xaaaabbbb, .mHigh = 0xccccdddd }, + .mNameOffset = 0, + .mNameSize = 1, + .mNamesBuffer = &namesBuffer, + }, + BSAFile::FileStruct{ + .mFileSize = 13, + .mOffset = 98, + .mHash = BSAFile::Hash{ .mLow = 0x11112222, .mHigh = 0x33334444 }, + .mNameOffset = 2, + .mNameSize = 1, + .mNamesBuffer = &namesBuffer, + })); + } + + TEST(BSAFileTest, shouldHandleSingleFileAtTheEndOfLargeFile) + { + constexpr std::uint32_t maxUInt32 = std::numeric_limits::max(); + const std::string buffer = makeBsaBuffer(maxUInt32, maxUInt32 - 34); + + TestBSAFile file; + // Use capacity assuming we never read beyond small header. + Files::IMemStream stream(buffer.data(), buffer.capacity()); + file.readHeader(stream); + + std::vector namesBuffer = { 'a', '\0' }; + + EXPECT_THAT(file.getList(), + ElementsAre(BSAFile::FileStruct{ + .mFileSize = maxUInt32, + .mOffset = maxUInt32, + .mHash = BSAFile::Hash{ .mLow = 0xaaaabbbb, .mHigh = 0xccccdddd }, + .mNameOffset = 0, + .mNameSize = 1, + .mNamesBuffer = &namesBuffer, + })); + } + + TEST(BSAFileTest, shouldThrowExceptionOnTooBigAbsoluteOffset) + { + constexpr std::uint32_t maxUInt32 = std::numeric_limits::max(); + const std::string buffer = makeBsaBuffer(maxUInt32, maxUInt32 - 34 + 1); + + TestBSAFile file; + // Use capacity assuming we never read beyond small header. + Files::IMemStream stream(buffer.data(), buffer.capacity()); + EXPECT_THROW(file.readHeader(stream), std::runtime_error); + + EXPECT_THAT(file.getList(), IsEmpty()); + } + } +} diff --git a/apps/components_tests/bsa/testcompressedbsafile.cpp b/apps/components_tests/bsa/testcompressedbsafile.cpp index 5a3aefc841..d6343ccaec 100644 --- a/apps/components_tests/bsa/testcompressedbsafile.cpp +++ b/apps/components_tests/bsa/testcompressedbsafile.cpp @@ -1,3 +1,5 @@ +#include "operators.hpp" + #include #include @@ -9,41 +11,9 @@ #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; diff --git a/components/bsa/ba2dx10file.cpp b/components/bsa/ba2dx10file.cpp index c426b258dd..c16a3bacd2 100644 --- a/components/bsa/ba2dx10file.cpp +++ b/components/bsa/ba2dx10file.cpp @@ -5,7 +5,7 @@ #include #include #include -#include +#include #include @@ -75,12 +75,10 @@ namespace Bsa } /// Read header information from the input source - void BA2DX10File::readHeader() + void BA2DX10File::readHeader(std::istream& input) { assert(!mIsLoaded); - std::ifstream input(mFilepath, std::ios_base::binary); - const std::streamsize fsize = Files::getStreamSizeLeft(input); if (fsize < 24) // header is 24 bytes @@ -138,8 +136,6 @@ namespace Bsa mFiles[i].mNameSize = fileNameSize; mFiles[i].mNamesBuffer = &mFileNames.back(); } - - mIsLoaded = true; } std::optional BA2DX10File::getFileRecord(std::string_view str) const diff --git a/components/bsa/ba2dx10file.hpp b/components/bsa/ba2dx10file.hpp index a10a0c4740..aed727dc1a 100644 --- a/components/bsa/ba2dx10file.hpp +++ b/components/bsa/ba2dx10file.hpp @@ -57,7 +57,7 @@ namespace Bsa virtual ~BA2DX10File(); /// Read header information from the input source - void readHeader() override; + void readHeader(std::istream& stream) override; Files::IStreamPtr getFile(const char* filePath); Files::IStreamPtr getFile(const FileStruct* fileStruct); diff --git a/components/bsa/ba2gnrlfile.cpp b/components/bsa/ba2gnrlfile.cpp index 5e8b835651..30e9b1eb0a 100644 --- a/components/bsa/ba2gnrlfile.cpp +++ b/components/bsa/ba2gnrlfile.cpp @@ -70,12 +70,10 @@ namespace Bsa } /// Read header information from the input source - void BA2GNRLFile::readHeader() + void BA2GNRLFile::readHeader(std::istream& input) { assert(!mIsLoaded); - std::ifstream input(mFilepath, std::ios_base::binary); - const std::streamsize fsize = Files::getStreamSizeLeft(input); if (fsize < 24) // header is 24 bytes @@ -129,8 +127,6 @@ namespace Bsa mFiles[i].mNameSize = fileNameSize; mFiles[i].mNamesBuffer = &mFileNames.back(); } - - mIsLoaded = true; } BA2GNRLFile::FileRecord BA2GNRLFile::getFileRecord(std::string_view str) const diff --git a/components/bsa/ba2gnrlfile.hpp b/components/bsa/ba2gnrlfile.hpp index bc1de65d98..371fe3d072 100644 --- a/components/bsa/ba2gnrlfile.hpp +++ b/components/bsa/ba2gnrlfile.hpp @@ -45,7 +45,7 @@ namespace Bsa virtual ~BA2GNRLFile(); /// Read header information from the input source - void readHeader() override; + void readHeader(std::istream& input) override; Files::IStreamPtr getFile(const char* filePath); Files::IStreamPtr getFile(const FileStruct* fileStruct); diff --git a/components/bsa/bsafile.cpp b/components/bsa/bsafile.cpp index 39555a9a7c..878369b137 100644 --- a/components/bsa/bsafile.cpp +++ b/components/bsa/bsafile.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -73,7 +74,7 @@ BSAFile::Hash getHash(const std::string& name) } /// Read header information from the input source -void BSAFile::readHeader() +void BSAFile::readHeader(std::istream& input) { /* * The layout of a BSA archive is as follows: @@ -107,8 +108,6 @@ void BSAFile::readHeader() */ assert(!mIsLoaded); - std::ifstream input(mFilepath, std::ios_base::binary); - // Total archive size const std::streamsize fsize = Files::getStreamSizeLeft(input); @@ -211,8 +210,6 @@ void BSAFile::readHeader() std::sort(mFiles.begin(), mFiles.end(), [](const FileStruct& left, const FileStruct& right) { return left.mOffset < right.mOffset; }); - - mIsLoaded = true; } /// Write header information to the output sink @@ -260,7 +257,11 @@ void BSAFile::open(const std::filesystem::path& file) mFilepath = file; if (std::filesystem::exists(file)) - readHeader(); + { + std::ifstream input(mFilepath, std::ios_base::binary); + readHeader(input); + mIsLoaded = true; + } else { { diff --git a/components/bsa/bsafile.hpp b/components/bsa/bsafile.hpp index c091e38548..bc25e1f5da 100644 --- a/components/bsa/bsafile.hpp +++ b/components/bsa/bsafile.hpp @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -95,7 +96,7 @@ namespace Bsa [[noreturn]] void fail(const std::string& msg) const; /// Read header information from the input source - virtual void readHeader(); + virtual void readHeader(std::istream& input); virtual void writeHeader(); public: diff --git a/components/bsa/compressedbsafile.cpp b/components/bsa/compressedbsafile.cpp index 06eee80539..8ad7221105 100644 --- a/components/bsa/compressedbsafile.cpp +++ b/components/bsa/compressedbsafile.cpp @@ -29,7 +29,7 @@ #include #include #include -#include +#include #include #include @@ -45,12 +45,10 @@ namespace Bsa { /// Read header information from the input source - void CompressedBSAFile::readHeader() + void CompressedBSAFile::readHeader(std::istream& input) { assert(!mIsLoaded); - std::ifstream input(mFilepath, std::ios_base::binary); - const std::streamsize fsize = Files::getStreamSizeLeft(input); if (fsize < 36) // Header is 36 bytes @@ -208,8 +206,6 @@ namespace Bsa mFiles.push_back(fileStruct); } } - - mIsLoaded = true; } CompressedBSAFile::FileRecord CompressedBSAFile::getFileRecord(std::string_view str) const diff --git a/components/bsa/compressedbsafile.hpp b/components/bsa/compressedbsafile.hpp index b6c99a906e..6eae44cec1 100644 --- a/components/bsa/compressedbsafile.hpp +++ b/components/bsa/compressedbsafile.hpp @@ -125,7 +125,7 @@ namespace Bsa virtual ~CompressedBSAFile() = default; /// Read header information from the input source - void readHeader() override; + void readHeader(std::istream& input) override; Files::IStreamPtr getFile(const char* filePath); Files::IStreamPtr getFile(const FileStruct* fileStruct);