Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 29 additions & 20 deletions lib/core/database/sqlite_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -324,28 +324,37 @@ class SqliteConnection {
if (trimmed.isEmpty) return const [];

// 1. Try temporary view probe in SQLite's in-memory temp schema.
const viewName = '_querya_zero_row_col_probe';
try {
var cleanSql = trimmed;
while (cleanSql.endsWith(';')) {
cleanSql = cleanSql.substring(0, cleanSql.length - 1).trim();
}
// Note: We execute on _db! directly to bypass readOnly restriction,
// as temp views only touch in-memory session temp schema.
await _db!.execute('CREATE TEMP VIEW IF NOT EXISTS $viewName AS $cleanSql');
final rows = await _db!.rawQuery('PRAGMA table_info($viewName)');
final cols = rows
.map((r) => r['name'] as String? ?? '')
.where((n) => n.isNotEmpty)
.toList();
await _db!.execute('DROP VIEW IF EXISTS $viewName');
if (cols.isNotEmpty) {
return cols;
}
} catch (_) {
//
// `Database.execute` (unlike `rawQuery`) runs every statement it's given,
// not just the first — so if `trimmed` smuggled in a second statement
// after a `;`, wrapping it in `CREATE TEMP VIEW ... AS $cleanSql` would
// execute that second statement for real (#1005). Only ever probe when
// `trimmed` is a single statement.
if (!sqliteHasMultipleStatements(trimmed)) {
const viewName = '_querya_zero_row_col_probe';
try {
var cleanSql = trimmed;
while (cleanSql.endsWith(';')) {
cleanSql = cleanSql.substring(0, cleanSql.length - 1).trim();
}
// Note: We execute on _db! directly to bypass readOnly restriction,
// as temp views only touch in-memory session temp schema.
await _db!
.execute('CREATE TEMP VIEW IF NOT EXISTS $viewName AS $cleanSql');
final rows = await _db!.rawQuery('PRAGMA table_info($viewName)');
final cols = rows
.map((r) => r['name'] as String? ?? '')
.where((n) => n.isNotEmpty)
.toList();
await _db!.execute('DROP VIEW IF EXISTS $viewName');
} catch (_) {}
if (cols.isNotEmpty) {
return cols;
}
} catch (_) {
try {
await _db!.execute('DROP VIEW IF EXISTS $viewName');
} catch (_) {}
}
}

// 2. Fallback: single-table target extraction
Expand Down
48 changes: 48 additions & 0 deletions lib/core/database/sqlite_sql.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,54 @@ String sqliteStripSqlComments(String sql) {
.replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '');
}

/// Whether [sql] contains more than one statement (a `;` outside any string /
/// quoted-identifier literal, followed by further non-whitespace text).
///
/// A single trailing `;` (with or without trailing whitespace) does not
/// count. Used to refuse operations that must not be handed more than one
/// statement at a time, such as wrapping arbitrary user SQL in a probe
/// `CREATE TEMP VIEW ... AS <sql>` — unlike `rawQuery`, `Database.execute`
/// runs every statement it's given, so a second, unintended statement in the
/// probed text would otherwise execute for real (#1005).
bool sqliteHasMultipleStatements(String sql) {
final s = sqliteStripSqlComments(sql);
var i = 0;
while (i < s.length) {
final c = s[i];
if (c == "'" || c == '"' || c == '`') {
i++;
while (i < s.length) {
if (s[i] == c) {
i++;
if (i < s.length && s[i] == c) {
i++;
continue;
}
break;
}
i++;
}
continue;
}
if (c == '[') {
i++;
while (i < s.length && s[i] != ']') {
i++;
}
if (i < s.length) i++;
continue;
}
if (c == ';') {
final rest = s.substring(i + 1);
if (!RegExp(r'^[;\s]*$').hasMatch(rest)) return true;
i++;
continue;
}
i++;
}
return false;
}

/// Whether [sql] should use `rawQuery` and is allowed on a read-only connection.
///
/// `WITH` is read-only only when the statement after the CTEs is `SELECT` /
Expand Down
30 changes: 30 additions & 0 deletions test/core/database/sqlite_connection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,36 @@ void main() {
.inferQueryColumns('SELECT 1 AS flag, COUNT(*) AS cnt WHERE 1=0;');
expect(colsComputed, ['flag', 'cnt']);
});

test(
'inferQueryColumns does not execute a second smuggled-in statement (#1005)',
() async {
final conn = SqliteConnection(
id: 99,
name: 'mem',
path: ':memory:',
);
addTearDown(conn.disconnect);
await conn.connect();
await conn.execute('CREATE TABLE t (x INTEGER);');
await conn.execute('INSERT INTO t VALUES (1), (2);');

// A single rawQuery only ever executes the first statement, so this is
// exactly the shape that reaches inferQueryColumns: a zero-row result
// from the first statement, with a write smuggled in after the `;`.
await conn.execute('SELECT * FROM t WHERE 0; DELETE FROM t;');

final rowsBefore =
await conn.execute('SELECT count(*) AS c FROM t');
expect(rowsBefore.first['c'], 2,
reason: 'the DELETE above ran as its own statement via execute()');

await conn.inferQueryColumns('SELECT * FROM t WHERE 0; DELETE FROM t;');

final rowsAfter = await conn.execute('SELECT count(*) AS c FROM t');
expect(rowsAfter.first['c'], 2,
reason: 'inferQueryColumns must not execute the DELETE either');
});
});

group('SQLite sessions on the same file are independent', () {
Expand Down
53 changes: 53 additions & 0 deletions test/core/database/sqlite_sql_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,57 @@ void main() {
expect(sqliteSqlIsReadOnlyQuery('CREATE TABLE t (id INT)'), isFalse);
});
});

group('sqliteHasMultipleStatements', () {
test('single statement, with or without a trailing semicolon', () {
expect(sqliteHasMultipleStatements('SELECT 1'), isFalse);
expect(sqliteHasMultipleStatements('SELECT 1;'), isFalse);
expect(sqliteHasMultipleStatements('SELECT 1; '), isFalse);
expect(sqliteHasMultipleStatements('SELECT 1;;'), isFalse);
});

test('a second statement after a top-level semicolon is detected', () {
expect(
sqliteHasMultipleStatements('SELECT 1 WHERE 0; DELETE FROM t'),
isTrue,
);
expect(
sqliteHasMultipleStatements('SELECT 1; SELECT 2;'),
isTrue,
);
});

test('a semicolon inside a string / quoted identifier does not count',
() {
expect(
sqliteHasMultipleStatements("SELECT 'a;b'"),
isFalse,
);
expect(
sqliteHasMultipleStatements('SELECT "a;b" FROM t'),
isFalse,
);
expect(
sqliteHasMultipleStatements('SELECT * FROM [a;b]'),
isFalse,
);
// A doubled quote (escaped) inside the literal, followed by a real
// top-level `;` and a second statement, is still detected.
expect(
sqliteHasMultipleStatements("SELECT 'it''s; fine'; DELETE FROM t"),
isTrue,
);
});

test('a semicolon inside a comment does not count', () {
expect(
sqliteHasMultipleStatements('SELECT 1 -- ; not real\n'),
isFalse,
);
expect(
sqliteHasMultipleStatements('SELECT 1 /* ; not real */'),
isFalse,
);
});
});
}
Loading