diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index b54205f30..350d9be7e 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -1,3 +1,4 @@ +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -36,12 +37,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { @@ -247,21 +242,14 @@ TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile) ASSERT_EQ(installed.write("installed database"), 18); installed.close(); - // A real filesystem write failure, isolated in the death-test child so the - // process-wide file limit never affects other cases or the parent runner. - ASSERT_EXIT( - { - std::signal(SIGXFSZ, SIG_IGN); - struct rlimit limit; - limit.rlim_cur = 128; - limit.rlim_max = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - CockatriceXml4Parser writer(&controller); - _exit(writer.saveToFile({}, sets, cards, fileName) ? 1 : 0); - }, - ::testing::ExitedWithCode(0), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + CockatriceXml4Parser writer(&controller); + EXPECT_FALSE(writer.saveToFile({}, sets, cards, fileName)); ASSERT_TRUE(installed.open(QIODevice::ReadOnly)); EXPECT_EQ(installed.readAll(), "installed database"); diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 259bae087..1b4e2d915 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,6 +1,7 @@ #include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/card_info.h" #include "gtest/gtest.h" @@ -33,12 +34,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - class OracleImporterTest : public ::testing::Test { protected: @@ -1336,20 +1331,16 @@ TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase) original.close(); const QByteArray data = ""; - // A real filesystem write failure, isolated in the death-test child so the - // process-wide file limit never affects other cases or the parent runner. - ASSERT_EXIT( - { - std::signal(SIGXFSZ, SIG_IGN); - struct rlimit limit; - limit.rlim_cur = 128; - limit.rlim_max = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - _exit(OracleDatabase::saveXml(path, data) ? 1 : 0); - }, - ::testing::ExitedWithCode(0), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + QString error; + EXPECT_FALSE(OracleDatabase::saveXml(path, data, &error)); + EXPECT_FALSE(error.isEmpty()); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); EXPECT_EQ(original.readAll(), "installed database"); } diff --git a/tests/spoiler_file_test.cpp b/tests/spoiler_file_test.cpp index 9944f95bd..ecdc3285f 100644 --- a/tests/spoiler_file_test.cpp +++ b/tests/spoiler_file_test.cpp @@ -1,4 +1,5 @@ #include "client/network/update/card_spoiler/spoiler_file.h" +#include "support/scoped_file_size_limit.h" #include #include @@ -6,12 +7,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { class SpoilerFileTest : public ::testing::Test @@ -123,25 +118,16 @@ TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt) #ifdef Q_OS_UNIX TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact) { - // Limit only the child process: a real short write exercises QSaveFile's - // buffered flush failure without depending on disk capacity or file ownership. const QByteArray changed = ""; - EXPECT_EXIT( - { - struct rlimit limit; - if (getrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - limit.rlim_cur = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(3); - } - std::signal(SIGXFSZ, SIG_IGN); - const auto result = save(changed); - _exit(result == SpoilerFile::SaveResult::Failed && !error.isEmpty() ? 0 : 1); - }, - ::testing::ExitedWithCode(0), ""); + // A real short write exercises QSaveFile's buffered flush failure without depending + // on disk capacity. The cap is scoped rather than forked, because a death-test child + // under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); EXPECT_EQ(read(), original); EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); } diff --git a/tests/support/scoped_file_size_limit.h b/tests/support/scoped_file_size_limit.h new file mode 100644 index 000000000..1c1f3d939 --- /dev/null +++ b/tests/support/scoped_file_size_limit.h @@ -0,0 +1,83 @@ +#ifndef COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H +#define COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H + +#include + +#ifdef Q_OS_UNIX + +#include +#include +#include + +namespace test_support +{ +/** + * @brief Caps how large a single file may grow, so a writer's short-write path is reachable. + * + * Lowering RLIMIT_FSIZE makes the next write past the cap fail with EFBIG instead of + * filling a filesystem, which is the only way to fail an install step mid-write without + * depending on free disk space or file ownership. Only the soft limit is lowered, so the + * destructor can hand the process back exactly as it found it: ctest runs one case per + * process, but a whole test binary must not carry the cap into its neighbours. + * + * Do not use this inside a gtest death test. A forked child under RLIMIT_FSIZE is killed + * by SIGSEGV on the macOS runners, while the same case passes on Linux, so the fork is + * what breaks - not the writer. The guard alone isolates the failure just as well. + */ +class ScopedFileSizeLimit +{ +public: + explicit ScopedFileSizeLimit(rlim_t maxBytes) + { + if (getrlimit(RLIMIT_FSIZE, &previousLimit) != 0) { + return; + } + + struct sigaction ignore = {}; + ignore.sa_handler = SIG_IGN; + sigemptyset(&ignore.sa_mask); + if (sigaction(SIGXFSZ, &ignore, &previousAction) != 0) { + return; + } + actionSaved = true; + + struct rlimit capped = previousLimit; + capped.rlim_cur = std::min(maxBytes, previousLimit.rlim_max); + if (setrlimit(RLIMIT_FSIZE, &capped) != 0) { + return; + } + active = true; + } + + ~ScopedFileSizeLimit() + { + if (active) { + setrlimit(RLIMIT_FSIZE, &previousLimit); + } + if (actionSaved) { + sigaction(SIGXFSZ, &previousAction, nullptr); + } + } + + ScopedFileSizeLimit(const ScopedFileSizeLimit &) = delete; + ScopedFileSizeLimit &operator=(const ScopedFileSizeLimit &) = delete; + + /** + * @brief Whether the cap is in force; a test that needs the failure must assert this. + */ + [[nodiscard]] bool isValid() const + { + return active; + } + +private: + bool active = false; + bool actionSaved = false; + struct rlimit previousLimit = {}; + struct sigaction previousAction = {}; +}; +} // namespace test_support + +#endif // Q_OS_UNIX + +#endif // COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H