Stop a query in a project file from hanging QElectroTech for ever

A <graphics_table>'s <query> is stored in the .qet and executed when the
project loads. SQLite produces rows lazily, so the cost of that query is
not bounded by anything the project contains -- it is bounded by how long
the loop reading the rows is willing to run. A recursive CTE takes one line
to make that forever:

  WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) SELECT n ...

Put that in the <query> of any project's summary table and opening the file
pins a core at 100% and grows ProjectDBModel::m_record until memory runs
out. Measured on examples/industrial.qet with the query swapped, built from
master:

  clean     --export-bom   3.6 s, 396 rows, exit 0
  poisoned  --export-bom   killed at 90 s, still going, no output

No scripting, no MCP, no flag beyond an ordinary export. Opening the file in
the editor is the same code path.

QetScriptApi::query() has the identical loop, and the script engine's own
30 s interrupt does not reach it: that aborts JavaScript, and this is C++
inside a single call. Left alone it hung a --run for 45 s until the harness
killed it.

Both loops now stop at projectDataBase::MaxResultRows (100000) and say so.
That is a backstop, not a page size: the largest table in the shipped
examples is 396 rows, and a caller that reaches 100000 has been handed
something it should not run to completion. It is not silent either way --
the model logs the offending query text, and qet.query() sets queryError(),
so a truncated result is never mistaken for a complete one.

  clean     --export-bom   3.6 s, 396 rows, exit 0   (unchanged)
  poisoned  --export-bom   20.2 s, 396 rows, exit 0, warning names the query
  qet.query(recursive CTE) 3.8 s, 100000 rows, queryError() set

Reverting each cap restores the hang, so both checks discriminate.

Related to #983, which fixes a different flaw reachable through the same
stored query. Neither depends on the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-23 04:16:21 +12:00
parent 1212f48c6d
commit 3fa5e0a475
3 changed files with 54 additions and 0 deletions
+20
View File
@@ -61,6 +61,26 @@ class projectDataBase : public QObject
QETProject *project() const;
QSqlQuery newQuery(const QString &query = QString(), QString *error = nullptr);
static bool isReadOnlySelect(const QString &query, QString *error = nullptr);
/**
The most rows any caller reads out of one query result.
A SELECT is not bounded by how much data the project holds:
SQLite produces rows lazily, so a query that never stops
producing them makes the loop that reads them never stop
either. A recursive CTE does exactly that in one line, and
a <graphics_table>'s <query> is stored in the .qet and run
on load -- so the text can arrive from a file rather than
from the person at the keyboard, and opening that file is
the whole attack.
100000 is far above any real result: the largest table in
the shipped examples is 396 rows. It is a backstop, not a
page size -- a caller that hits it has almost certainly
been handed something it should not run to completion, and
says so rather than truncating quietly.
*/
static constexpr int MaxResultRows = 100000;
QSqlDatabase database() const {return m_data_base;}
int excludedConductorCount() const;