Add a zip reader and writer for .qetz projects (bundled miniz)

First stage of the zipped project file discussed in #1440 (and #865):
the zip itself, nothing reads or writes a project with it yet.

miniz 3.1.2 (MIT, one .c and one .h, no zlib) is bundled unmodified in
sources/container/miniz, as the LZMA SDK is for .edz files, so builds
need nothing new. QetZip wraps it: plain ZIP with Deflate, entries stored
where asked (pictures, the mimetype entry), written through QSaveFile so
a failed save keeps the previous file, without timestamps so that the
same project gives the same bytes. Reading refuses unsafe or repeated
entry names, encrypted entries and archives over 2 GB once unpacked.

Test: tst_qetzip (round trip, same bytes twice, names refused both ways,
truncated and non-zip input). A written archive also passes unzip -t and
Python's zipfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Wga9B92BNurDEtxxtCDxf
This commit is contained in:
ispyisail
2026-10-10 22:18:16 +13:00
parent 124a889be3
commit d7ac633043
10 changed files with 9883 additions and 0 deletions
+7
View File
@@ -102,6 +102,13 @@ else()
message(STATUS "Qt Qml module not available: JavaScript scripting (--run) disabled")
endif()
# The zip container of a .qetz project is read and written with the bundled
# miniz (sources/container/miniz): in memory only, and without timestamps so
# that saving the same project twice gives the same bytes. The names must not
# clash with the zlib Qt links. Every target including miniz.h needs the same
# three, hence directory-wide.
add_compile_definitions(MINIZ_NO_TIME MINIZ_NO_STDIO MINIZ_NO_ZLIB_COMPATIBLE_NAMES)
# The crash handler writes a backtrace into the dump. Detecting this with
# __has_include(<execinfo.h>) is not enough: the header is present on FreeBSD
# too, but backtrace() lives in a separate libexecinfo there, so the compile
+4
View File
@@ -165,6 +165,10 @@ set(QET_SRC_FILES
${QET_DIR}/sources/import/edz/lzma/LzmaDec.c
${QET_DIR}/sources/import/edz/lzma/Ppmd7.c
${QET_DIR}/sources/import/edz/lzma/Ppmd7Dec.c
${QET_DIR}/sources/container/miniz/miniz.c
${QET_DIR}/sources/container/miniz/miniz.h
${QET_DIR}/sources/container/qetzip.cpp
${QET_DIR}/sources/container/qetzip.h
${QET_DIR}/sources/borderproperties.cpp
${QET_DIR}/sources/borderproperties.h
${QET_DIR}/sources/bordertitleblock.cpp
+22
View File
@@ -0,0 +1,22 @@
Copyright 2013-2014 RAD Game Tools and Valve Software
Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC
All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
miniz 3.1.2 (https://github.com/richgel999/miniz, release asset
miniz-3.1.2.zip, sha256 f0446d863f9c19926ad9483c523fdc42e42b8d4a6a431d27e09d49c79a140d9a),
MIT licence (LICENSE). Unmodified. Reads and writes the zip container of a
.qetz project (sources/container/qetzip.*). Built with MINIZ_NO_TIME,
MINIZ_NO_STDIO and MINIZ_NO_ZLIB_COMPATIBLE_NAMES (see qetzip.h).
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "qetzip.h"
#include "miniz/miniz.h"
#include <QFile>
#include <QSaveFile>
#include <QSet>
namespace {
void setError(QString *error, const QString &text)
{
if (error) *error = text;
}
//A name read from an archive is only ever a key, but refuse the ones a
//careless caller could turn into a path outside a folder: absolute,
//with a drive, with "..", or not valid UTF-8.
bool safeName(const QString &name)
{
if (name.isEmpty() || name.startsWith(QLatin1Char('/'))
|| name.contains(QLatin1Char('\\')) || name.contains(QLatin1Char(':'))
|| name.contains(QChar::ReplacementCharacter)) {
return false;
}
for (const QString &part : name.split(QLatin1Char('/'))) {
if (part == QLatin1String("..")) return false;
}
return true;
}
}
/**
@brief QetZip::toBytes
@return the zip holding @p entries in their order, or an empty array
with @p error set
*/
QByteArray QetZip::toBytes(const QList<Entry> &entries, QString *error)
{
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
if (!mz_zip_writer_init_heap(&zip, 0, 0)) {
setError(error, QStringLiteral("cannot start a zip archive"));
return {};
}
QSet<QString> names;
for (const Entry &entry : entries)
{
if (!safeName(entry.name) || names.contains(entry.name)) {
setError(error, QStringLiteral("invalid or repeated entry name \"%1\"").arg(entry.name));
mz_zip_writer_end(&zip);
return {};
}
names.insert(entry.name);
const QByteArray name = entry.name.toUtf8();
const mz_uint level = entry.compress ? MZ_DEFAULT_LEVEL : MZ_NO_COMPRESSION;
if (!mz_zip_writer_add_mem(&zip, name.constData(), entry.data.constData(),
size_t(entry.data.size()), level)) {
setError(error, QStringLiteral("cannot add \"%1\": %2")
.arg(entry.name, QString::fromLatin1(mz_zip_get_error_string(mz_zip_get_last_error(&zip)))));
mz_zip_writer_end(&zip);
return {};
}
}
void *buffer = nullptr;
size_t size = 0;
if (!mz_zip_writer_finalize_heap_archive(&zip, &buffer, &size)) {
setError(error, QStringLiteral("cannot finish the zip archive"));
mz_zip_writer_end(&zip);
return {};
}
const QByteArray bytes(static_cast<const char *>(buffer), qsizetype(size));
mz_zip_writer_end(&zip); //frees buffer
return bytes;
}
/**
@brief QetZip::write
Write @p entries to @p path, replacing it only once the whole archive
is written.
*/
bool QetZip::write(const QString &path, const QList<Entry> &entries, QString *error)
{
const QByteArray bytes = toBytes(entries, error);
if (bytes.isEmpty()) return false;
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly)) {
setError(error, file.errorString());
return false;
}
if (file.write(bytes) != bytes.size() || !file.commit()) {
setError(error, file.errorString());
return false;
}
return true;
}
/**
@brief QetZip::fromBytes
Read every file entry of the zip @p zip into @p entries, in archive
order. Folders are skipped. An encrypted entry, an unsafe or repeated
name, a method miniz cannot read, or more than MaxEntries entries or
MaxUncompressedSize bytes refuse the whole archive.
*/
bool QetZip::fromBytes(const QByteArray &zip_bytes, QList<Entry> *entries, QString *error)
{
if (!entries) return false;
entries->clear();
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
if (!mz_zip_reader_init_mem(&zip, zip_bytes.constData(), size_t(zip_bytes.size()), 0)) {
setError(error, QStringLiteral("not a zip archive: %1")
.arg(QString::fromLatin1(mz_zip_get_error_string(mz_zip_get_last_error(&zip)))));
return false;
}
const mz_uint count = mz_zip_reader_get_num_files(&zip);
if (count > mz_uint(MaxEntries)) {
setError(error, QStringLiteral("too many entries (%1)").arg(count));
mz_zip_reader_end(&zip);
return false;
}
qint64 total = 0;
QSet<QString> names;
for (mz_uint i = 0 ; i < count ; ++i)
{
mz_zip_archive_file_stat stat;
if (!mz_zip_reader_file_stat(&zip, i, &stat)) {
setError(error, QStringLiteral("unreadable entry %1").arg(i));
mz_zip_reader_end(&zip);
return false;
}
if (stat.m_is_directory) continue;
const QString name = QString::fromUtf8(stat.m_filename);
if (stat.m_is_encrypted || !stat.m_is_supported || !safeName(name)
|| names.contains(name)) {
setError(error, QStringLiteral("entry \"%1\" cannot be read").arg(name));
mz_zip_reader_end(&zip);
return false;
}
total += qint64(stat.m_uncomp_size);
if (total > MaxUncompressedSize) {
setError(error, QStringLiteral("archive too large once unpacked"));
mz_zip_reader_end(&zip);
return false;
}
size_t size = 0;
void *data = mz_zip_reader_extract_to_heap(&zip, i, &size, 0);
if (!data) {
setError(error, QStringLiteral("cannot unpack \"%1\": %2")
.arg(name, QString::fromLatin1(mz_zip_get_error_string(mz_zip_get_last_error(&zip)))));
mz_zip_reader_end(&zip);
return false;
}
Entry entry;
entry.name = name;
entry.data = QByteArray(static_cast<const char *>(data), qsizetype(size));
entry.compress = stat.m_method != 0;
mz_free(data);
names.insert(name);
entries->append(entry);
}
mz_zip_reader_end(&zip);
return true;
}
/**
@brief QetZip::read
fromBytes() on the file at @p path
*/
bool QetZip::read(const QString &path, QList<Entry> *entries, QString *error)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
setError(error, file.errorString());
return false;
}
return fromBytes(file.readAll(), entries, error);
}
+55
View File
@@ -0,0 +1,55 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef QETZIP_H
#define QETZIP_H
#include <QByteArray>
#include <QList>
#include <QString>
/**
@brief The QetZip namespace
Reads and writes the zip file a .qetz project is stored in: plain ZIP,
Deflate, as ODF and OOXML documents are, so any unzip tool opens it.
A whole archive is read into, or written from, memory -- projects are
megabytes, not gigabytes -- and written through QSaveFile, so a failed
save leaves the previous file as it was.
Entries carry no timestamp: saving the same project twice gives the
same bytes.
*/
namespace QetZip
{
struct Entry
{
QString name; ///< path inside the archive, '/'-separated
QByteArray data;
bool compress = true; ///< false: stored (pictures, the mimetype entry)
};
/// Largest archive read(): uncompressed total and entry count
constexpr qint64 MaxUncompressedSize = qint64(2) * 1024 * 1024 * 1024;
constexpr int MaxEntries = 100000;
bool write(const QString &path, const QList<Entry> &entries, QString *error = nullptr);
QByteArray toBytes(const QList<Entry> &entries, QString *error = nullptr);
bool read(const QString &path, QList<Entry> *entries, QString *error = nullptr);
bool fromBytes(const QByteArray &zip, QList<Entry> *entries, QString *error = nullptr);
}
#endif // QETZIP_H
+11
View File
@@ -1162,3 +1162,14 @@ if(Qt6_VERSION VERSION_LESS 6.5.0)
endif()
endforeach()
endif()
# The zip a .qetz project is stored in: round trip, same bytes twice,
# unsafe entry names refused (sources/container/qetzip.*, bundled miniz).
add_executable(
tst_qetzip
tst_qetzip.cpp
${QET_DIR}/sources/container/qetzip.cpp
${QET_DIR}/sources/container/miniz/miniz.c)
add_test(NAME tst_qetzip COMMAND tst_qetzip)
target_include_directories(tst_qetzip PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_qetzip PRIVATE Qt::Test)
+145
View File
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QTemporaryDir>
#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()));
}
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"