Files
docview/tests/test_archive.cpp
T
2026-09-21 13:41:40 +09:00

583 lines
37 KiB
C++

#include "archive/archive.h"
#include "common/worker_process.h"
#include <QCryptographicHash>
#include <QFile>
#include <QTemporaryDir>
#include <QSignalSpy>
#include <QTest>
#include <QtEndian>
#include <zip.h>
#ifdef Q_OS_UNIX
#include <unistd.h>
#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<File> &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<quint32>(reinterpret_cast<const uchar *>(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<quint32>(reinterpret_cast<const uchar *>(raw.constData() + central + 20));
if (size > padded) return false;
if (compressed) *compressed = size;
qToLittleEndian<quint32>(padded, raw.data() + 18);
qToLittleEndian<quint32>(padded, raw.data() + central + 20);
qToLittleEndian<quint32>(central + padded - size, raw.data() + end + 16);
raw.insert(central, QByteArray(padded - size, '\0'));
return archive.resize(0) && archive.write(raw) == raw.size();
}
QList<File> epubFiles(QByteArray package = {}, QByteArray nav = {}) {
if (package.isEmpty()) package = R"(<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>試験書籍</dc:title><dc:identifier id="uid">urn:uuid:1234</dc:identifier><meta property="rendition:layout">pre-paginated</meta></metadata><manifest><item id="one" href="one.xhtml" media-type="application/xhtml+xml"/><item id="two" href="two.xhtml" media-type="application/xhtml+xml"/><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/></manifest><spine page-progression-direction="rtl"><itemref idref="one"/><itemref idref="two" linear="no" properties="rendition:layout-reflowable"/></spine></package>)";
if (nav.isEmpty()) nav = R"(<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><body><nav epub:type="toc"><ol><li><a href="two.xhtml#end">Second first</a><ol><li><a href="one.xhtml">First second</a></li></ol></li></ol></nav><nav epub:type="page-list"><ol><li><a href="one.xhtml#p1">i</a></li></ol></nav><nav epub:type="landmarks"><ol><li><a href="one.xhtml">Start</a></li></ol></nav></body></html>)";
return {{"mimetype", "application/epub+zip"}, {"META-INF/container.xml", R"(<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="OPS/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>)"},
{"OPS/package.opf", package}, {"OPS/one.xhtml", "<html><body>one</body></html>"}, {"OPS/two.xhtml", "<html><body>two</body></html>"}, {"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<QString>("path"); QTest::addColumn<bool>("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<QByteArray>("first"); QTest::addColumn<QByteArray>("second"); QTest::addColumn<quint32>("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<File> 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<int>("method");
for (const auto &entry : QList<QPair<const char *, int>>{{"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<quint32>(reinterpret_cast<const uchar *>(raw.constData() + central + 20));
qToLittleEndian<quint32>(size + 5, raw.data() + 18); qToLittleEndian<quint32>(size + 5, raw.data() + central + 20);
qToLittleEndian<quint32>(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<QString>("name"); QTest::addColumn<bool>("opens"); QTest::addColumn<bool>("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("</metadata>", QByteArray("<meta property=\"rendition:spread\">") + policy.toUtf8() + "</meta></metadata>");
files[2].data.replace("<itemref idref=\"one\"/>", "<itemref idref=\"one\" properties=\"rendition:page-spread-right\"/>");
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("<itemref idref=\"one\"/>", "<itemref idref=\"one\" properties=\"page-spread-left rendition:spread-both\"/>");
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("<spine ", "<spine toc=\"ncx\" ");
files[2].data.replace("</manifest>", "<item id=\"ncx\" href=\"toc.ncx\" media-type=\"application/x-dtbncx+xml\"/></manifest>");
files[5].data = "<malformed";
files.append(File{"OPS/toc.ncx", R"(<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://127.0.0.1:18765/never-load.dtd"><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/"><navMap><navPoint id="n"><navLabel><text>NCX chapter</text></navLabel><content src="two.xhtml"/></navPoint></navMap></ncx>)"});
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"(<!DOCTYPE container [<!ENTITY secret SYSTEM "file:///etc/passwd">]><container><rootfiles><rootfile full-path="&secret;"/></rootfiles></container>)";
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("<container>") + QByteArray("<a>").repeated(129) + QByteArray("</a>").repeated(129) + "</container>";
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("<itemref idref=\"one\"", "<itemref idref=\"foreign\"");
files[2].data.replace("</manifest>", "<item id=\"foreign\" href=\"foreign.dat\" media-type=\"application/unknown\" fallback=\"one\"/></manifest>");
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("</manifest>", "<item id=\"font\" href=\"font.otf\" media-type=\"font/otf\"/></manifest>");
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"(<encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.idpf.org/2008/embedding"/><CipherData><CipherReference URI="OPS/font.otf"/></CipherData></EncryptedData></encryption>)"});
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>(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("</rootfiles>", QByteArray("<rootfile full-path=\"OPS/package.opf\"/>").repeated(20000) + "</rootfiles>");
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("</manifest>", "<item id=\"picture\" href=\"picture\" media-type=\"image/png\"/><item id=\"font\" href=\"font\" media-type=\"application/vnd.ms-opentype\"/></manifest>");
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<QString, QString> 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 = "<html><body>opened handle</body></html>";
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", "<html>handle</html>"}});
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<QFile>(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, "<html>");
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<QCborMap> 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"