From 027e67b1973ef2efb91bbe93be7ecc7879971820 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 8 Nov 2026 13:48:02 +0100 Subject: [PATCH] crdt_sync: SYNC_NAMESPACE sandbox define Compile-time dart-define that prefixes every RTDB path with sandbox/; invalid values throw at startup. Empty keeps prod paths unchanged. Co-Authored-By: Claude Opus 5.5 --- crdt_sync_dart/README.md | 10 ++ crdt_sync_dart/lib/crdt_sync.dart | 1 + crdt_sync_dart/lib/src/firebase_client.dart | 16 +- crdt_sync_dart/lib/src/firebase_config.dart | 3 + crdt_sync_dart/lib/src/sync_namespace.dart | 26 +++ crdt_sync_dart/test/sync_namespace_test.dart | 170 +++++++++++++++++++ crdt_sync_dart/tool/namespace_probe.dart | 31 ++++ 7 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 crdt_sync_dart/lib/src/sync_namespace.dart create mode 100644 crdt_sync_dart/test/sync_namespace_test.dart create mode 100644 crdt_sync_dart/tool/namespace_probe.dart diff --git a/crdt_sync_dart/README.md b/crdt_sync_dart/README.md index ddf9358..c356fe3 100644 --- a/crdt_sync_dart/README.md +++ b/crdt_sync_dart/README.md @@ -118,6 +118,16 @@ The public/private split is not cosmetic: every repo in this fleet is public. APK — the security rules, not secrecy, protect the data. The account email and password identify *a person* and are entered once per device. +### Sandbox namespace for test builds + +`--dart-define=SYNC_NAMESPACE=sandbox/slot3` moves every RTDB read and write +under `/sandbox/slot3/...`, so a test build never touches real data. Sign-in +is unchanged (the real account). Empty, the default, keeps the exact current +paths. Only `^sandbox/[a-z0-9_-]+$` is accepted; anything else throws +`ConfigException` at startup (`firebaseClientFor` / `FirebaseRestClient`), never +falling back to production paths. The shipped rules (root-level uid pin) already +allow these paths. + ### `password: ''` is legal The Google one-tap path and the seeded-session path both store an empty diff --git a/crdt_sync_dart/lib/crdt_sync.dart b/crdt_sync_dart/lib/crdt_sync.dart index 93ff771..24515be 100644 --- a/crdt_sync_dart/lib/crdt_sync.dart +++ b/crdt_sync_dart/lib/crdt_sync.dart @@ -20,4 +20,5 @@ export 'src/remote_store.dart'; export 'src/secure_credential_store.dart'; export 'src/store.dart'; export 'src/sync.dart'; +export 'src/sync_namespace.dart'; export 'src/sync_state.dart'; diff --git a/crdt_sync_dart/lib/src/firebase_client.dart b/crdt_sync_dart/lib/src/firebase_client.dart index a9be3a6..58c6ed0 100644 --- a/crdt_sync_dart/lib/src/firebase_client.dart +++ b/crdt_sync_dart/lib/src/firebase_client.dart @@ -19,6 +19,7 @@ import 'firebase_auth_rest.dart'; import 'firebase_errors.dart'; import 'firebase_keys.dart'; import 'remote_store.dart'; +import 'sync_namespace.dart'; const _httpUnauthorized = 401; const _httpForbidden = 403; @@ -33,13 +34,22 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader { required String databaseUrl, required this.auth, http.Client? httpClient, + String namespace = syncNamespace, }) : _databaseUrl = databaseUrl.endsWith('/') ? databaseUrl.substring(0, databaseUrl.length - 1) : databaseUrl, + _prefix = _prefixFor(checkSyncNamespace(namespace)), _http = httpClient ?? http.Client(); final String _databaseUrl; + /// `/` or empty: put in front of every RTDB path. Every request + /// goes through [_uri], so this is the single choke point. + final String _prefix; + + static String _prefixFor(String namespace) => + namespace.isEmpty ? '' : '$namespace/'; + /// Mints the ID token every request carries. Public so a settings screen /// can drive `signIn` / `signOut` through the same instance the client uses. final FirebaseTokenProvider auth; @@ -47,8 +57,12 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader { Future _uri(String path, {Map query = const {}}) async { final encoded = encodePath(path); + // A bare namespace (the root probe) must not leave a trailing slash. + final full = encoded.isEmpty && _prefix.isNotEmpty + ? _prefix.substring(0, _prefix.length - 1) + : '$_prefix$encoded'; return Uri.parse( - '$_databaseUrl/$encoded.json', + '$_databaseUrl/$full.json', ).replace(queryParameters: {'auth': await auth.idToken(), ...query}); } diff --git a/crdt_sync_dart/lib/src/firebase_config.dart b/crdt_sync_dart/lib/src/firebase_config.dart index 6157523..61c445d 100644 --- a/crdt_sync_dart/lib/src/firebase_config.dart +++ b/crdt_sync_dart/lib/src/firebase_config.dart @@ -20,6 +20,7 @@ import 'firebase_credentials.dart'; import 'firebase_client.dart'; import 'mirror_store.dart'; import 'remote_store.dart'; +import 'sync_namespace.dart'; /// The shared config is missing a field, or still holds a scaffold placeholder. class ConfigException implements Exception { @@ -121,6 +122,8 @@ Future firebaseClientFor({ String? expectedUid, http.Client? httpClient, }) async { + // Before any sign-in: a bad SYNC_NAMESPACE must stop the app at startup. + checkSyncNamespace(syncNamespace); final auth = FirebaseTokenProvider( apiKey: config.apiKey, store: store, diff --git a/crdt_sync_dart/lib/src/sync_namespace.dart b/crdt_sync_dart/lib/src/sync_namespace.dart new file mode 100644 index 0000000..b79bdd9 --- /dev/null +++ b/crdt_sync_dart/lib/src/sync_namespace.dart @@ -0,0 +1,26 @@ +/// Compile-time sandbox namespace for the Realtime Database. +/// +/// Build a test app with `--dart-define=SYNC_NAMESPACE=sandbox/slot3` and every +/// RTDB path the library reads or writes moves under `/sandbox/slot3/`, so a +/// test build can never touch real data. Sign-in is unchanged (real account). +/// Empty, the default, leaves every path exactly as it was. +library; + +import 'firebase_config.dart'; + +/// The `SYNC_NAMESPACE` dart-define; empty means production paths. +const String syncNamespace = String.fromEnvironment('SYNC_NAMESPACE'); + +final RegExp _allowed = RegExp(r'^sandbox/[a-z0-9_-]+$'); + +/// Returns [namespace] when it is empty or `sandbox/`, else throws. +/// +/// Fails closed: a typo must stop the app at startup, never quietly fall back +/// to production paths. A trailing newline is rejected too. +String checkSyncNamespace(String namespace) { + if (namespace.isEmpty || _allowed.hasMatch(namespace)) return namespace; + throw ConfigException( + 'SYNC_NAMESPACE "$namespace" is invalid: it must be empty or match ' + r'^sandbox/[a-z0-9_-]+$ (e.g. sandbox/slot3)', + ); +} diff --git a/crdt_sync_dart/test/sync_namespace_test.dart b/crdt_sync_dart/test/sync_namespace_test.dart new file mode 100644 index 0000000..d9379f5 --- /dev/null +++ b/crdt_sync_dart/test/sync_namespace_test.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:test/test.dart'; + +const _databaseUrl = 'https://example-default-rtdb.firebasedatabase.app'; + +FirebaseTokenProvider _auth() => FirebaseTokenProvider( + apiKey: 'fake-api-key', + store: InMemoryCredentialStore( + FirebaseCredentials( + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresAt: DateTime.utc(2099), + ), + ), + httpClient: http_testing.MockClient( + (_) async => throw StateError('auth must not be contacted'), + ), +); + +/// Client over an in-memory transport that records every request path. +(FirebaseRestClient, List) _client(String? namespace) { + final requests = []; + final mock = http_testing.MockClient((request) async { + requests.add(request); + return http.Response(jsonEncode(request.method == 'GET' ? null : 'x'), 200); + }); + final client = namespace == null + ? FirebaseRestClient( + databaseUrl: _databaseUrl, + auth: _auth(), + httpClient: mock, + ) + : FirebaseRestClient( + databaseUrl: _databaseUrl, + auth: _auth(), + httpClient: mock, + namespace: namespace, + ); + return (client, requests); +} + +void main() { + group('compile-time default', () { + test('is empty when SYNC_NAMESPACE is not defined', () { + expect(syncNamespace, isEmpty); + }); + + test('empty namespace leaves paths byte-for-byte unchanged', () async { + final (client, requests) = _client(null); + await client.getFileText('devices/pc/log.json'); + expect(requests.single.url.path, '/devices/pc/log~2Ejson.json'); + }); + }); + + group('prefixing', () { + test('every operation lands under the namespace', () async { + final (client, requests) = _client('sandbox/slot3'); + await client.getFileText('devices/pc/log.json'); + await client.putFileText('devices/pc/log.json', 't', message: 'm'); + await client.listDirectory('devices'); + await client.getStringMap('revs'); + await client.patchValues('revs', {'a': 'b'}); + await client.deleteFile('devices/pc/log.json'); + await client.canAccessRemote(); + expect(requests.map((r) => r.url.path), [ + '/sandbox/slot3/devices/pc/log~2Ejson.json', + '/sandbox/slot3/devices/pc/log~2Ejson.json', + '/sandbox/slot3/devices.json', + '/sandbox/slot3/revs.json', + '/sandbox/slot3/revs.json', + '/sandbox/slot3/devices/pc/log~2Ejson.json', + '/sandbox/slot3.json', + ]); + }); + + test('an explicitly empty namespace is the unprefixed path', () async { + final (client, requests) = _client(''); + await client.getStringMap('revs'); + expect(requests.single.url.path, '/revs.json'); + }); + }); + + group('validation (fail closed)', () { + const bad = [ + 'sandbox', + 'sandbox/', + 'sandbox/Slot3', + 'sandbox/slot 3', + 'sandbox/slot3/', + 'sandbox/a/b', + '/sandbox/slot3', + 'prod/slot3', + 'sandbox/slot3\n', + 'Sandbox/slot3', + 'sandbox/slot.3', + 'sandbox/..', + ]; + + for (final value in bad) { + test('rejects ${jsonEncode(value)}', () { + expect( + () => checkSyncNamespace(value), + throwsA(isA()), + ); + expect( + () => FirebaseRestClient( + databaseUrl: _databaseUrl, + auth: _auth(), + namespace: value, + ), + throwsA(isA()), + ); + }); + } + + test('the message names the define and the required shape', () { + expect( + () => checkSyncNamespace('typo'), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('SYNC_NAMESPACE'), + contains(r'^sandbox/[a-z0-9_-]+$'), + ), + ), + ), + ); + }); + + test('accepts the allowed alphabet', () { + expect(() => checkSyncNamespace('sandbox/slot3'), returnsNormally); + expect(() => checkSyncNamespace('sandbox/a_b-c9'), returnsNormally); + expect(() => checkSyncNamespace(''), returnsNormally); + }); + + test( + 'a bad compile-time define stops firebaseClientFor at startup', + () async { + // The define is compile-time, so only a fresh process can set it. + final result = await Process.run(Platform.resolvedExecutable, [ + '-DSYNC_NAMESPACE=sandbox/Typo', + 'run', + 'tool/namespace_probe.dart', + ]); + expect(result.exitCode, isNot(0)); + expect( + result.stderr, + contains('SYNC_NAMESPACE "sandbox/Typo" is invalid'), + ); + }, + ); + + test('a good compile-time define reaches the request path', () async { + final result = await Process.run(Platform.resolvedExecutable, [ + '-DSYNC_NAMESPACE=sandbox/slot3', + 'run', + 'tool/namespace_probe.dart', + ]); + expect(result.stderr, isEmpty); + expect(result.stdout, '/sandbox/slot3/devices.json\n'); + }); + }); +} diff --git a/crdt_sync_dart/tool/namespace_probe.dart b/crdt_sync_dart/tool/namespace_probe.dart new file mode 100644 index 0000000..23ddf0c --- /dev/null +++ b/crdt_sync_dart/tool/namespace_probe.dart @@ -0,0 +1,31 @@ +// Helper for test/sync_namespace_test.dart: the SYNC_NAMESPACE define is +// compile-time, so the test runs this in a fresh process with -D set. It signs +// in from an in-memory session (no network) and prints the first request path. +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; + +Future main() async { + final mock = http_testing.MockClient((request) async { + print(request.url.path); + return http.Response('null', 200); + }); + final client = await firebaseClientFor( + config: const FirebaseConfig( + apiKey: 'k', + databaseUrl: 'https://example.firebasedatabase.app', + projectId: 'p', + uid: 'u', + email: 'e@x.y', + ), + store: InMemoryCredentialStore( + FirebaseCredentials( + idToken: 'i', + refreshToken: 'r', + expiresAt: DateTime.utc(2099), + ), + ), + httpClient: mock, + ); + await client.listDirectory('devices'); +}