fix(#471): don't offload the client block list onto a blank radio

On connect, the block reconcile unioned the client's block list with the
radio's and pushed the difference, so a fresh/erased radio (empty list) had
every client block offloaded onto it and came up pre-blocked (#471, owner
verdict ERROR). Owner decision: Option A. A radio that reports an EMPTY block
list is treated as blank and is NOT auto-offloaded; the client stays the source
of truth and blocks reach a radio only by explicit user action. A radio that
already holds blocks still unions normally. Client-side only, no wire change.
Added a setOffbandCapsForTest seam so the reconcile is testable.

Part of radio-profile isolation Feature #853. The per-radio Blocked view +
provenance (#507) is the follow-on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs/853-isolation-plan
diffwireauto 4 weeks ago committed by Benjamin Wiechel
parent a331075a8b
commit b59bc728fc

@ -5873,6 +5873,7 @@ class MeshCoreConnector extends ChangeNotifier {
@visibleForTesting
void setFirmwareVerCodeForTest(int? value) => _firmwareVerCode = value;
void setOffbandCapsForTest(int? value) => _offbandCaps = value;
void _guardRegionScope() {
if (!supportsRegionScope) {
@ -6245,6 +6246,21 @@ class MeshCoreConnector extends ChangeNotifier {
final toImport = nodeSet.difference(local).difference(touched);
final toPush = local.difference(nodeSet).difference(touched);
unawaited(blockService.importKeys(toImport));
// #471 Option A: a radio that reports an EMPTY block list is treated as
// blank. Do NOT auto-offload the client's blocks onto it, which made
// fresh/erased radios come up pre-blocked. The client stays the source of
// truth; blocks reach a radio only by an explicit user action. A radio that
// already holds blocks still unions normally.
if (nodeSet.isEmpty) {
if (toPush.isNotEmpty) {
_appDebugLogService?.info(
'Radio reports no blocks; not offloading ${toPush.length} '
'client block(s) onto a blank radio (#471)',
tag: 'Block',
);
}
return;
}
for (final key in toPush) {
_pushBlockChange(key, true);
}

@ -0,0 +1,110 @@
import 'dart:typed_data';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/services/block_service.dart';
import 'package:meshcore_open/services/message_retry_service.dart';
import 'package:meshcore_open/services/path_history_service.dart';
import 'package:meshcore_open/services/storage_service.dart';
import 'package:meshcore_open/storage/drift/blob_store.dart';
import 'package:meshcore_open/storage/drift/offband_database.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
// #471 Option A: connecting to a radio that reports an EMPTY block list must not
// offload the client's block list onto it (that made fresh/erased radios come
// up pre-blocked). A radio that already holds blocks still unions normally.
void main() {
final clientKey = 'cc' * 32; // 32-byte key, all 0xcc
final extraKey = 'ee' * 32;
late OffbandDatabase db;
late MeshCoreConnector connector;
late BlockService block;
late List<Uint8List> sent;
// Block-list dump framing: START [0xC2 0x03 0xFF count], KEY [0xC2 0x03 idx key32], END [0xC2 0x03 0xFE].
Uint8List start(int count) =>
Uint8List.fromList([cmdOffbandBlock, offbandBlockList, 0xFF, count]);
Uint8List key(int idx, int keyByte) => Uint8List.fromList([
cmdOffbandBlock,
offbandBlockList,
idx,
...List<int>.filled(32, keyByte),
]);
Uint8List end() =>
Uint8List.fromList([cmdOffbandBlock, offbandBlockList, 0xFE]);
List<Uint8List> blockAddFrames() => sent
.where(
(f) =>
f.length > 1 && f[0] == cmdOffbandBlock && f[1] == offbandBlockAdd,
)
.toList();
setUp(() async {
SharedPreferences.setMockInitialValues({});
PrefsManager.reset();
await PrefsManager.initialize();
db = OffbandDatabase(NativeDatabase.memory());
BlobStore.overrideForTest(BlobStore(db));
connector = MeshCoreConnector();
block = BlockService();
connector.initialize(
retryService: MessageRetryService(),
pathHistoryService: PathHistoryService(StorageService()),
blockService: block,
);
sent = [];
connector.sendFrameOverrideForTest = sent.add;
connector.setConnectedForTest();
// Make the radio block-capable so a push WOULD fire but for the guard.
connector.setFirmwareVerCodeForTest(15);
connector.setOffbandCapsForTest(offbandCapBlock);
});
tearDown(() async {
BlobStore.clearTestOverride();
await db.close();
});
test(
'a radio reporting no blocks is NOT offloaded the client list',
() async {
await block.importKeys({clientKey}); // client has a block
sent.clear();
connector.handleFrameForTest(start(0));
connector.handleFrameForTest(end());
await Future<void>.delayed(Duration.zero);
expect(
blockAddFrames(),
isEmpty,
reason: 'a blank radio must not inherit the client block list',
);
},
);
test(
'a radio that already holds blocks still unions the client extras',
() async {
await block.importKeys({clientKey, extraKey});
sent.clear();
// Radio holds clientKey (0xcc) already, but not extraKey (0xee).
connector.handleFrameForTest(start(1));
connector.handleFrameForTest(key(0, 0xcc));
connector.handleFrameForTest(end());
await Future<void>.delayed(Duration.zero);
final pushed = blockAddFrames();
expect(pushed.length, 1, reason: 'only the extra block is offloaded');
// The pushed key is extraKey (0xee...), not the one the radio already had.
expect(pushed.single.sublist(2), List<int>.filled(32, 0xee));
},
);
}
Loading…
Cancel
Save

Powered by TurnKey Linux.