From 5975b7576612da84d1274348b0f5b9e03c383a00 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 14 Oct 2026 08:57:25 +1300 Subject: [PATCH] .qetz: keep a symbol's type in its folio; refuse a database that does not match A folio file in a .qetz did not say which symbol each item is: the type (embed://...) was moved to project.sqlite, so a folio read alone could not be drawn or diffed. It stays in the folio file now; format 2 files, which moved it, are still read. Nothing checked that project.sqlite and the folio files agree: a row no item has, an item's row on another folio, or information rows of no symbol were read as far as they went. Reading now refuses such a file and says what does not match. Co-Authored-By: Claude Opus 5.5 --- sources/container/qetcontainerdb.cpp | 66 ++++++++++++++++++++--- tests/qttest/tst_qetcontainer.cpp | 80 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/sources/container/qetcontainerdb.cpp b/sources/container/qetcontainerdb.cpp index f7eb10b31..fd7b7bc4b 100644 --- a/sources/container/qetcontainerdb.cpp +++ b/sources/container/qetcontainerdb.cpp @@ -63,9 +63,14 @@ const QStringList FolioData = { QStringLiteral("folio"), QStringLiteral("indexrev"), QStringLiteral("filename"), QStringLiteral("locmach"), QStringLiteral("plant"), QStringLiteral("auto_page_num")}; //From format 3 on an item's uuid is its row's key, not a column of its - //own: it stays in the folio file, where it says which row is the item's + //own: it stays in the folio file, where it says which row is the item's. + //So does a symbol's type, the symbol it is: a folio file read alone + //still says what is drawn where const QStringList ElementData = { - QStringLiteral("type"), QStringLiteral("prefix"), QStringLiteral("freezeLabel")}; + QStringLiteral("prefix"), QStringLiteral("freezeLabel")}; + //Format 2 also moved the type +const QStringList ElementDataV2 = { + QStringLiteral("uuid"), QStringLiteral("type"), QStringLiteral("prefix"), QStringLiteral("freezeLabel")}; const QStringList ConductorData = { QStringLiteral("element1"), QStringLiteral("terminal1"), QStringLiteral("element2"), QStringLiteral("terminal2"), QStringLiteral("num"), @@ -735,7 +740,7 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS const QString item_key = v2 ? QStringLiteral("key") : QStringLiteral("uuid"); const QString element_ref = v2 ? QStringLiteral("element_key") : QStringLiteral("element_uuid"); const QString show_column = v2 ? QStringLiteral("NULL") : QStringLiteral("show"); - const QStringList element_data = v2 ? QStringList{QStringLiteral("uuid")} + ElementData : ElementData; + const QStringList element_data = v2 ? ElementDataV2 : ElementData; const QStringList conductor_data = v2 ? QStringList{QStringLiteral("uuid")} + ConductorData : ConductorData; auto quoted = [](const QStringList &names) { return QStringLiteral("\"") + names.join(QStringLiteral("\", \"")) + QStringLiteral("\""); @@ -783,10 +788,22 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS } } + //Every row must be an item's, once, on the folio the item is on: + //nothing enforces it in the file, which is untrusted + QSet folios_used, elements_used, conductors_used; + auto use = [error](QSet &used, const QString &key, const QString &what) { + if (used.contains(key)) { + setError(error, QStringLiteral("%1 %2 is in the folios twice").arg(what, key)); + return false; + } + used.insert(key); + return true; + }; for (QDomElement d : children(project, QStringLiteral("diagram"))) { const QString id = keyOf(d, FolioAttribute); if (id.isEmpty()) continue; + if (!use(folios_used, id, QStringLiteral("folio"))) return false; Statement f(db.m_db, QStringLiteral("SELECT %1, props_at, props_attrs FROM folio WHERE %2 = ?") .arg(quoted(FolioData), folio_key)); f.bind({id}); @@ -813,10 +830,15 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS for (QDomElement e : children(list, QStringLiteral("element"))) { const QString key = keyOf(e, KeyAttribute); if (key.isEmpty()) continue; - Statement s(db.m_db, QStringLiteral("SELECT %1, info_at, info_attrs, links_at, links_attrs FROM element WHERE %2 = ?") - .arg(quoted(element_data), item_key)); + if (!use(elements_used, key, QStringLiteral("symbol"))) return false; + Statement s(db.m_db, QStringLiteral("SELECT %1, info_at, info_attrs, links_at, links_attrs, %2 FROM element WHERE %3 = ?") + .arg(quoted(element_data), folio_ref, item_key)); s.bind({key}); if (!s.next()) { setError(error, QStringLiteral("symbol %1 is not in the database").arg(key)); return false; } + if (s.value(element_data.size() + 4).toString() != id) { + setError(error, QStringLiteral("symbol %1 is on folio %2, the database says another").arg(key, id)); + return false; + } QVariantList v; for (int i = 0 ; i < element_data.size() + 4 ; ++i) v << s.value(i); put(e, element_data, v, 0); @@ -854,15 +876,45 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS for (QDomElement c : children(list, QStringLiteral("conductor"))) { const QString key = keyOf(c, KeyAttribute); if (key.isEmpty()) continue; - Statement s(db.m_db, QStringLiteral("SELECT %1 FROM conductor WHERE %2 = ?") - .arg(quoted(conductor_data), item_key)); + if (!use(conductors_used, key, QStringLiteral("wire"))) return false; + Statement s(db.m_db, QStringLiteral("SELECT %1, %2 FROM conductor WHERE %3 = ?") + .arg(quoted(conductor_data), folio_ref, item_key)); s.bind({key}); if (!s.next()) { setError(error, QStringLiteral("wire %1 is not in the database").arg(key)); return false; } + if (s.value(conductor_data.size()).toString() != id) { + setError(error, QStringLiteral("wire %1 is on folio %2, the database says another").arg(key, id)); + return false; + } QVariantList v; for (int i = 0 ; i < conductor_data.size() ; ++i) v << s.value(i); put(c, conductor_data, v, 0); } } } + + //No row left over: each table's rows, counted, are the items read, + //and every entry belongs to a row + auto count = [&db](const QString &sql) { + Statement s(db.m_db, sql); + return s.next() ? s.value(0).toInt() : -1; + }; + const struct { QString sql; int expected; QString what; } checks[] = { + {QStringLiteral("SELECT count(*) FROM folio"), int(folios_used.size()), QStringLiteral("folio rows")}, + {QStringLiteral("SELECT count(*) FROM element"), int(elements_used.size()), QStringLiteral("symbol rows")}, + {QStringLiteral("SELECT count(*) FROM conductor"), int(conductors_used.size()), QStringLiteral("wire rows")}, + {QStringLiteral("SELECT count(*) FROM folio_property WHERE %1 NOT IN (SELECT %2 FROM folio)") + .arg(folio_ref, folio_key), 0, QStringLiteral("folio property rows of no folio")}, + {QStringLiteral("SELECT count(*) FROM element_info WHERE %1 NOT IN (SELECT %2 FROM element)") + .arg(element_ref, item_key), 0, QStringLiteral("information rows of no symbol")}, + {QStringLiteral("SELECT count(*) FROM link WHERE %1 NOT IN (SELECT %2 FROM element)") + .arg(element_ref, item_key), 0, QStringLiteral("link rows of no symbol")}}; + for (const auto &check : checks) { + const int found = count(check.sql); + if (found != check.expected) { + setError(error, QStringLiteral("the project database does not match its folios: %1 %2, %3 expected") + .arg(found).arg(check.what).arg(check.expected)); + return false; + } + } return true; } diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index 82323695f..84e9db31c 100644 --- a/tests/qttest/tst_qetcontainer.cpp +++ b/tests/qttest/tst_qetcontainer.cpp @@ -323,6 +323,86 @@ private slots: // A crafted project.sqlite whose "element" is a view, not a table, is // refused: nothing in a file's schema is trusted. + // project.sqlite changed by @p sql in @p entries + static void alterDatabase(QList &entries, const char *sql) + { + for (QetZip::Entry &e : entries) { + if (e.name != QLatin1String("project.sqlite")) continue; + sqlite3 *db = nullptr; + sqlite3_open(":memory:", &db); + unsigned char *copy = static_cast(sqlite3_malloc64(sqlite3_uint64(e.data.size()))); + memcpy(copy, e.data.constData(), size_t(e.data.size())); + sqlite3_deserialize(db, "main", copy, e.data.size(), e.data.size(), + SQLITE_DESERIALIZE_FREEONCLOSE | SQLITE_DESERIALIZE_RESIZEABLE); + QCOMPARE(sqlite3_exec(db, sql, nullptr, nullptr, nullptr), SQLITE_OK); + sqlite3_int64 size = 0; + unsigned char *bytes = sqlite3_serialize(db, "main", &size, 0); + e.data = QByteArray(reinterpret_cast(bytes), qsizetype(size)); + sqlite3_free(bytes); + sqlite3_close(db); + } + } + + // A database that does not match its folio files -- a row no item has, + // an item's row on another folio, entries of no symbol -- is refused + // with what is wrong, not read as far as it goes + void mismatchRefused_data() + { + QTest::addColumn("sql"); + QTest::addColumn("said"); + QTest::newRow("symbol row of no symbol") + << QByteArray("INSERT INTO element SELECT 'x', folio_uuid, prefix, freezeLabel, info_at, info_attrs," + " links_at, links_attrs FROM element LIMIT 1") + << QStringLiteral("symbol rows"); + QTest::newRow("wire row of no wire") + << QByteArray("INSERT INTO conductor SELECT 'x', folio_uuid, element1, terminal1, element2, terminal2," + " num, formula, function, tension_protocol, conductor_color, conductor_section, cable," + " bus, freezeLabel FROM conductor LIMIT 1") + << QStringLiteral("wire rows"); + QTest::newRow("symbol on another folio") + << QByteArray("UPDATE element SET folio_uuid = (SELECT uuid FROM folio WHERE pos = 2)" + " WHERE folio_uuid = (SELECT uuid FROM folio WHERE pos = 1)") + << QStringLiteral("the database says another"); + QTest::newRow("information of no symbol") + << QByteArray("UPDATE element_info SET element_uuid = 'x' WHERE rowid = 1") + << QStringLiteral("information rows of no symbol"); + } + void mismatchRefused() + { + QFETCH(QByteArray, sql); + QFETCH(QString, said); + QDomDocument original; + QList entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original); + QVERIFY(!entries.isEmpty()); + { + QDomDocument back; + QString error; + QVERIFY2(QetContainer::join(entries, &back, &error), qPrintable(error)); + } + alterDatabase(entries, sql.constData()); + QDomDocument back; + QString error; + QVERIFY(!QetContainer::join(entries, &back, &error)); + QVERIFY2(error.contains(said), qPrintable(error)); + } + + // A folio file says which symbol each item is (its type), read alone + void folioSaysWhichSymbol() + { + QDomDocument original; + const QList entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original); + QVERIFY(!entries.isEmpty()); + int typed = 0, symbols = 0; + for (const QetZip::Entry &e : entries) { + if (!e.name.startsWith(QStringLiteral("folios/"))) continue; + const QString text = QString::fromUtf8(e.data); + symbols += int(text.count(QStringLiteral("]*type=\"embed://")))); + } + QVERIFY(symbols > 0); + QCOMPARE(typed, symbols); + } + void viewInsteadOfTableRefused() { QDomDocument original;