diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index 4f2b1b8dd..c5ed9da44 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -194,7 +194,8 @@ class _DittoExampleState extends State { // Use the Soft-Delete pattern // https://docs.ditto.live/sdk/latest/crud/delete#soft-delete-pattern await _ditto!.store.execute( - "UPDATE tasks SET deleted = true WHERE _id = '${task.id}'", + "UPDATE tasks SET deleted = true WHERE _id = :id", + arguments: {"id": task.id}, ); if (mounted) { @@ -209,7 +210,8 @@ class _DittoExampleState extends State { title: Text(task.title), value: task.done, onChanged: (value) => _ditto!.store.execute( - "UPDATE tasks SET done = $value WHERE _id = '${task.id}'", + "UPDATE tasks SET done = :done WHERE _id = :id", + arguments: {"id": task.id, "done": value}, ), secondary: IconButton( icon: const Icon(Icons.edit), @@ -220,7 +222,8 @@ class _DittoExampleState extends State { // https://docs.ditto.live/sdk/latest/crud/update _ditto!.store.execute( - "UPDATE tasks SET title = '${newTask.title}' where _id = '${task.id}'", + "UPDATE tasks SET title = :title WHERE _id = :id", + arguments: {"id": task.id, "title": newTask.title}, ); }, ), diff --git a/flutter_app/test/dql_safety_test.dart b/flutter_app/test/dql_safety_test.dart new file mode 100644 index 000000000..3be9fc9f6 --- /dev/null +++ b/flutter_app/test/dql_safety_test.dart @@ -0,0 +1,71 @@ +// Regression test for the parameterized-DQL fix. +// +// Earlier versions of this file built UPDATE statements by interpolating +// user-typed task titles into DQL strings, e.g.: +// +// "UPDATE tasks SET title = '${newTask.title}' where _id = '${task.id}'" +// +// A task titled `'); EVICT FROM tasks WHERE true; --` could break out of +// the quoted value and run arbitrary DQL. The fix switched to named +// parameter binding (`:title`, `:done`, `:id`) with values passed via the +// `arguments:` map. +// +// This test pins the fix as a static-source check: any future change that +// reintroduces Dart string interpolation into the DQL strings in +// `lib/main.dart` fails here loudly. It does not exercise the DQL engine — +// a real round-trip test would need a live `Ditto` instance, which lives in +// `integration_test/`. The check is intentionally coarse and operates on +// the file as text. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('main.dart DQL safety', () { + late String source; + + setUpAll(() { + source = File('lib/main.dart').readAsStringSync(); + }); + + test('uses named parameter binding for every mutable field', () { + expect(source, contains(':task'), reason: 'INSERT binding missing'); + expect(source, contains(':title'), reason: 'updateTitle binding missing'); + expect(source, contains(':done'), reason: 'setDone binding missing'); + expect(source, contains(':id'), + reason: 'WHERE _id = :id binding missing'); + }); + + test('no Dart string interpolation inside DQL execute() calls', () { + // Find every `_ditto!.store.execute(` (or `.execute(`) call site and + // inspect the string literal that follows up to the next `,` or `)`. + // Forbid `${` and `$identifier` inside those literals. + final calls = RegExp(r'\.execute\(\s*"([^"]*)"').allMatches(source); + expect( + calls.isNotEmpty, + isTrue, + reason: 'No execute() calls found — has main.dart moved?', + ); + + final shortInterp = RegExp(r'\$[a-zA-Z_{][a-zA-Z0-9_]*'); + for (final m in calls) { + final query = m.group(1)!; + expect( + query, + isNot(contains(r'${')), + reason: 'Found `\${` inside DQL: $query — use named binding.', + ); + final hit = shortInterp.firstMatch(query); + expect( + hit, + isNull, + reason: hit == null + ? '' + : 'Found `${hit.group(0)}` (Dart interpolation) inside DQL: ' + '$query — replace with `:name` and pass via `arguments:`.', + ); + } + }); + }); +}