Fix #1045: crash on selecting an element with the online-installer Qt

Since #983, projectDataBase::newQuery() checked a query with
sqlite3_prepare_v2() and sqlite3_stmt_readonly() on the handle of the
QSQLITE driver. Those calls go to the libsqlite3 QElectroTech links. The
QSQLITE plugin of the Qt online installer does not use that library: it
carries its own copy of SQLite, so the handle belongs to another library
and the call crashes. #1021 then put newQuery() on every element
selection, which is where #1045 hits it.

The check now runs the query with PRAGMA query_only set, through the
driver. SQLite refuses a write itself, before touching a row, so the CTE
prefix #983 closed ("WITH x AS (SELECT 1) DELETE FROM element") stays
closed. A refused or failed query comes back empty, because several
callers call exec() again on what newQuery() returns, after query_only
is off.

QElectroTech no longer calls the SQLite C API anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-26 19:20:47 +12:00
parent 923367d2a8
commit 45aec7735b
6 changed files with 197 additions and 184 deletions
+16 -35
View File
@@ -34,7 +34,6 @@
#include <QRegularExpression>
#include <QSqlDriver>
#include <QSqlError>
#include <sqlite3.h>
@@ -205,10 +204,9 @@ bool projectDataBase::isReadOnlySelect(const QString &query, QString *error)
@param query the SQL text to run -- must be a single read-only
SELECT/WITH statement, see isReadOnlySelect()
@param error set to a human-readable reason when the query was rejected
before ever reaching the database
@return a QSqlQuery with query as query and the internal database of
this class as database to use, or an unexecuted, harmless QSqlQuery if
the query was rejected
or failed
@return the executed query, on the internal database of this class, or
an empty, harmless QSqlQuery if the query was rejected or failed
*/
QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
QString reason;
@@ -226,24 +224,24 @@ QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
return QSqlQuery(m_data_base);
}
// Second gate, and the one that actually enforces read-only: SQLite is
// asked about the statement it compiled, instead of the text being read
// for clues. The first gate cannot see through a CTE prefix --
// "WITH x AS (SELECT 1) DELETE FROM element" starts with WITH, contains
// no semicolon, and deletes every row. That matters beyond the
// custom-query box, because this path is reachable from a file: a
// <graphics_table>'s saved <query> is read straight out of the .qet by
// ProjectDBModel::fromXml() and executed by fillValue(), so opening or
// exporting a project someone else produced would have been enough.
if (!QETSql::isSingleReadOnlyStatement(sqliteHandle(&m_data_base), query, &reason)) {
// Second gate, and the one that actually enforces read-only: SQLite
// runs the statement with query_only set and refuses a write itself,
// instead of the text being read for clues. The first gate cannot see
// through a CTE prefix -- "WITH x AS (SELECT 1) DELETE FROM element"
// starts with WITH, contains no semicolon, and deletes every row. That
// matters beyond the custom-query box, because this path is reachable
// from a file: a <graphics_table>'s saved <query> is read straight out
// of the .qet by ProjectDBModel::fromXml() and executed by fillValue(),
// so opening or exporting a project someone else produced would have
// been enough.
QSqlQuery result = QETSql::execReadOnly(m_data_base, query, &reason);
if (!reason.isEmpty()) {
qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query;
if (error) {
*error = reason;
}
return QSqlQuery(m_data_base);
}
return QSqlQuery(query, m_data_base);
return result;
}
/**
@@ -1301,23 +1299,6 @@ void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram)
}
}
/**
@brief projectDataBase::sqliteHandle
@param db
@return the sqlite3 handler class used internally by db
*/
sqlite3 *projectDataBase::sqliteHandle(QSqlDatabase *db)
{
sqlite3 *handle = nullptr;
QVariant v = db->driver()->handle();
if (v.isValid() && qstrcmp(v.typeName(), "sqlite3*") == 0) {
handle = *static_cast<sqlite3 **>(v.data());
}
return handle;
}
#ifdef QET_EXPORT_PROJECT_DB
/**
-7
View File
@@ -29,7 +29,6 @@ class QETProject;
class Diagram;
class Conductor;
class Terminal;
struct sqlite3;
/**
@brief The projectDataBase class
@@ -156,12 +155,6 @@ class projectDataBase : public QObject
m_cascade_remove_conductor_query,
m_cascade_remove_element_query;
public:
// Deliberately outside the QET_EXPORT_PROJECT_DB guard below:
// newQuery() needs the raw connection to ask SQLite whether a
// query only reads, and that check runs in every build.
static sqlite3 *sqliteHandle(QSqlDatabase *db);
#ifdef QET_EXPORT_PROJECT_DB
public:
static void exportDb(projectDataBase *db,
+48 -72
View File
@@ -18,15 +18,14 @@
#include "sqlreadonly.h"
#include <QCoreApplication>
#include <sqlite3.h>
#include <QSqlError>
namespace QETSql {
/**
@brief QETSql::isSingleReadOnlyStatement
Ask SQLite itself whether @p query is exactly one statement, and whether
that statement only reads.
@brief QETSql::execReadOnly
Run @p query on @p db with SQLite's query_only pragma set, so that
SQLite itself refuses anything that would write.
Why SQLite is asked rather than the text inspected: a check on the
query's first keyword cannot see what the statement actually does.
@@ -37,98 +36,75 @@ namespace QETSql {
WITH x AS (SELECT 1) DELETE FROM element
@endcode
begins with WITH, contains no semicolon, and deletes every row.
sqlite3_stmt_readonly() reports on the statement SQLite compiled, not
on how it was spelled, so the same query is correctly refused here
while an ordinary WITH ... SELECT still passes.
begins with WITH, contains no semicolon, and deletes every row. With
query_only set, SQLite fails that statement with SQLITE_READONLY when it
tries to start writing, before any row is touched, while an ordinary
WITH ... SELECT still runs.
The statement is compiled and immediately finalised; sqlite3_prepare_v2()
does not run it, so nothing is executed to reach this verdict.
Why a pragma rather than sqlite3_stmt_readonly(): that needs the
driver's native sqlite3 handle passed to the libsqlite3 QElectroTech
links. The QSQLITE plugin of the Qt online installer carries its own
private copy of SQLite, so that handle belongs to another library and
the call crashes (qelectrotech-source-mirror#1045). A pragma goes
through the driver, whichever SQLite it uses.
This is a read-only test, NOT a statement-type allowlist: SQLite
considers ATTACH, BEGIN and several PRAGMAs read-only too, because none
of them change the contents of the database. Callers that need to
restrict which *kind* of statement is acceptable must say so separately
-- projectDataBase::newQuery() keeps isReadOnlySelect() in front of this
A statement that succeeded here is read-only, so running the returned
query again, as several callers do, runs a read-only statement again.
A refused one comes back as an empty query with nothing to run again:
query_only is only set for the duration of this call.
Qt's SQLite driver refuses a second statement after the first one, so
"SELECT 1; DROP TABLE element" is refused too.
This is a read-only test, NOT a statement-type allowlist: query_only
does not refuse ATTACH, BEGIN or most PRAGMAs, because none of them
change the contents of the database. Callers that need to restrict
which *kind* of statement is acceptable must say so separately --
projectDataBase::newQuery() keeps isReadOnlySelect() in front of this
for exactly that reason.
@param handle the connection the query would run on. A null handle is
refused rather than waved through: without it there is nothing to ask,
and guessing from the text is the weakness this exists to replace.
@param db the connection to run the query on
@param query the raw SQL text
@param error set to a human-readable reason when this returns false
@return true if @p query is a single, read-only statement
@param error set to a human-readable reason when the query is refused
@return the executed query, or an empty query on @p db if @p query was
refused or failed
*/
bool isSingleReadOnlyStatement(sqlite3 *handle, const QString &query, QString *error)
QSqlQuery execReadOnly(const QSqlDatabase &db, const QString &query, QString *error)
{
if (error) {
error->clear();
}
if (!handle) {
if (!QSqlQuery(db).exec(QStringLiteral("PRAGMA query_only = ON"))) {
if (error) {
*error = QCoreApplication::translate("QETSql",
"Impossible de vérifier la requête : "
"aucune connexion SQLite disponible.");
"la base de données ne peut pas être mise en lecture seule.");
}
return false;
return QSqlQuery(db);
}
const QByteArray utf8 = query.toUtf8();
sqlite3_stmt *statement = nullptr;
const char *tail = nullptr;
QSqlQuery result(db);
const bool ok = result.exec(query);
QSqlQuery(db).exec(QStringLiteral("PRAGMA query_only = OFF"));
if (sqlite3_prepare_v2(handle, utf8.constData(), utf8.size(),
&statement, &tail) != SQLITE_OK)
{
if (error) {
*error = QCoreApplication::translate("QETSql",
"Requête SQL invalide : %1")
.arg(QString::fromUtf8(sqlite3_errmsg(handle)));
}
sqlite3_finalize(statement);
return false;
if (ok) {
return result;
}
// Whitespace or a bare comment compiles successfully to no statement
// at all, and sqlite3_stmt_readonly() must not be handed that.
if (!statement) {
if (error) {
*error = QCoreApplication::translate("QETSql",
"La requête ne contient aucune instruction.");
}
return false;
}
const bool read_only = sqlite3_stmt_readonly(statement) != 0;
sqlite3_finalize(statement);
if (!read_only) {
if (error) {
if (error) {
// SQLITE_READONLY is 8; extended codes keep it in the low byte.
if ((result.lastError().nativeErrorCode().toInt() & 0xff) == 8) {
*error = QCoreApplication::translate("QETSql",
"Seules les requêtes en lecture seule sont autorisées : "
"cette requête modifierait la base de données.");
}
return false;
}
// tail points just past the first statement, semicolon included.
// Anything left once semicolons and spacing are stripped is a second
// statement -- caught structurally here, where "SELECT ';'" is a
// perfectly ordinary query rather than a suspicious string.
if (tail) {
QString rest = QString::fromUtf8(tail);
rest.remove(QLatin1Char(';'));
if (!rest.trimmed().isEmpty()) {
if (error) {
*error = QCoreApplication::translate("QETSql",
"Une seule requête est autorisée.");
}
return false;
} else {
*error = QCoreApplication::translate("QETSql",
"Requête SQL invalide : %1")
.arg(result.lastError().databaseText());
}
}
return true;
return QSqlQuery(db);
}
} // namespace QETSql
+11 -11
View File
@@ -18,26 +18,26 @@
#ifndef SQLREADONLY_H
#define SQLREADONLY_H
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QString>
struct sqlite3;
/**
Deciding whether a piece of SQL only reads.
Running a piece of SQL only if it reads.
Deliberately its own translation unit, depending on nothing but QString
and SQLite: it is the enforcement point for every query QElectroTech
runs against a project database, including queries that arrive from
outside the application (a .qet file's saved report/table query), so it
is worth being able to test it in isolation -- see
Deliberately its own translation unit, depending on nothing but Qt SQL:
it is the enforcement point for every query QElectroTech runs against a
project database, including queries that arrive from outside the
application (a .qet file's saved report/table query), so it is worth
being able to test it in isolation -- see
tests/qttest/tst_sqlreadonly.cpp, which links this file and nothing
else of QElectroTech.
*/
namespace QETSql {
bool isSingleReadOnlyStatement(sqlite3 *handle,
const QString &query,
QString *error = nullptr);
QSqlQuery execReadOnly(const QSqlDatabase &db,
const QString &query,
QString *error = nullptr);
}
#endif // SQLREADONLY_H