mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-14 17:44:13 +02:00
c240e4e6f7
miniz is built with MINIZ_NO_TIME so that saving the same project twice gives the same bytes. Without a clock it writes 0 as each entry's DOS date: day 0 of month 0, 1980, which is not a date. Unzip tools show it as 30 Nov 1979, others fail the conversion and show 13 Dec 1901 (reported on PR #1462). Write 1 Jan 1980 00:00, the first valid DOS date, into the local and central header of every entry once the archive is built. The bytes stay reproducible, and miniz stays unmodified. Using the save time instead was rejected: two saves would no longer give the same file, and miniz converts through the local time zone, so even a fixed time_t would differ between machines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
6.1 KiB
C++
174 lines
6.1 KiB
C++
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
#include <QtTest>
|
|
|
|
#include <QTemporaryDir>
|
|
#include <QtEndian>
|
|
|
|
#include "container/qetzip.h"
|
|
#include "container/miniz/miniz.h"
|
|
|
|
// The zip a .qetz project is stored in (sources/container/qetzip.*):
|
|
// entries come back as written, in order, stored ones stored; the same
|
|
// entries give the same bytes; unsafe names are refused both ways.
|
|
class tst_qetzip : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
static QList<QetZip::Entry> sample()
|
|
{
|
|
QList<QetZip::Entry> entries;
|
|
entries.append({QStringLiteral("mimetype"), QByteArray("application/x-qelectrotech-project+zip"), false});
|
|
QByteArray xml("<project>");
|
|
for (int i = 0 ; i < 2000 ; ++i) xml += "<element name=\"K1\"/>";
|
|
xml += "</project>";
|
|
entries.append({QStringLiteral("project.xml"), xml, true});
|
|
QByteArray picture;
|
|
for (int i = 0 ; i < 4096 ; ++i) picture += char((i * 7919) & 0xff);
|
|
entries.append({QStringLiteral("images/00.png"), picture, false});
|
|
entries.append({QStringLiteral("folios/é ü.xml"), QByteArray("<diagram/>"), true});
|
|
entries.append({QStringLiteral("empty"), QByteArray(), true});
|
|
return entries;
|
|
}
|
|
|
|
// a zip holding one entry named @p name, written past QetZip's checks
|
|
static QByteArray rawZip(const char *name)
|
|
{
|
|
mz_zip_archive zip;
|
|
mz_zip_zero_struct(&zip);
|
|
mz_zip_writer_init_heap(&zip, 0, 0);
|
|
mz_zip_writer_add_mem(&zip, name, "x", 1, MZ_DEFAULT_LEVEL);
|
|
void *buffer = nullptr;
|
|
size_t size = 0;
|
|
mz_zip_writer_finalize_heap_archive(&zip, &buffer, &size);
|
|
const QByteArray bytes(static_cast<const char *>(buffer), qsizetype(size));
|
|
mz_zip_writer_end(&zip);
|
|
return bytes;
|
|
}
|
|
|
|
private slots:
|
|
void roundTrip()
|
|
{
|
|
const QList<QetZip::Entry> in = sample();
|
|
QString error;
|
|
const QByteArray zip = QetZip::toBytes(in, &error);
|
|
QVERIFY2(!zip.isEmpty(), qPrintable(error));
|
|
QVERIFY(zip.startsWith("PK"));
|
|
QVERIFY2(zip.size() < in.at(1).data.size() / 4, "the XML entry was not compressed");
|
|
|
|
QList<QetZip::Entry> out;
|
|
QVERIFY2(QetZip::fromBytes(zip, &out, &error), qPrintable(error));
|
|
QCOMPARE(out.size(), in.size());
|
|
for (int i = 0 ; i < in.size() ; ++i) {
|
|
QCOMPARE(out.at(i).name, in.at(i).name);
|
|
QCOMPARE(out.at(i).data, in.at(i).data);
|
|
if (!in.at(i).data.isEmpty())
|
|
QCOMPARE(out.at(i).compress, in.at(i).compress);
|
|
}
|
|
// mimetype first and stored: its bytes appear as is after the header
|
|
QCOMPARE(zip.indexOf("application/x-qelectrotech-project+zip"), 30 + int(qstrlen("mimetype")));
|
|
}
|
|
|
|
void sameBytesTwice()
|
|
{
|
|
QCOMPARE(QetZip::toBytes(sample()), QetZip::toBytes(sample()));
|
|
}
|
|
|
|
// every entry carries a valid date in both of its headers, not the
|
|
// 0 miniz writes without a clock (shown as 1979 or 1901 by unzip tools)
|
|
void entriesHaveValidDate()
|
|
{
|
|
const QByteArray zip = QetZip::toBytes(sample());
|
|
QVERIFY(!zip.isEmpty());
|
|
mz_zip_archive archive;
|
|
mz_zip_zero_struct(&archive);
|
|
QVERIFY(mz_zip_reader_init_mem(&archive, zip.constData(), size_t(zip.size()), 0));
|
|
const mz_uint count = mz_zip_reader_get_num_files(&archive);
|
|
QCOMPARE(int(count), sample().size());
|
|
for (mz_uint i = 0 ; i < count ; ++i) {
|
|
mz_zip_archive_file_stat stat;
|
|
QVERIFY(mz_zip_reader_file_stat(&archive, i, &stat));
|
|
const char *local = zip.constData() + stat.m_local_header_ofs;
|
|
const char *central = zip.constData() + archive.m_central_directory_file_ofs + stat.m_central_dir_ofs;
|
|
QCOMPARE(qFromLittleEndian<quint16>(local), quint16(0x50 | 0x4b << 8)); // "PK"
|
|
QCOMPARE(qFromLittleEndian<quint16>(central), quint16(0x50 | 0x4b << 8));
|
|
// 1 Jan 1980: day 1, month 1, year 0 since 1980; time 00:00:00
|
|
QCOMPARE(qFromLittleEndian<quint16>(local + 12), quint16(0x21));
|
|
QCOMPARE(qFromLittleEndian<quint16>(central + 14), quint16(0x21));
|
|
QCOMPARE(qFromLittleEndian<quint16>(local + 10), quint16(0));
|
|
QCOMPARE(qFromLittleEndian<quint16>(central + 12), quint16(0));
|
|
}
|
|
mz_zip_reader_end(&archive);
|
|
}
|
|
|
|
void refusesUnsafeNamesOnWrite_data()
|
|
{
|
|
QTest::addColumn<QString>("name");
|
|
QTest::newRow("parent") << QStringLiteral("../x.xml");
|
|
QTest::newRow("inner parent") << QStringLiteral("folios/../../x.xml");
|
|
QTest::newRow("absolute") << QStringLiteral("/etc/x");
|
|
QTest::newRow("drive") << QStringLiteral("C:/x");
|
|
QTest::newRow("backslash") << QStringLiteral("folios\\x.xml");
|
|
QTest::newRow("empty") << QString();
|
|
}
|
|
void refusesUnsafeNamesOnWrite()
|
|
{
|
|
QFETCH(QString, name);
|
|
QString error;
|
|
QVERIFY(QetZip::toBytes({{name, QByteArray("x"), true}}, &error).isEmpty());
|
|
QVERIFY(!error.isEmpty());
|
|
}
|
|
|
|
void refusesRepeatedNames()
|
|
{
|
|
QString error;
|
|
QVERIFY(QetZip::toBytes({{QStringLiteral("a"), "1", true}, {QStringLiteral("a"), "2", true}}, &error).isEmpty());
|
|
}
|
|
|
|
void refusesUnsafeNamesOnRead()
|
|
{
|
|
QList<QetZip::Entry> out;
|
|
QString error;
|
|
QVERIFY(QetZip::fromBytes(rawZip("ok/name.xml"), &out, &error));
|
|
QVERIFY(!QetZip::fromBytes(rawZip("../evil.xml"), &out, &error));
|
|
QVERIFY(out.isEmpty());
|
|
// miniz will not write a name starting with '/': write "Xabs.xml"
|
|
// and turn the X into '/' in both of its headers
|
|
QByteArray absolute = rawZip("Xabs.xml");
|
|
QCOMPARE(absolute.count("Xabs.xml"), 2);
|
|
absolute.replace("Xabs.xml", "/abs.xml");
|
|
QVERIFY(!QetZip::fromBytes(absolute, &out, &error));
|
|
}
|
|
|
|
void refusesGarbage()
|
|
{
|
|
QList<QetZip::Entry> out;
|
|
QString error;
|
|
QVERIFY(!QetZip::fromBytes(QByteArray("not a zip at all"), &out, &error));
|
|
QVERIFY(!error.isEmpty());
|
|
QByteArray cut = QetZip::toBytes(sample());
|
|
cut.chop(cut.size() / 2);
|
|
QVERIFY(!QetZip::fromBytes(cut, &out, &error));
|
|
}
|
|
|
|
void writeAndRead()
|
|
{
|
|
QTemporaryDir dir;
|
|
QVERIFY(dir.isValid());
|
|
const QString path = dir.filePath(QStringLiteral("p.qetz"));
|
|
QString error;
|
|
QVERIFY2(QetZip::write(path, sample(), &error), qPrintable(error));
|
|
QList<QetZip::Entry> out;
|
|
QVERIFY2(QetZip::read(path, &out, &error), qPrintable(error));
|
|
QCOMPARE(out.size(), sample().size());
|
|
QCOMPARE(out.at(1).data, sample().at(1).data);
|
|
// a refused write leaves the file as it was
|
|
QVERIFY(!QetZip::write(path, {{QStringLiteral("../x"), "x", true}}, &error));
|
|
QVERIFY(QetZip::read(path, &out, &error));
|
|
QCOMPARE(out.size(), sample().size());
|
|
}
|
|
};
|
|
|
|
QTEST_APPLESS_MAIN(tst_qetzip)
|
|
|
|
#include "tst_qetzip.moc"
|