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
|
APK — the security rules, not secrecy, protect the data. The account email and
|
||||||
password identify *a person* and are entered once per device.
|
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
|
### `password: ''` is legal
|
||||||
|
|
||||||
The Google one-tap path and the seeded-session path both store an empty
|
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/secure_credential_store.dart';
|
||||||
export 'src/store.dart';
|
export 'src/store.dart';
|
||||||
export 'src/sync.dart';
|
export 'src/sync.dart';
|
||||||
|
export 'src/sync_namespace.dart';
|
||||||
export 'src/sync_state.dart';
|
export 'src/sync_state.dart';
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import 'firebase_auth_rest.dart';
|
|||||||
import 'firebase_errors.dart';
|
import 'firebase_errors.dart';
|
||||||
import 'firebase_keys.dart';
|
import 'firebase_keys.dart';
|
||||||
import 'remote_store.dart';
|
import 'remote_store.dart';
|
||||||
|
import 'sync_namespace.dart';
|
||||||
|
|
||||||
const _httpUnauthorized = 401;
|
const _httpUnauthorized = 401;
|
||||||
const _httpForbidden = 403;
|
const _httpForbidden = 403;
|
||||||
@ -33,13 +34,22 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
|
|||||||
required String databaseUrl,
|
required String databaseUrl,
|
||||||
required this.auth,
|
required this.auth,
|
||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
|
String namespace = syncNamespace,
|
||||||
}) : _databaseUrl = databaseUrl.endsWith('/')
|
}) : _databaseUrl = databaseUrl.endsWith('/')
|
||||||
? databaseUrl.substring(0, databaseUrl.length - 1)
|
? databaseUrl.substring(0, databaseUrl.length - 1)
|
||||||
: databaseUrl,
|
: databaseUrl,
|
||||||
|
_prefix = _prefixFor(checkSyncNamespace(namespace)),
|
||||||
_http = httpClient ?? http.Client();
|
_http = httpClient ?? http.Client();
|
||||||
|
|
||||||
final String _databaseUrl;
|
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
|
/// Mints the ID token every request carries. Public so a settings screen
|
||||||
/// can drive `signIn` / `signOut` through the same instance the client uses.
|
/// can drive `signIn` / `signOut` through the same instance the client uses.
|
||||||
final FirebaseTokenProvider auth;
|
final FirebaseTokenProvider auth;
|
||||||
@ -47,8 +57,12 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
|
|||||||
|
|
||||||
Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async {
|
Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async {
|
||||||
final encoded = encodePath(path);
|
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(
|
return Uri.parse(
|
||||||
'$_databaseUrl/$encoded.json',
|
'$_databaseUrl/$full.json',
|
||||||
).replace(queryParameters: {'auth': await auth.idToken(), ...query});
|
).replace(queryParameters: {'auth': await auth.idToken(), ...query});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import 'firebase_credentials.dart';
|
|||||||
import 'firebase_client.dart';
|
import 'firebase_client.dart';
|
||||||
import 'mirror_store.dart';
|
import 'mirror_store.dart';
|
||||||
import 'remote_store.dart';
|
import 'remote_store.dart';
|
||||||
|
import 'sync_namespace.dart';
|
||||||
|
|
||||||
/// The shared config is missing a field, or still holds a scaffold placeholder.
|
/// The shared config is missing a field, or still holds a scaffold placeholder.
|
||||||
class ConfigException implements Exception {
|
class ConfigException implements Exception {
|
||||||
@ -121,6 +122,8 @@ Future<FirebaseRestClient> firebaseClientFor({
|
|||||||
String? expectedUid,
|
String? expectedUid,
|
||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Before any sign-in: a bad SYNC_NAMESPACE must stop the app at startup.
|
||||||
|
checkSyncNamespace(syncNamespace);
|
||||||
final auth = FirebaseTokenProvider(
|
final auth = FirebaseTokenProvider(
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
store: store,
|
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