/*
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 .
*/
/*
QETSql::isSingleReadOnlyStatement() -- the read-only enforcement every
project-database query goes through.
The case that matters most here is the CTE prefix. SQLite has allowed
WITH in front of DELETE/UPDATE/INSERT since 3.8.3, so a check on the
first keyword lets a write through while reading as though it refused
one. That is not hypothetical for QElectroTech: a 's
is stored in the .qet and executed on load, so the text can
arrive from a file rather than from the person at the keyboard.
This test owns its own in-memory database and links nothing of
QElectroTech but sqlreadonly.cpp, so it stays a fast, hermetic check of
the security property itself.
*/
#include "dataBase/sqlreadonly.h"
#include
#include
class TstSqlReadOnly : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void cleanupTestCase();
void acceptsOrdinaryReads();
void acceptsLegitimateCommonTableExpression();
void refusesCtePrefixedWrites_data();
void refusesCtePrefixedWrites();
void refusesBareWrites_data();
void refusesBareWrites();
void refusesTrailingStatement();
void refusesEmptyAndCommentOnly_data();
void refusesEmptyAndCommentOnly();
void refusesWithoutAConnection();
void reportsAReason();
void doesNotExecuteWhatItRefuses();
private:
sqlite3 *m_db = nullptr;
int rowCount();
};
int TstSqlReadOnly::rowCount()
{
sqlite3_stmt *st = nullptr;
sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM element", -1, &st, nullptr);
sqlite3_step(st);
const int n = sqlite3_column_int(st, 0);
sqlite3_finalize(st);
return n;
}
void TstSqlReadOnly::initTestCase()
{
QCOMPARE(sqlite3_open(":memory:", &m_db), SQLITE_OK);
QCOMPARE(sqlite3_exec(m_db,
"CREATE TABLE element (uuid TEXT);"
"INSERT INTO element VALUES ('a'),('b');", nullptr, nullptr, nullptr),
SQLITE_OK);
QCOMPARE(rowCount(), 2);
}
void TstSqlReadOnly::cleanupTestCase()
{
sqlite3_close(m_db);
m_db = nullptr;
}
void TstSqlReadOnly::acceptsOrdinaryReads()
{
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element"));
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT uuid FROM element WHERE uuid = 'a'"));
// A semicolon inside a string literal is not a second statement. The
// textual check this replaced rejected exactly this.
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT ';' AS semicolon"));
// One trailing semicolon is ordinary punctuation, not a second statement.
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element;"));
}
void TstSqlReadOnly::acceptsLegitimateCommonTableExpression()
{
// WITH must keep working -- the fix is not "ban CTEs".
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db,
"WITH x AS (SELECT 1 AS n) SELECT n FROM x"));
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db,
"WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) "
"SELECT n FROM c LIMIT 3"));
}
void TstSqlReadOnly::refusesCtePrefixedWrites_data()
{
QTest::addColumn("query");
QTest::newRow("delete") << "WITH x AS (SELECT 1) DELETE FROM element";
QTest::newRow("update") << "WITH x AS (SELECT 1) UPDATE element SET uuid = 'pwned'";
QTest::newRow("insert") << "WITH x AS (SELECT 1) INSERT INTO element VALUES ('injected')";
}
void TstSqlReadOnly::refusesCtePrefixedWrites()
{
QFETCH(QString, query);
QVERIFY2(!QETSql::isSingleReadOnlyStatement(m_db, query),
qPrintable(QStringLiteral("accepted a write: %1").arg(query)));
}
void TstSqlReadOnly::refusesBareWrites_data()
{
QTest::addColumn("query");
QTest::newRow("delete") << "DELETE FROM element";
QTest::newRow("update") << "UPDATE element SET uuid = 'pwned'";
QTest::newRow("insert") << "INSERT INTO element VALUES ('injected')";
QTest::newRow("drop") << "DROP TABLE element";
}
void TstSqlReadOnly::refusesBareWrites()
{
QFETCH(QString, query);
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, query));
}
void TstSqlReadOnly::refusesTrailingStatement()
{
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "SELECT 1; DROP TABLE element"));
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "SELECT 1; SELECT 2"));
}
void TstSqlReadOnly::refusesEmptyAndCommentOnly_data()
{
QTest::addColumn("query");
QTest::newRow("empty") << "";
QTest::newRow("whitespace") << " ";
QTest::newRow("comment") << "-- nothing to see here";
}
void TstSqlReadOnly::refusesEmptyAndCommentOnly()
{
// sqlite3_prepare_v2() reports success and a null statement for these;
// sqlite3_stmt_readonly() must never be handed that.
QFETCH(QString, query);
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, query));
}
void TstSqlReadOnly::refusesWithoutAConnection()
{
// Fails closed: with no connection there is nothing to ask, and
// guessing from the text is the weakness this replaced.
QVERIFY(!QETSql::isSingleReadOnlyStatement(nullptr, "SELECT * FROM element"));
}
void TstSqlReadOnly::reportsAReason()
{
QString reason;
QVERIFY(!QETSql::isSingleReadOnlyStatement(
m_db, "WITH x AS (SELECT 1) DELETE FROM element", &reason));
QVERIFY2(!reason.isEmpty(), "a refusal must say why");
reason = QStringLiteral("stale");
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element", &reason));
QVERIFY2(reason.isEmpty(), "an accepted query must not leave a reason behind");
}
void TstSqlReadOnly::doesNotExecuteWhatItRefuses()
{
// The check compiles the statement to inspect it. Proving the table is
// untouched afterwards is what says it compiled without running it --
// and this same assertion goes red if the refusals above ever stop
// refusing, since then the caller would run the DELETE for real.
QCOMPARE(rowCount(), 2);
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "WITH x AS (SELECT 1) DELETE FROM element"));
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "DELETE FROM element"));
QCOMPARE(rowCount(), 2);
}
QTEST_APPLESS_MAIN(TstSqlReadOnly)
#include "tst_sqlreadonly.moc"