From a7c4b957011f68f775daffe35be6e92e57b2fbf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 21:47:13 +0000 Subject: [PATCH] fix: keep generated IDs free of the store path delimiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateId drew from an alphabet that included '_', so a random ID could contain '__' — the delimiter the store uses to split paths into segments. Because generated IDs are used as path segments (observer value keys, auto-generated document IDs), a '__' in an ID split it into extra segments and misplaced the value deeper in the store than reads and invalidations expect: - An observable's cached value was stored below the path the broadcast manager invalidates (clearAll/writes call delete(path, recursive: false)), so it was never invalidated and the observer emitted a stale value. This is the intermittent "broadcast test flake": it fired whenever a random observer ID happened to contain '__' (~7.5% of core suite runs at --concurrency=1, more under parallel load). Forcing an extra segment into the observer ID turned it into 17 deterministic failures. - An auto-id document whose ID contained '__' was written below its collection node and was invisible to the collection's query. Restrict generated IDs to alphanumeric [0-9A-Za-z] via uniform rejection sampling, so an ID can never contain the delimiter. Adds a regression test asserting generateId never produces '__' (100k samples). Verified: core suite 45/45 green across parallel and --concurrency=1 stress runs that previously flaked ~7.5-20%. --- lib/utils/id.dart | 21 ++++++++++++++++----- test/core/id_test.dart | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 test/core/id_test.dart diff --git a/lib/utils/id.dart b/lib/utils/id.dart index 16eb2ad..c0b36ff 100644 --- a/lib/utils/id.dart +++ b/lib/utils/id.dart @@ -1,9 +1,16 @@ import 'dart:math'; import 'dart:typed_data'; -// 64-characters (2^6) +// Alphanumeric only (62 characters). Generated IDs are used as `__`-delimited +// path segments throughout the store (document paths, observer value keys), so +// they must never contain that delimiter. The previous alphabet included `_`, +// which meant a random ID could contain `__` and be parsed as a segment +// boundary — misplacing the value deeper in the store than reads/invalidations +// expect (e.g. an observer value never getting invalidated, or an auto-id +// document being invisible to its collection). Restricting to [0-9A-Za-z] +// removes the delimiter character entirely, so no ID can introduce a boundary. const String _alphabet = - '_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; final Uint8List _alphabytes = Uint8List.fromList(_alphabet.codeUnits); const int _u32 = 0x100000000; // 2^32 @@ -20,10 +27,14 @@ String _generateRandomId(Random random, int size) { var k = 0; while (k < 5 && i < size) { - // Sample a character randomly from the 64 characters. - out[i++] = _alphabytes[r & 63]; + final index = r & 63; r >>= 6; k++; + // The 6-bit sample spans 0-63; reject the values past the 62-char + // alphabet so every character is sampled uniformly. + if (index < _alphabytes.length) { + out[i++] = _alphabytes[index]; + } } } @@ -34,7 +45,7 @@ String _generateRandomId(Random random, int size) { /// be user-visible, persisted, synced, or treated as unguessable by callers. /// /// Use this for document IDs and other public identifiers. -/// Default: 21 chars, about 126 bits of entropy. +/// Default: 21 chars, about 125 bits of entropy. String generateSecureId([int size = 21]) => _generateRandomId(_secureRandom, size); diff --git a/test/core/id_test.dart b/test/core/id_test.dart new file mode 100644 index 0000000..94475b5 --- /dev/null +++ b/test/core/id_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:loon/utils/id.dart'; + +void main() { + // Generated IDs are used as `__`-delimited path segments in the store, so an + // ID containing the delimiter would be parsed as multiple segments and + // misplace data. Guard that neither generator can produce one. + for (final entry in { + 'generateSecureId': generateSecureId, + 'generateFastId': generateFastId, + }.entries) { + final name = entry.key; + final gen = entry.value; + + group(name, () { + test('never contains the path delimiter', () { + for (var i = 0; i < 100000; i++) { + final id = gen(); + expect(id.contains('__'), false, reason: 'id "$id" contains "__"'); + } + }); + + test('produces alphanumeric IDs of the requested length', () { + final alphanumeric = RegExp(r'^[0-9A-Za-z]+$'); + for (var i = 0; i < 1000; i++) { + final id = gen(); + expect(id.length, 21); + expect(alphanumeric.hasMatch(id), true, reason: 'id "$id"'); + } + expect(gen(8).length, 8); + }); + }); + } +}