#include "archive/archive.h" #include "common/worker_process.h" #include #include #include #include #include #include #include #ifdef Q_OS_UNIX #include #endif using namespace docview; #ifndef Q_MOC_RUN namespace { struct File { QByteArray path; QByteArray data; quint32 mode = 0100600; bool compress = false; zip_int32_t method = ZIP_CM_DEFAULT; }; QString createZip(QTemporaryDir &dir, const QList &files, const QString &name = "book.zip") { const auto path = dir.filePath(name); int error; zip_t *archive = zip_open(QFile::encodeName(path).constData(), ZIP_CREATE | ZIP_TRUNCATE, &error); if (!archive) return {}; for (const auto &file : files) { auto *source = zip_source_buffer(archive, file.data.constData(), zip_uint64_t(file.data.size()), 0); const auto index = zip_file_add(archive, file.path.constData(), source, ZIP_FL_ENC_UTF_8); if (index < 0) { zip_source_free(source); zip_discard(archive); return {}; } if (zip_set_file_compression(archive, zip_uint64_t(index), file.method == ZIP_CM_DEFAULT ? (file.compress ? ZIP_CM_DEFLATE : ZIP_CM_STORE) : file.method, 9) < 0) { zip_discard(archive); return {}; } zip_file_set_external_attributes(archive, zip_uint64_t(index), 0, ZIP_OPSYS_UNIX, file.mode << 16); } if (zip_close(archive) < 0) { zip_discard(archive); return {}; } return path; } bool padCompressedEntry(const QString &path, quint32 padded, quint32 *compressed = nullptr) { QFile archive(path); if (!archive.open(QIODevice::ReadWrite)) return false; auto raw = archive.readAll(); const auto end = raw.lastIndexOf(QByteArray("PK\5\6", 4)); if (end < 22) return false; const auto central = qFromLittleEndian(reinterpret_cast(raw.constData() + end + 16)); if (central < 30 || quint64(central) + 46 > quint64(end) || raw.mid(central, 4) != QByteArray("PK\1\2", 4)) return false; const auto size = qFromLittleEndian(reinterpret_cast(raw.constData() + central + 20)); if (size > padded) return false; if (compressed) *compressed = size; qToLittleEndian(padded, raw.data() + 18); qToLittleEndian(padded, raw.data() + central + 20); qToLittleEndian(central + padded - size, raw.data() + end + 16); raw.insert(central, QByteArray(padded - size, '\0')); return archive.resize(0) && archive.write(raw) == raw.size(); } QList epubFiles(QByteArray package = {}, QByteArray nav = {}) { if (package.isEmpty()) package = R"(試験書籍urn:uuid:1234pre-paginated)"; if (nav.isEmpty()) nav = R"()"; return {{"mimetype", "application/epub+zip"}, {"META-INF/container.xml", R"()"}, {"OPS/package.opf", package}, {"OPS/one.xhtml", "one"}, {"OPS/two.xhtml", "two"}, {"OPS/nav.xhtml", nav}}; } } #endif class ArchiveTests : public QObject { Q_OBJECT private slots: void paths_data(); void paths(); void unsafeArchives_data(); void unsafeArchives(); void htmlEntryPriority(); void htmlCommonDirectoryAndCandidates(); void limits(); void paddedCompressedInputRatio_data(); void paddedCompressedInputRatio(); void legalCompressionInputAccounting_data(); void legalCompressionInputAccounting(); void smallTrailingGarbageRemainsCorrupt(); void libzipPaddingConsistencyBaseline(); void ratioThresholdBoundary(); void sandboxedWorkerRejectsPaddedInput(); void generatedRatioCorpus_data(); void generatedRatioCorpus(); void crcAndPartialCleanup(); void embeddedNul(); void nofollowExtraction(); void epub3(); void epubSpreadMetadata(); void epub2Ncx(); void xmlEntitiesAndDepth(); void missingSpineAndFallback(); void forbiddenNavTargets(); void fontObfuscationAndDrm(); void encryptedZip(); void forgedSize(); void metadataLimits(); void extensionlessManifestResources(); void streamingExtraction(); void borrowedReadOnlySource(); void sourceLifetimeAndAccess(); void sandboxedWorkerStreamsOpenedSource(); }; void ArchiveTests::paths_data() { QTest::addColumn("path"); QTest::addColumn("valid"); const QStringList bad{"../x", "/x", "C:/x", "//host/x", "a\\b", "a/../b", "a//b", "a/./b", "a:b", "CON", "aux.txt", "LPT1.txt", "COM¹.dat", "foo.", "foo ", "a/NUL/x", "a?b", "a*", "a|b", "a<", QString("a") + QChar::Null + "b", QString(1025, 'a')}; for (int i = 0; i < bad.size(); ++i) QTest::newRow(qPrintable(QString::number(i))) << bad[i] << false; QTest::newRow("relative") << QString("assets/font.woff2") << true; QTest::newRow("unicode") << QString("本/本文.xhtml") << true; QTest::newRow("literal percent") << QString("100%25.html") << true; } void ArchiveTests::paths() { QFETCH(QString, path); QFETCH(bool, valid); QCOMPARE(ArchiveReader::validPath(path, false), valid); } void ArchiveTests::unsafeArchives_data() { QTest::addColumn("first"); QTest::addColumn("second"); QTest::addColumn("mode"); QTest::newRow("traversal") << QByteArray("../index.html") << QByteArray() << quint32(0100600); QTest::newRow("absolute") << QByteArray("/index.html") << QByteArray() << quint32(0100600); QTest::newRow("symlink") << QByteArray("index.html") << QByteArray() << quint32(0120777); QTest::newRow("fifo") << QByteArray("index.html") << QByteArray() << quint32(0010600); QTest::newRow("device") << QByteArray("index.html") << QByteArray() << quint32(0020600); QTest::newRow("case") << QByteArray("index.html") << QByteArray("INDEX.html") << quint32(0100600); QTest::newRow("normalization") << QString("é.html").toUtf8() << QString("e\u0301.html").toUtf8() << quint32(0100600); QTest::newRow("directory case") << QByteArray("Assets/a.css") << QByteArray("assets/b.css") << quint32(0100600); QTest::newRow("file-dir forward") << QByteArray("a") << QByteArray("a/index.html") << quint32(0100600); QTest::newRow("file-dir reverse") << QByteArray("a/index.html") << QByteArray("a") << quint32(0100600); } void ArchiveTests::unsafeArchives() { QFETCH(QByteArray, first); QFETCH(QByteArray, second); QFETCH(quint32, mode); QTemporaryDir dir; QList files{{first, "x", mode}}; if (!second.isEmpty()) files.append(File{second, "x"}); const auto path = createZip(dir, files); QVERIFY(!path.isEmpty()); ArchiveReader reader; ArchiveError error; QVERIFY(!reader.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_UNSAFE_PATH"); } void ArchiveTests::htmlEntryPriority() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "root"}, {"folder/index.html", "inner"}, {"style.css", "body{}"}}); ArchiveReader reader; ArchiveError error; ArchiveDocument document; QVERIFY(reader.open(path, &error)); QVERIFY(reader.describe(false, &document, &error)); QCOMPARE(document.entry, "index.html"); QCOMPARE(document.format, "htmlzip"); QCOMPARE(document.candidates.size(), 2); QTemporaryDir out; QVERIFY(reader.extract("index.html", out.path(), &error)); QFile extracted(out.filePath("index.html")); QVERIFY(extracted.open(QIODevice::ReadOnly)); QCOMPARE(extracted.readAll(), "root"); QVERIFY(!QFile::exists(out.filePath("style.css"))); // lazy extraction } void ArchiveTests::htmlCommonDirectoryAndCandidates() { QTemporaryDir dir; ArchiveError error; ArchiveDocument doc; ArchiveReader reader; QVERIFY(reader.open(createZip(dir, {{"book/index.html", "book"}, {"book/css/style.css", "s"}}), &error)); QVERIFY(reader.describe(false, &doc, &error)); QCOMPARE(doc.entry, "book/index.html"); QVERIFY(reader.open(createZip(dir, {{"book/index.htm", "book"}, {"book/INDEX.html", "b"}}, "ambiguous.zip"), &error)); QVERIFY(reader.describe(false, &doc, &error)); QVERIFY(doc.entry.isEmpty()); QCOMPARE(doc.candidates.size(), 2); QVERIFY(reader.open(createZip(dir, {{"style.css", "x"}}, "empty.zip"), &error)); QVERIFY(!reader.describe(false, &doc, &error)); QCOMPARE(error.code, "E_HTML_ENTRY_MISSING"); } void ArchiveTests::limits() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", QByteArray(101, 'a')}, {"two.html", "b"}}); ArchiveError error; ArchiveLimits limit; limit.maxEntryBytes = 100; ArchiveReader a(limit); QVERIFY(!a.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); limit = {}; limit.maxEntries = 1; ArchiveReader b(limit); QVERIFY(!b.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); limit = {}; limit.maxTotalBytes = 101; ArchiveReader c(limit); QVERIFY(!c.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); limit = {}; limit.maxDepth = 2; QVERIFY(!ArchiveReader::validPath("a/b/c", false, limit)); limit = {}; limit.ratioThreshold = 32; limit.maxRatio = 2; const auto compressed = createZip(dir, {{"index.html", QByteArray(10000, 'x'), 0100600, true}}, "bomb.zip"); ArchiveReader d(limit); QVERIFY(!d.open(compressed, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); } void ArchiveTests::paddedCompressedInputRatio_data() { QTest::addColumn("method"); for (const auto &entry : QList>{{"deflate", ZIP_CM_DEFLATE}, {"bzip2", ZIP_CM_BZIP2}, {"lzma", ZIP_CM_LZMA}, {"xz", ZIP_CM_XZ}, {"zstd", ZIP_CM_ZSTD}}) if (zip_compression_method_supported(entry.second, 0) && zip_compression_method_supported(entry.second, 1)) QTest::newRow(entry.first) << entry.second; } void ArchiveTests::paddedCompressedInputRatio() { QFETCH(int, method); QTemporaryDir dir; const QByteArray plain(33 * 1024 * 1024, 'x'); const auto path = createZip(dir, {{"index.html", plain, 0100600, true, method}}, "padded.zip"); QVERIFY(!path.isEmpty()); quint32 compressed = 0; const quint32 padded = 256 * 1024; QVERIFY(padCompressedEntry(path, padded, &compressed)); QVERIFY(compressed < padded); ArchiveReader reader; ArchiveError error; QVERIFY2(reader.open(path, &error), qPrintable(error.message)); quint64 output = 0; const bool accepted = reader.extractTo("index.html", [&](const char *, qsizetype n) { output += quint64(n); return true; }, &error); const auto statistics = reader.lastStreamStatistics(); qInfo() << "compressed stream bytes" << compressed << "ZIP declared bytes" << padded << "actual source input" << statistics.inputBytesRead << "open input" << statistics.inputBytesDuringOpen << "max source read" << statistics.largestInputRead << "provisional output" << output << "accepted" << accepted; QVERIFY(!accepted); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); QVERIFY(output <= 32ull * 1024 * 1024); QVERIFY(statistics.inputBytesRead < padded); QVERIFY(statistics.largestInputRead <= 64 * 1024); QVERIFY(statistics.outputBytes > 32ull * 1024 * 1024); #ifdef Q_OS_UNIX QTemporaryDir out; QVERIFY(!reader.extract("index.html", out.path(), &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); QVERIFY(!QFile::exists(out.filePath("index.html"))); #endif } void ArchiveTests::legalCompressionInputAccounting_data() { paddedCompressedInputRatio_data(); QTest::newRow("store") << int(ZIP_CM_STORE); } void ArchiveTests::legalCompressionInputAccounting() { QFETCH(int, method); // Incompressible early input must remain charged after later output crosses // the threshold. Resetting the input count there would reject this stream. QByteArray plain(1024 * 1024, Qt::Uninitialized); quint32 random = 0x5a123456; for (auto &byte : plain) { random ^= random << 13; random ^= random >> 17; random ^= random << 5; byte = char(random & 255); } plain += QByteArray(32768, 'x'); QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", plain, 0100600, true, method}, {"other.html", "other"}}); ArchiveLimits limits; limits.ratioThreshold = 1024 * 1024; limits.maxRatio = 2; ArchiveReader reader(limits); ArchiveError error; QVERIFY2(reader.open(path, &error), qPrintable(error.message)); QByteArray actual; QVERIFY2(reader.extractTo("index.html", [&](const char *data, qsizetype size) { actual.append(data, size); return true; }, &error), qPrintable(error.message)); QCOMPARE(actual, plain); const auto statistics = reader.lastStreamStatistics(); qInfo() << "source input" << statistics.inputBytesRead << "open input" << statistics.inputBytesDuringOpen << "max source read" << statistics.largestInputRead << "output" << statistics.outputBytes; QVERIFY(statistics.inputBytesRead >= 1024 * 1024); QVERIFY(statistics.largestInputRead <= 64 * 1024); QCOMPARE(statistics.outputBytes, quint64(plain.size())); actual.clear(); QVERIFY(reader.extractTo("other.html", [&](const char *data, qsizetype size) { actual.append(data, size); return true; }, &error)); QCOMPARE(actual, "other"); QCOMPARE(reader.lastStreamStatistics().outputBytes, 5u); QVERIFY(reader.lastStreamStatistics().inputBytesRead < 65536); // No prior entry's denominator survives. } void ArchiveTests::smallTrailingGarbageRemainsCorrupt() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", QByteArray(1024, 'x'), 0100600, true}}); QFile file(path); QVERIFY(file.open(QIODevice::ReadWrite)); auto raw = file.readAll(); const auto central = raw.indexOf(QByteArray("PK\1\2", 4)); const auto end = raw.lastIndexOf(QByteArray("PK\5\6", 4)); QVERIFY(central > 30 && end > central); const auto size = qFromLittleEndian(reinterpret_cast(raw.constData() + central + 20)); qToLittleEndian(size + 5, raw.data() + 18); qToLittleEndian(size + 5, raw.data() + central + 20); qToLittleEndian(quint32(central) + 5, raw.data() + end + 16); raw.insert(central, "extra"); QVERIFY(file.resize(0)); QCOMPARE(file.write(raw), raw.size()); file.close(); ArchiveReader reader; ArchiveError error; QVERIFY(reader.open(path, &error)); QByteArray actual; QVERIFY(!reader.read("index.html", 4096, &actual, &error)); QCOMPARE(error.code, "E_ARCHIVE_CORRUPT"); QVERIFY(actual.isEmpty()); } void ArchiveTests::libzipPaddingConsistencyBaseline() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", QByteArray(33 * 1024 * 1024, 'x'), 0100600, true}}); QVERIFY(padCompressedEntry(path, 256 * 1024)); int code = 0; auto *archive = zip_open(QFile::encodeName(path).constData(), ZIP_RDONLY | ZIP_CHECKCONS, &code); QVERIFY(archive); zip_stat_t stat; zip_stat_init(&stat); QCOMPARE(zip_stat_index(archive, 0, 0, &stat), 0); QVERIFY(stat.size / stat.comp_size < 200); // The original metadata-only guard passes. auto *entry = zip_fopen_index(archive, 0, 0); QVERIFY(entry); QByteArray buffer(65536, Qt::Uninitialized); quint64 output = 0; zip_int64_t n = 0; while ((n = zip_fread(entry, buffer.data(), zip_uint64_t(buffer.size()))) > 0) output += quint64(n); QCOMPARE(n, -1); const int zipError = zip_error_code_zip(zip_file_get_error(entry)); qInfo() << "libzip-only baseline provisional output" << output << "final zip error" << zipError; QCOMPARE(output, 33ull * 1024 * 1024); QCOMPARE(zipError, ZIP_ER_INCONS); zip_fclose(entry); zip_discard(archive); } void ArchiveTests::ratioThresholdBoundary() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", QByteArray(32 * 1024 * 1024, 'x'), 0100600, true}}); ArchiveReader reader; ArchiveError error; QVERIFY(reader.open(path, &error)); quint64 total = 0; QVERIFY2(reader.extractTo("index.html", [&](const char *, qsizetype n) { total += quint64(n); return true; }, &error), qPrintable(error.message)); QCOMPARE(total, 32ull * 1024 * 1024); // The design applies only above 32 MiB. } void ArchiveTests::sandboxedWorkerRejectsPaddedInput() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", QByteArray(33 * 1024 * 1024, 'x'), 0100600, true}}); QVERIFY(padCompressedEntry(path, 256 * 1024)); WorkerProcess worker("archive-ratio-test", 1); QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-archive-worker", {"--source", path})); QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY2(failed.isEmpty(), failed.isEmpty() ? "" : qPrintable(failed.first()[1].toString())); bool opened = false; worker.request("open", {}, [&](const QCborMap &reply) { opened = reply.contains(QStringLiteral("result")); }); QTRY_VERIFY_WITH_TIMEOUT(opened || !failed.isEmpty(), 5000); QVERIFY(opened); quint64 bytes = 0; bool success = false; QString code; worker.request("extract", {{QStringLiteral("path"), QStringLiteral("index.html")}}, [&](const QCborMap &reply) { if (reply.contains(QStringLiteral("error"))) code = reply.value(QStringLiteral("error")).toMap().value(QStringLiteral("code")).toString(); else { const auto result = reply.value(QStringLiteral("result")).toMap(); if (result.value(QStringLiteral("more")).toBool()) bytes += quint64(result.value(QStringLiteral("data")).toByteArray().size()); else success = true; } }); QTRY_VERIFY_WITH_TIMEOUT(!code.isEmpty() || success || !failed.isEmpty(), 10000); QVERIFY(!success); QVERIFY(failed.isEmpty()); QCOMPARE(code, "E_ARCHIVE_LIMIT"); QCOMPARE(bytes, 32ull * 1024 * 1024); bool pinged = false; worker.request("ping", {}, [&](const QCborMap &reply) { pinged = reply.value(QStringLiteral("result")).toMap().value(QStringLiteral("ready")).toBool(); }); QTRY_VERIFY_WITH_TIMEOUT(pinged, 5000); worker.stop(); } void ArchiveTests::generatedRatioCorpus_data() { QTest::addColumn("name"); QTest::addColumn("opens"); QTest::addColumn("extracts"); QTest::newRow("padded") << QString("padded-33mib.zip") << true << false; QTest::newRow("threshold") << QString("threshold-32mib.zip") << true << true; QTest::newRow("unpadded") << QString("unpadded-33mib.zip") << false << false; } void ArchiveTests::generatedRatioCorpus() { QFETCH(QString, name); QFETCH(bool, opens); QFETCH(bool, extracts); const auto path = QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/fixtures/archive-ratio/" + name; ArchiveReader reader; ArchiveError error; QCOMPARE(reader.open(path, &error), opens); if (!opens) { QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); return; } quint64 bytes = 0; QCOMPARE(reader.extractTo("index.html", [&](const char *, qsizetype size) { bytes += quint64(size); return true; }, &error), extracts); if (!extracts) QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); QCOMPARE(bytes, 32ull * 1024 * 1024); } void ArchiveTests::crcAndPartialCleanup() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "correct contents"}}); QFile archive(path); QVERIFY(archive.open(QIODevice::ReadWrite)); QByteArray raw = archive.readAll(); const auto offset = raw.indexOf("correct contents"); QVERIFY(offset >= 0); raw[offset] = 'X'; archive.resize(0); QCOMPARE(archive.write(raw), raw.size()); archive.close(); ArchiveReader reader; ArchiveError error; QVERIFY(reader.open(path, &error)); QTemporaryDir out; QVERIFY(!reader.extract("index.html", out.path(), &error)); QCOMPARE(error.code, "E_ARCHIVE_CORRUPT"); QVERIFY(!QFile::exists(out.filePath("index.html"))); } void ArchiveTests::embeddedNul() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "hello"}}); QFile file(path); QVERIFY(file.open(QIODevice::ReadWrite)); auto raw = file.readAll(); const int central = raw.indexOf(QByteArray("PK\1\2", 4)); QVERIFY(central >= 0); raw[central + 46 + 2] = '\0'; file.resize(0); file.write(raw); file.close(); ArchiveReader reader; ArchiveError error; QVERIFY(!reader.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_UNSAFE_PATH"); } void ArchiveTests::nofollowExtraction() { #ifdef Q_OS_UNIX QTemporaryDir dir, out, victim; ArchiveReader reader; ArchiveError error; QVERIFY(reader.open(createZip(dir, {{"assets/a.css", "s"}}), &error)); QVERIFY(::symlink(QFile::encodeName(victim.path()).constData(), QFile::encodeName(out.filePath("assets")).constData()) == 0); QVERIFY(!reader.extract("assets/a.css", out.path(), &error)); QCOMPARE(error.code, "E_ARCHIVE_UNSAFE_PATH"); QVERIFY(!QFile::exists(victim.filePath("a.css"))); #endif } void ArchiveTests::epub3() { QTemporaryDir dir; ArchiveReader reader; ArchiveError error; ArchiveDocument doc; QVERIFY(reader.open(createZip(dir, epubFiles()), &error)); QVERIFY2(reader.describe(true, &doc, &error), qPrintable(error.message)); QCOMPARE(doc.format, "epub"); QCOMPARE(doc.title, "試験書籍"); QVERIFY(doc.rtl); QVERIFY(doc.fixed); QCOMPARE(doc.entry, "OPS/one.xhtml"); QCOMPARE(doc.spine.size(), 2); QCOMPARE(doc.spine[0].toMap()["href"].toString(), "OPS/one.xhtml"); QCOMPARE(doc.spine[1].toMap()["layout"].toString(), "reflowable"); QVERIFY(!doc.spine[1].toMap()["linear"].toBool()); QCOMPARE(doc.outline[0].toMap()["href"].toString(), "OPS/two.xhtml#end"); QCOMPARE(doc.outline[1].toMap()["depth"].toInt(), 1); QCOMPARE(doc.pageList.size(), 1); QCOMPARE(doc.landmarks.size(), 1); } void ArchiveTests::epubSpreadMetadata() { for (const auto &policy : QStringList{"auto", "both", "none", "landscape"}) { QTemporaryDir dir; auto files = epubFiles(); files[2].data.replace("", QByteArray("") + policy.toUtf8() + ""); files[2].data.replace("", ""); files[2].data.replace("rendition:layout-reflowable", "rendition:layout-reflowable rendition:spread-none rendition:page-spread-center"); ArchiveReader reader; ArchiveError error; ArchiveDocument doc; QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); QCOMPARE(doc.spread, policy); QCOMPARE(doc.toVariant().value("spread").toString(), policy); QCOMPARE(doc.spine[0].toMap().value("renditionSpread").toString(), policy); QCOMPARE(doc.spine[0].toMap().value("spread").toString(), "right"); QCOMPARE(doc.spine[1].toMap().value("renditionSpread").toString(), "none"); QCOMPARE(doc.spine[1].toMap().value("spread").toString(), "center"); QCOMPARE(doc.spine[1].toMap().value("layout").toString(), "reflowable"); } QTemporaryDir dir; auto files = epubFiles(); files[2].data.replace("", ""); ArchiveReader reader; ArchiveError error; ArchiveDocument doc; QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); QCOMPARE(doc.spread, "auto"); QCOMPARE(doc.spine[0].toMap().value("spread").toString(), "left"); QCOMPARE(doc.spine[0].toMap().value("renditionSpread").toString(), "both"); } void ArchiveTests::epub2Ncx() { QTemporaryDir dir; auto files = epubFiles(); files[2].data.replace("version=\"3.0\"", "version=\"2.0\""); files[2].data.replace("", ""); files[5].data = "NCX chapter)"}); ArchiveReader reader; ArchiveError error; ArchiveDocument doc; QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); QCOMPARE(doc.outline[0].toMap()["title"].toString(), "NCX chapter"); QVERIFY(doc.warnings.contains("E_EPUB_NAV_INVALID")); } void ArchiveTests::xmlEntitiesAndDepth() { QTemporaryDir dir; auto files = epubFiles(); files[1].data = R"(]>)"; ArchiveReader reader; ArchiveError error; ArchiveDocument doc; QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(!reader.describe(true, &doc, &error)); QCOMPARE(error.code, "E_EPUB_PACKAGE_INVALID"); files = epubFiles(); files[1].data = QByteArray("") + QByteArray("").repeated(129) + QByteArray("").repeated(129) + ""; QVERIFY(reader.open(createZip(dir, files, "deep.zip"), &error)); QVERIFY(!reader.describe(true, &doc, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); } void ArchiveTests::missingSpineAndFallback() { QTemporaryDir dir; auto files = epubFiles(); files[2].data.replace("href=\"two.xhtml\"", "href=\"missing.xhtml\""); ArchiveReader reader; ArchiveError error; ArchiveDocument doc; QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); QVERIFY(doc.spine[1].toMap()["missing"].toBool()); QVERIFY(doc.warnings.contains("E_EPUB_SPINE_MISSING")); files = epubFiles(); files[2].data.replace("", ""); QVERIFY(reader.open(createZip(dir, files, "fallback.zip"), &error)); QVERIFY(reader.describe(true, &doc, &error)); QCOMPARE(doc.spine[0].toMap()["href"].toString(), "OPS/one.xhtml"); files[2].data.replace("fallback=\"one\"", "fallback=\"foreign\""); QVERIFY(reader.open(createZip(dir, files, "cycle.zip"), &error)); QVERIFY(reader.describe(true, &doc, &error)); QVERIFY(doc.spine[0].toMap()["missing"].toBool()); } void ArchiveTests::forbiddenNavTargets() { QTemporaryDir dir; auto files = epubFiles(); files[5].data.replace("two.xhtml#end", "../../outside.xhtml"); files[5].data.replace("href=\"one.xhtml\"", "href=\"file:///etc/passwd\""); ArchiveReader reader; ArchiveError error; ArchiveDocument doc; QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); QVERIFY(doc.outline[0].toMap()["missing"].toBool()); QVERIFY(doc.outline[1].toMap()["missing"].toBool()); } void ArchiveTests::fontObfuscationAndDrm() { QTemporaryDir dir; auto files = epubFiles(); files[2].data.replace("", ""); QByteArray plain(2000, Qt::Uninitialized); for (int i = 0; i < plain.size(); ++i) plain[i] = char(i % 251); auto encrypted = plain; const auto key = QCryptographicHash::hash("urn:uuid:1234", QCryptographicHash::Sha1); for (int i = 0; i < 1040; ++i) encrypted[i] = char(uchar(encrypted[i]) ^ uchar(key[i % 20])); files.append(File{"OPS/font.otf", encrypted}); files.append(File{"META-INF/encryption.xml", R"()"}); ArchiveReader reader; ArchiveError error; ArchiveDocument doc; QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); QByteArray decoded; QVERIFY(reader.read("OPS/font.otf", 3000, &decoded, &error)); QCOMPARE(decoded, plain); files.last().data.replace("http://www.idpf.org/2008/embedding", "urn:unsupported-drm"); QVERIFY(reader.open(createZip(dir, files, "drm.zip"), &error)); QVERIFY(!reader.describe(true, &doc, &error)); QCOMPARE(error.code, "E_DRM_UNSUPPORTED"); } void ArchiveTests::encryptedZip() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "protected contents"}}); int zipError = 0; auto *archive = zip_open(QFile::encodeName(path).constData(), 0, &zipError); QVERIFY(archive); QCOMPARE(zip_file_set_encryption(archive, 0, ZIP_EM_AES_256, "fixture-password"), 0); QCOMPARE(zip_close(archive), 0); ArchiveReader reader; ArchiveError error; QVERIFY(!reader.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_ENCRYPTED"); } void ArchiveTests::forgedSize() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "declared contents"}}); QFile file(path); QVERIFY(file.open(QIODevice::ReadWrite)); auto bytes = file.readAll(); const int central = bytes.indexOf(QByteArray("PK\1\2", 4)); QVERIFY(central >= 0); qToLittleEndian(quint32(1024 * 1024 * 1024), bytes.data() + central + 24); file.resize(0); file.write(bytes); file.close(); ArchiveReader reader; ArchiveError error; QVERIFY(!reader.open(path, &error)); QVERIFY(error.code == "E_ARCHIVE_LIMIT" || error.code == "E_ARCHIVE_CORRUPT"); } void ArchiveTests::metadataLimits() { QTemporaryDir dir; auto files = epubFiles(); files[2].data.replace("idref=\"one\"", "idref=\"" + QByteArray(1025, 'a') + "\""); ArchiveReader reader; ArchiveError error; ArchiveDocument document; QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(!reader.describe(true, &document, &error)); QCOMPARE(error.code, "E_EPUB_PACKAGE_INVALID"); files = epubFiles(); files[1].data.replace("", QByteArray("").repeated(20000) + ""); QVERIFY(reader.open(createZip(dir, files, "renditions.epub"), &error)); QVERIFY(!reader.describe(true, &document, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); } void ArchiveTests::extensionlessManifestResources() { QTemporaryDir dir; auto files = epubFiles(); files[2].data.replace("one.xhtml", "chapter"); files[3].path = "OPS/chapter"; files[2].data.replace("", ""); files.append(File{"OPS/picture", "image fixture"}); files.append(File{"OPS/font", "font fixture"}); ArchiveReader reader; ArchiveError error; ArchiveDocument document; QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &document, &error)); QCOMPARE(document.entry, "OPS/chapter"); QHash types; for (const auto &entry : reader.entries()) types[entry.path] = entry.mime; QCOMPARE(types["OPS/chapter"], "application/xhtml+xml"); QCOMPARE(types["OPS/picture"], "image/png"); QCOMPARE(types["OPS/font"], "font/otf"); } void ArchiveTests::streamingExtraction() { QTemporaryDir dir; QByteArray original(170000, 'x'); original[12345] = 'y'; ArchiveReader reader; ArchiveError error; QVERIFY(reader.open(createZip(dir, {{"index.html", original}}), &error)); QByteArray result; qsizetype largest = 0; int chunks = 0; QVERIFY(reader.extractTo("index.html", [&](const char *data, qsizetype size) { largest = std::max(largest, size); result.append(data, size); ++chunks; return true; }, &error)); QCOMPARE(result, original); QVERIFY(largest <= 65536); QVERIFY(chunks > 1); QVERIFY(!reader.extractTo("index.html", [](const char *, qsizetype) { return false; }, &error)); QCOMPARE(error.code, "E_STORAGE_FULL"); ArchiveLimits limits; limits.maxTotalBytes = quint64(original.size()); ArchiveReader limited(limits); QVERIFY(limited.open(dir.filePath("book.zip"), &error)); QVERIFY(limited.extractTo("index.html", [](const char *, qsizetype) { return true; }, &error)); QVERIFY(!limited.extractTo("index.html", [](const char *, qsizetype) { return true; }, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); } void ArchiveTests::borrowedReadOnlySource() { QTemporaryDir dir; const QByteArray contents = "opened handle"; const auto path = createZip(dir, {{"index.html", contents}}); QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); const auto before = QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256); #ifdef Q_OS_UNIX // An unlinked source can only be parsed through the still-open descriptor. QVERIFY(QFile::remove(path)); #endif { ArchiveReader reader; ArchiveError error; ArchiveDocument document; QVERIFY2(reader.openFile(&source, &error), qPrintable(error.message)); QVERIFY(reader.describe(false, &document, &error)); QCOMPARE(document.entry, "index.html"); QByteArray actual; QVERIFY(reader.extractTo("index.html", [&](const char *data, qsizetype size) { actual.append(data, size); return true; }, &error)); QCOMPARE(actual, contents); } QVERIFY(source.isOpen()); QVERIFY(source.seek(0)); QCOMPARE(QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256), before); } void ArchiveTests::sourceLifetimeAndAccess() { QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "handle"}}); ArchiveReader reader; ArchiveError error; QVERIFY(!reader.openFile(nullptr, &error)); QFile writable(path); QVERIFY(writable.open(QIODevice::ReadWrite)); QVERIFY(!reader.openFile(&writable, &error)); QCOMPARE(error.code, "E_OPEN_FAILED"); writable.close(); auto source = std::make_unique(path); QVERIFY(source->open(QIODevice::ReadOnly)); QVERIFY(reader.openFile(source.get(), &error)); source.reset(); QByteArray actual; QVERIFY(!reader.read("index.html", 1024, &actual, &error)); QCOMPARE(error.code, "E_ARCHIVE_CORRUPT"); } void ArchiveTests::sandboxedWorkerStreamsOpenedSource() { QTemporaryDir dir; QByteArray contents(170000, 'x'); contents.replace(0, 6, ""); const auto path = createZip(dir, {{"index.html", contents}}); WorkerProcess worker("archive-handle-test", 1); QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-archive-worker", {"--source", path})); QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY2(failed.isEmpty(), failed.isEmpty() ? "" : qPrintable(failed.first()[1].toString())); QVERIFY(!ready.isEmpty()); QList responses; worker.request("ping", {}, [&](const QCborMap &reply) { responses.append(reply); }); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 5000); QVERIFY(responses.takeFirst().value("result").toMap().value("ready").toBool()); worker.request("open", {}, [&](const QCborMap &reply) { responses.append(reply); }); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 5000); const auto opened = responses.takeFirst(); QVERIFY2(!opened.contains(QStringLiteral("error")), qPrintable(opened.value("error").toMap().value("message").toString())); #ifdef Q_OS_UNIX QVERIFY(QFile::remove(path)); #endif QByteArray actual; bool finished = false; int chunks = 0; worker.request("extract", {{QStringLiteral("path"), QStringLiteral("index.html")}}, [&](const QCborMap &reply) { const auto result = reply.value("result").toMap(); if (result.value("more").toBool()) { actual += result.value("data").toByteArray(); ++chunks; } else { responses.append(reply); finished = true; } }); QTRY_VERIFY_WITH_TIMEOUT(finished || !failed.isEmpty(), 5000); QVERIFY(failed.isEmpty()); QVERIFY(finished); QCOMPARE(chunks, 3); QVERIFY(!responses.last().contains(QStringLiteral("error"))); QCOMPARE(responses.last().value("result").toMap().value("size").toInteger(), contents.size()); QCOMPARE(actual, contents); worker.stop(); } QTEST_GUILESS_MAIN(ArchiveTests) #include "test_archive.moc"