583 lines
37 KiB
C++
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"
|