mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
crdt_sync: SYNC_NAMESPACE sandbox define
Compile-time dart-define that prefixes every RTDB path with sandbox/<slot>; invalid values throw at startup. Empty keeps prod paths unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8dfc4fb55f
commit
027e67b197
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
/// `<namespace>/` 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> _uri(String path, {Map<String, String> 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});
|
||||
}
|
||||
|
||||
|
||||
@ -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<FirebaseRestClient> 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,
|
||||
|
||||
26
crdt_sync_dart/lib/src/sync_namespace.dart
Normal file
26
crdt_sync_dart/lib/src/sync_namespace.dart
Normal file
@ -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/<slot>`, 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)',
|
||||
);
|
||||
}
|
||||
170
crdt_sync_dart/test/sync_namespace_test.dart
Normal file
170
crdt_sync_dart/test/sync_namespace_test.dart
Normal file
@ -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<http.Request>) _client(String? namespace) {
|
||||
final requests = <http.Request>[];
|
||||
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<ConfigException>()),
|
||||
);
|
||||
expect(
|
||||
() => FirebaseRestClient(
|
||||
databaseUrl: _databaseUrl,
|
||||
auth: _auth(),
|
||||
namespace: value,
|
||||
),
|
||||
throwsA(isA<ConfigException>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the message names the define and the required shape', () {
|
||||
expect(
|
||||
() => checkSyncNamespace('typo'),
|
||||
throwsA(
|
||||
isA<ConfigException>().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');
|
||||
});
|
||||
});
|
||||
}
|
||||
31
crdt_sync_dart/tool/namespace_probe.dart
Normal file
31
crdt_sync_dart/tool/namespace_probe.dart
Normal file
@ -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<void> 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');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user