diff --git a/lib/models/stock_config.dart b/lib/models/stock_config.dart new file mode 100644 index 0000000..086c91c --- /dev/null +++ b/lib/models/stock_config.dart @@ -0,0 +1,624 @@ +/// MeshCore stock companion config export format (#572, epic #568). +/// +/// This is a faithful model of the JSON file the official MeshCore Companion +/// app writes from its Export Config screen, so that a stock export imports +/// into Offband and an Offband export imports into stock. The shape was +/// reverse-engineered from ten real exports across five radios (2359 contacts, +/// 76 channels); the evidence is recorded on issue #569. +/// +/// Rules the format imposes, none of them obvious: +/// +/// * **There is no version field.** Stock writes none, so we write none, and +/// validation is by shape alone. Every top-level section is independently +/// optional because the export screen lets the user deselect any of them. +/// * `public_key` and `private_key` are one unit: stock's single "Private +/// Identity Key" control emits both or neither. +/// * `radio_settings` mixes units: `frequency` is kHz, `bandwidth` is Hz, and +/// `coding_rate` is a bare denominator (5 means 4/5). +/// * Latitudes and longitudes are JSON **strings**, never numbers. +/// * `channels` has no index field; array position is the channel index. +/// * Contact timestamps are whatever the mesh reported and are not sanitized. +/// The real corpus contains values from epoch 14 to the year 2095. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import '../connector/meshcore_protocol.dart' show pubKeyToHex; + +/// Thrown when a config file does not match the stock format. +/// +/// Carries the offending JSON path so an import screen can tell the user which +/// part of the file is wrong rather than just "invalid file". +class StockConfigFormatException implements Exception { + StockConfigFormatException(this.message, {this.path}); + + final String message; + + /// Dotted path to the offending value, e.g. `contacts[3].out_path_list`. + final String? path; + + @override + String toString() => path == null + ? 'StockConfigFormatException: $message' + : 'StockConfigFormatException at $path: $message'; +} + +/// Firmware caps a contact name at `char name[32]`, so 31 usable characters. +const int kStockMaxContactNameChars = 31; + +const int _publicKeyBytes = 32; +const int _privateKeyBytes = 64; +const int _channelSecretBytes = 16; + +/// Hop-hash widths the parser will accept, in bytes. +/// +/// Only width 2 has been observed in the wild (a single path across the whole +/// corpus). 1 and 3 are accepted because the firmware path-length byte encodes +/// widths 1..3 (#309) and there is no reason stock would refuse to emit them. +/// Anything else is rejected rather than guessed at. +const Set kStockPathHashWidths = {1, 2, 3}; + +Uint8List _parseHex(String value, String path, {int? expectBytes}) { + if (value.length.isOdd) { + throw StockConfigFormatException( + 'expected hex, got an odd number of characters (${value.length})', + path: path, + ); + } + final bytes = Uint8List(value.length ~/ 2); + for (var i = 0; i < bytes.length; i++) { + final byte = int.tryParse(value.substring(i * 2, i * 2 + 2), radix: 16); + if (byte == null) { + throw StockConfigFormatException('expected hex characters', path: path); + } + bytes[i] = byte; + } + if (expectBytes != null && bytes.length != expectBytes) { + throw StockConfigFormatException( + 'expected $expectBytes bytes, got ${bytes.length}', + path: path, + ); + } + return bytes; +} + +Map _asObject(Object? value, String path) { + if (value is! Map) { + throw StockConfigFormatException('expected an object', path: path); + } + return value.cast(); +} + +int _asInt(Object? value, String path) { + if (value is int) return value; + throw StockConfigFormatException('expected an integer', path: path); +} + +bool _asBool(Object? value, String path) { + if (value is bool) return value; + throw StockConfigFormatException('expected a boolean', path: path); +} + +String _asString(Object? value, String path) { + if (value is String) return value; + throw StockConfigFormatException('expected a string', path: path); +} + +/// Stock writes coordinates as decimal strings, so `0.0` and `39.561991` both +/// appear verbatim. Dart's shortest-round-trip `double.toString()` reproduces +/// exactly that, which is why [_coordToJson] is a plain `toString`. +double _parseCoord(Object? value, String path) { + final text = _asString(value, path); + final parsed = double.tryParse(text); + if (parsed == null) { + throw StockConfigFormatException( + 'expected a decimal coordinate string', + path: path, + ); + } + return parsed; +} + +String _coordToJson(double value) => value.toString(); + +/// LoRa parameters. Units differ per field. See the class doc on [StockConfig]. +class StockRadioSettings { + const StockRadioSettings({ + required this.frequencyKhz, + required this.bandwidthHz, + required this.spreadingFactor, + required this.codingRate, + required this.txPower, + }); + + /// `frequency`, in **kHz** (910525 means 910.525 MHz). + final int frequencyKhz; + + /// `bandwidth`, in **Hz** (62500 means 62.5 kHz). + final int bandwidthHz; + + final int spreadingFactor; + + /// `coding_rate`, the **denominator only**, 5 means 4/5. + final int codingRate; + + /// `tx_power`, in dBm. + final int txPower; + + factory StockRadioSettings.fromJson(Map json, String path) { + return StockRadioSettings( + frequencyKhz: _asInt(json['frequency'], '$path.frequency'), + bandwidthHz: _asInt(json['bandwidth'], '$path.bandwidth'), + spreadingFactor: _asInt( + json['spreading_factor'], + '$path.spreading_factor', + ), + codingRate: _asInt(json['coding_rate'], '$path.coding_rate'), + txPower: _asInt(json['tx_power'], '$path.tx_power'), + ); + } + + Map toJson() => { + 'frequency': frequencyKhz, + 'bandwidth': bandwidthHz, + 'spreading_factor': spreadingFactor, + 'coding_rate': codingRate, + 'tx_power': txPower, + }; +} + +/// The device's own advertised position. Both values are strings on the wire. +class StockPositionSettings { + const StockPositionSettings({ + required this.latitude, + required this.longitude, + }); + + final double latitude; + final double longitude; + + factory StockPositionSettings.fromJson( + Map json, + String path, + ) { + return StockPositionSettings( + latitude: _parseCoord(json['latitude'], '$path.latitude'), + longitude: _parseCoord(json['longitude'], '$path.longitude'), + ); + } + + Map toJson() => { + 'latitude': _coordToJson(latitude), + 'longitude': _coordToJson(longitude), + }; +} + +/// Stock's "Other Settings". Both fields are ints on the wire even though they +/// read as booleans in the UI: `manual_add_contacts` is emitted as 0/1, not +/// true/false, unlike the [StockAutoAddSettings] block. +class StockOtherSettings { + const StockOtherSettings({ + required this.manualAddContacts, + required this.advertLocationPolicy, + }); + + final int manualAddContacts; + + /// Shown in stock's UI as "Share Position in Advert". + final int advertLocationPolicy; + + factory StockOtherSettings.fromJson(Map json, String path) { + return StockOtherSettings( + manualAddContacts: _asInt( + json['manual_add_contacts'], + '$path.manual_add_contacts', + ), + advertLocationPolicy: _asInt( + json['advert_location_policy'], + '$path.advert_location_policy', + ), + ); + } + + Map toJson() => { + 'manual_add_contacts': manualAddContacts, + 'advert_location_policy': advertLocationPolicy, + }; +} + +/// Stock's "Auto Add Settings". Booleans here really are JSON booleans. +class StockAutoAddSettings { + const StockAutoAddSettings({ + required this.autoAddChat, + required this.autoAddRepeater, + required this.autoAddRoomServer, + required this.autoAddSensor, + required this.overwriteOldest, + required this.autoAddMaxHops, + }); + + final bool autoAddChat; + final bool autoAddRepeater; + final bool autoAddRoomServer; + final bool autoAddSensor; + final bool overwriteOldest; + + /// `auto_add_max_hops`; stock renders 0 as "(no limit)". + final int autoAddMaxHops; + + factory StockAutoAddSettings.fromJson( + Map json, + String path, + ) { + return StockAutoAddSettings( + autoAddChat: _asBool(json['auto_add_chat'], '$path.auto_add_chat'), + autoAddRepeater: _asBool( + json['auto_add_repeater'], + '$path.auto_add_repeater', + ), + autoAddRoomServer: _asBool( + json['auto_add_room_server'], + '$path.auto_add_room_server', + ), + autoAddSensor: _asBool(json['auto_add_sensor'], '$path.auto_add_sensor'), + overwriteOldest: _asBool( + json['overwrite_oldest'], + '$path.overwrite_oldest', + ), + autoAddMaxHops: _asInt( + json['auto_add_max_hops'], + '$path.auto_add_max_hops', + ), + ); + } + + Map toJson() => { + 'auto_add_chat': autoAddChat, + 'auto_add_repeater': autoAddRepeater, + 'auto_add_room_server': autoAddRoomServer, + 'auto_add_sensor': autoAddSensor, + 'overwrite_oldest': overwriteOldest, + 'auto_add_max_hops': autoAddMaxHops, + }; +} + +/// One channel. The array position in [StockConfig.channels] is the index: +/// the record itself carries no index field. +class StockChannel { + const StockChannel({required this.name, required this.secret}); + + final String name; + + /// The 16-byte PSK, hex on the wire. + final Uint8List secret; + + factory StockChannel.fromJson(Map json, String path) { + return StockChannel( + name: _asString(json['name'], '$path.name'), + secret: _parseHex( + _asString(json['secret'], '$path.secret'), + '$path.secret', + expectBytes: _channelSecretBytes, + ), + ); + } + + Map toJson() => { + 'name': name, + 'secret': pubKeyToHex(secret), + }; +} + +/// A contact's routing path, as carried by `out_path_list`. +/// +/// Stock writes one comma-separated element per hop, each element being that +/// hop's hash in hex, so `"a1b2,c3d4"` is two hops at width 2. That maps +/// directly onto our width-aware path model (#309): [hopCount] is our hash +/// count and [hashWidth] is our per-hop width. Neither is inferred. +class StockOutPath { + StockOutPath({required this.hashWidth, required this.bytes}) + : assert(hashWidth > 0), + assert(bytes.length % hashWidth == 0); + + /// The `""` form of the field, as distinct from the key being null. + /// + /// Both mean "no path" and the corpus contains 10 of these against 2448 + /// nulls, but stock writes both and we do not know what distinguishes them. + /// Keeping them apart costs one constructor and makes an Offband re-export + /// byte-identical to its source, so we keep them apart. + StockOutPath.empty() : hashWidth = 0, bytes = _noBytes; + + static final Uint8List _noBytes = Uint8List(0); + + /// Bytes per hop hash, 1..3, or 0 for [StockOutPath.empty]. + /// See [kStockPathHashWidths]. + final int hashWidth; + + /// Flattened hop hashes, `hopCount * hashWidth` bytes. + final Uint8List bytes; + + int get hopCount => hashWidth == 0 ? 0 : bytes.length ~/ hashWidth; + + /// Parses `out_path_list`. A null key returns null; `""` returns + /// [StockOutPath.empty]. Both mean the contact has no route. + static StockOutPath? fromJson(Object? value, String path) { + if (value == null) return null; + final text = _asString(value, path); + if (text.isEmpty) return StockOutPath.empty(); + + final elements = text.split(','); + final widths = elements.map((e) => e.length).toSet(); + if (widths.length != 1) { + throw StockConfigFormatException( + 'hop hashes must all be the same width, got $widths', + path: path, + ); + } + final chars = widths.single; + if (chars.isOdd || !kStockPathHashWidths.contains(chars ~/ 2)) { + throw StockConfigFormatException( + 'unsupported hop hash width of $chars characters', + path: path, + ); + } + + final width = chars ~/ 2; + final bytes = BytesBuilder(); + for (final element in elements) { + bytes.add(_parseHex(element, path, expectBytes: width)); + } + return StockOutPath(hashWidth: width, bytes: bytes.toBytes()); + } + + String toJson() { + if (hashWidth == 0) return ''; + final hops = []; + for (var i = 0; i < bytes.length; i += hashWidth) { + hops.add(pubKeyToHex(Uint8List.sublistView(bytes, i, i + hashWidth))); + } + return hops.join(','); + } +} + +/// One contact. This is a near 1:1 serialization of the firmware's +/// `ContactInfo` struct, with [customName] as the one app-side addition. +class StockContact { + const StockContact({ + required this.type, + required this.name, + required this.publicKey, + required this.flags, + required this.latitude, + required this.longitude, + required this.lastAdvert, + required this.lastModified, + this.customName, + this.outPath, + }); + + /// `ADV_TYPE_*`: 1 chat, 2 repeater, 3 room, 4 sensor. + final int type; + + final String name; + + /// App-side rename. Null in all 2359 contacts of the reference corpus, so + /// its round-trip behavior is carried but unverified (#569). + final String? customName; + + final Uint8List publicKey; + + /// Bit 0 is "favourite" (firmware `flags & 0x01`). + final int flags; + + final double latitude; + final double longitude; + + /// Epoch seconds by THEIR clock. **Not sanitized**: the reference corpus + /// ranges from 14 to the year 2095. Kept as a raw int so no caller mistakes + /// it for a trustworthy time. + final int lastAdvert; + + /// Epoch seconds by OUR clock. Same caveat as [lastAdvert]. + final int lastModified; + + final StockOutPath? outPath; + + bool get isFavourite => flags & 0x01 != 0; + + factory StockContact.fromJson(Map json, String path) { + return StockContact( + type: _asInt(json['type'], '$path.type'), + name: _asString(json['name'], '$path.name'), + customName: json['custom_name'] == null + ? null + : _asString(json['custom_name'], '$path.custom_name'), + publicKey: _parseHex( + _asString(json['public_key'], '$path.public_key'), + '$path.public_key', + expectBytes: _publicKeyBytes, + ), + flags: _asInt(json['flags'], '$path.flags'), + latitude: _parseCoord(json['latitude'], '$path.latitude'), + longitude: _parseCoord(json['longitude'], '$path.longitude'), + lastAdvert: _asInt(json['last_advert'], '$path.last_advert'), + lastModified: _asInt(json['last_modified'], '$path.last_modified'), + outPath: StockOutPath.fromJson( + json['out_path_list'], + '$path.out_path_list', + ), + ); + } + + Map toJson() { + if (name.length > kStockMaxContactNameChars) { + throw StockConfigFormatException( + 'contact name is ${name.length} characters; stock firmware stores at ' + 'most $kStockMaxContactNameChars and would truncate it', + path: 'contacts[].name', + ); + } + return { + 'type': type, + 'name': name, + 'custom_name': customName, + 'public_key': pubKeyToHex(publicKey), + 'flags': flags, + 'latitude': _coordToJson(latitude), + 'longitude': _coordToJson(longitude), + 'last_advert': lastAdvert, + 'last_modified': lastModified, + 'out_path_list': outPath?.toJson(), + }; + } +} + +/// A whole stock config export. +/// +/// Every section is nullable, and null means "the user did not select this +/// section", which is distinct from an empty list. Absent sections are omitted +/// from [toJson] entirely rather than written as null, matching stock. +class StockConfig { + const StockConfig({ + this.name, + this.publicKey, + this.privateKey, + this.radioSettings, + this.positionSettings, + this.otherSettings, + this.autoAddSettings, + this.channels, + this.contacts, + }); + + final String? name; + + /// 32 bytes. Travels as a unit with [privateKey]: stock's identity control + /// emits both or neither. + final Uint8List? publicKey; + + /// 64 bytes. See [publicKey]. + final Uint8List? privateKey; + + final StockRadioSettings? radioSettings; + final StockPositionSettings? positionSettings; + final StockOtherSettings? otherSettings; + final StockAutoAddSettings? autoAddSettings; + + /// Ordered; array position is the channel index. + final List? channels; + + final List? contacts; + + /// Parses a decoded JSON object. + /// + /// Unknown top-level keys are ignored, not rejected: the format has no + /// version field, so tolerating additions is the only way to survive a stock + /// release that adds one. + factory StockConfig.fromJson(Map json) { + final channelsJson = json['channels']; + final contactsJson = json['contacts']; + + return StockConfig( + name: json['name'] == null ? null : _asString(json['name'], 'name'), + publicKey: json['public_key'] == null + ? null + : _parseHex( + _asString(json['public_key'], 'public_key'), + 'public_key', + expectBytes: _publicKeyBytes, + ), + privateKey: json['private_key'] == null + ? null + : _parseHex( + _asString(json['private_key'], 'private_key'), + 'private_key', + expectBytes: _privateKeyBytes, + ), + radioSettings: json['radio_settings'] == null + ? null + : StockRadioSettings.fromJson( + _asObject(json['radio_settings'], 'radio_settings'), + 'radio_settings', + ), + positionSettings: json['position_settings'] == null + ? null + : StockPositionSettings.fromJson( + _asObject(json['position_settings'], 'position_settings'), + 'position_settings', + ), + otherSettings: json['other_settings'] == null + ? null + : StockOtherSettings.fromJson( + _asObject(json['other_settings'], 'other_settings'), + 'other_settings', + ), + autoAddSettings: json['auto_add_settings'] == null + ? null + : StockAutoAddSettings.fromJson( + _asObject(json['auto_add_settings'], 'auto_add_settings'), + 'auto_add_settings', + ), + channels: channelsJson == null + ? null + : _parseList( + channelsJson, + 'channels', + (item, path) => + StockChannel.fromJson(_asObject(item, path), path), + ), + contacts: contactsJson == null + ? null + : _parseList( + contactsJson, + 'contacts', + (item, path) => + StockContact.fromJson(_asObject(item, path), path), + ), + ); + } + + static List _parseList( + Object? value, + String path, + T Function(Object? item, String itemPath) parse, + ) { + if (value is! List) { + throw StockConfigFormatException('expected an array', path: path); + } + final result = []; + for (var i = 0; i < value.length; i++) { + result.add(parse(value[i], '$path[$i]')); + } + return result; + } + + /// Parses raw file text. + factory StockConfig.parse(String source) { + final Object? decoded; + try { + decoded = jsonDecode(source); + } on FormatException catch (e) { + throw StockConfigFormatException('file is not valid JSON: ${e.message}'); + } + return StockConfig.fromJson(_asObject(decoded, r'$')); + } + + Map toJson() { + return { + if (name != null) 'name': name, + if (publicKey != null) 'public_key': pubKeyToHex(publicKey!), + if (privateKey != null) 'private_key': pubKeyToHex(privateKey!), + if (radioSettings != null) 'radio_settings': radioSettings!.toJson(), + if (positionSettings != null) + 'position_settings': positionSettings!.toJson(), + if (otherSettings != null) 'other_settings': otherSettings!.toJson(), + if (autoAddSettings != null) + 'auto_add_settings': autoAddSettings!.toJson(), + if (channels != null) 'channels': [for (final c in channels!) c.toJson()], + if (contacts != null) 'contacts': [for (final c in contacts!) c.toJson()], + }; + } + + String encode() => jsonEncode(toJson()); +} diff --git a/test/models/stock_config_test.dart b/test/models/stock_config_test.dart new file mode 100644 index 0000000..b3ca0b2 --- /dev/null +++ b/test/models/stock_config_test.dart @@ -0,0 +1,448 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/stock_config.dart'; + +/// All key material in this file is synthetic and obviously fake. Real stock +/// exports carry a live node private key and channel PSKs and must never enter +/// the repository (#568). +const String fakePublicKey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const String fakePrivateKey = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + +/// 32 hex characters = the 16-byte channel PSK. +const String fakeChannelSecret = 'cccccccccccccccccccccccccccccccc'; + +Map contactJson({ + String name = 'Node', + String? customName, + String publicKey = fakePublicKey, + int type = 1, + int flags = 0, + String latitude = '0.0', + String longitude = '0.0', + int lastAdvert = 1785706493, + int lastModified = 1785706508, + Object? outPathList, +}) => { + 'type': type, + 'name': name, + 'custom_name': customName, + 'public_key': publicKey, + 'flags': flags, + 'latitude': latitude, + 'longitude': longitude, + 'last_advert': lastAdvert, + 'last_modified': lastModified, + 'out_path_list': outPathList, +}; + +Map fullConfigJson() => { + 'name': 'Test Radio', + 'public_key': fakePublicKey, + 'private_key': fakePrivateKey, + 'radio_settings': { + 'frequency': 910525, + 'bandwidth': 62500, + 'spreading_factor': 7, + 'coding_rate': 5, + 'tx_power': 22, + }, + 'position_settings': {'latitude': '39.561991', 'longitude': '-84.635731'}, + 'other_settings': {'manual_add_contacts': 1, 'advert_location_policy': 0}, + 'auto_add_settings': { + 'auto_add_chat': true, + 'auto_add_repeater': true, + 'auto_add_room_server': false, + 'auto_add_sensor': false, + 'overwrite_oldest': true, + 'auto_add_max_hops': 0, + }, + 'channels': [ + {'name': 'Public', 'secret': fakeChannelSecret}, + ], + 'contacts': [contactJson()], +}; + +void main() { + group('round trip', () { + test('a full config survives parse then encode unchanged', () { + final source = jsonEncode(fullConfigJson()); + + final reencoded = StockConfig.parse(source).encode(); + + expect(jsonDecode(reencoded), fullConfigJson()); + }); + + test('radio settings keep their raw values and units', () { + final radio = StockConfig.parse( + jsonEncode(fullConfigJson()), + ).radioSettings!; + + // frequency is kHz, bandwidth is Hz, coding_rate is a bare denominator. + expect(radio.frequencyKhz, 910525); + expect(radio.bandwidthHz, 62500); + expect(radio.codingRate, 5); + }); + + test('coordinates are emitted as strings, never numbers', () { + final encoded = + jsonDecode(StockConfig.parse(jsonEncode(fullConfigJson())).encode()) + as Map; + + expect(encoded['position_settings']['latitude'], isA()); + expect(encoded['position_settings']['latitude'], '39.561991'); + expect((encoded['contacts'] as List).first['latitude'], '0.0'); + }); + }); + + group('optional sections', () { + test('a config with no sections at all parses to all nulls', () { + final config = StockConfig.parse('{}'); + + expect(config.name, isNull); + expect(config.publicKey, isNull); + expect(config.channels, isNull); + expect(config.contacts, isNull); + }); + + test('an identity-only export parses, mirroring stock 272-byte files', () { + final config = StockConfig.parse( + jsonEncode({ + 'name': 'Test Radio', + 'public_key': fakePublicKey, + 'private_key': fakePrivateKey, + }), + ); + + expect(config.name, 'Test Radio'); + expect(config.privateKey, hasLength(64)); + expect(config.radioSettings, isNull); + expect(config.contacts, isNull); + }); + + test( + 'identity deselected omits both key fields, not just the private one', + () { + final json = fullConfigJson() + ..remove('public_key') + ..remove('private_key'); + + final encoded = + jsonDecode(StockConfig.parse(jsonEncode(json)).encode()) + as Map; + + expect(encoded.containsKey('public_key'), isFalse); + expect(encoded.containsKey('private_key'), isFalse); + }, + ); + + test('a public key without a private key is still accepted', () { + // Observed in a 2026-08-07 export, a combination current stock does not + // produce. The format has no version field, so the reader tolerates it. + final json = fullConfigJson()..remove('private_key'); + + final config = StockConfig.parse(jsonEncode(json)); + + expect(config.publicKey, hasLength(32)); + expect(config.privateKey, isNull); + }); + + test('absent sections are omitted rather than written as null', () { + final encoded = + jsonDecode(const StockConfig(name: 'Bare').encode()) + as Map; + + expect(encoded.keys, ['name']); + }); + + test('an empty contact list is distinct from an absent one', () { + expect(StockConfig.parse('{"contacts":[]}').contacts, isEmpty); + expect(StockConfig.parse('{}').contacts, isNull); + }); + + test('unknown top-level keys are ignored, not rejected', () { + final json = fullConfigJson()..['some_future_section'] = {'a': 1}; + + expect(StockConfig.parse(jsonEncode(json)).name, 'Test Radio'); + }); + }); + + group('channels', () { + test('array position is the index, since records carry none', () { + final config = StockConfig.parse( + jsonEncode({ + 'channels': [ + {'name': 'First', 'secret': fakeChannelSecret}, + {'name': 'Second', 'secret': fakeChannelSecret}, + ], + }), + ); + + expect(config.channels!.map((c) => c.name), ['First', 'Second']); + expect(config.channels!.first.secret, hasLength(16)); + }); + + test('a secret of the wrong length is rejected with its path', () { + expect( + () => StockConfig.parse('{"channels":[{"name":"X","secret":"aabb"}]}'), + throwsA( + isA().having( + (e) => e.path, + 'path', + 'channels[0].secret', + ), + ), + ); + }); + }); + + group('contacts', () { + test('a 25-character name round trips', () { + final name = 'A' * 25; + final json = { + 'contacts': [contactJson(name: name)], + }; + + expect(StockConfig.parse(jsonEncode(json)).contacts!.first.name, name); + }); + + test('serializing a name longer than the firmware cap throws', () { + final config = StockConfig.parse( + jsonEncode({ + 'contacts': [contactJson(name: 'A' * 32)], + }), + ); + + expect(config.encode, throwsA(isA())); + }); + + test('an implausible far-future timestamp is preserved, not sanitized', () { + // The reference corpus contains values up to the year 2095. + final json = { + 'contacts': [contactJson(lastAdvert: 3950815473, lastModified: 14)], + }; + + final contact = StockConfig.parse(jsonEncode(json)).contacts!.first; + + expect(contact.lastAdvert, 3950815473); + expect(contact.lastModified, 14); + }); + + test('flags bit 0 decodes as favourite', () { + final json = { + 'contacts': [contactJson(flags: 15), contactJson(flags: 0)], + }; + + final contacts = StockConfig.parse(jsonEncode(json)).contacts!; + + expect(contacts[0].isFavourite, isTrue); + expect(contacts[1].isFavourite, isFalse); + }); + + test('a null custom name round trips as null', () { + final config = StockConfig.parse( + jsonEncode({ + 'contacts': [contactJson()], + }), + ); + + expect(config.contacts!.first.customName, isNull); + expect( + (jsonDecode(config.encode())['contacts'] as List).first['custom_name'], + isNull, + ); + }); + }); + + group('out_path_list', () { + test('null and empty string both mean no path', () { + final json = { + 'contacts': [ + contactJson(outPathList: null), + contactJson(outPathList: ''), + ], + }; + + final contacts = StockConfig.parse(jsonEncode(json)).contacts!; + + expect(contacts[0].outPath, isNull); + expect(contacts[1].outPath!.hopCount, 0); + }); + + test('the null and empty-string forms stay distinct on re-encode', () { + // Real exports contain both. We do not know what distinguishes them, so + // collapsing them would discard information we cannot recover. + final json = { + 'contacts': [ + contactJson(outPathList: null), + contactJson(outPathList: ''), + ], + }; + + final encoded = + jsonDecode(StockConfig.parse(jsonEncode(json)).encode())['contacts'] + as List; + + expect(encoded[0]['out_path_list'], isNull); + expect(encoded[1]['out_path_list'], ''); + }); + + test('a width-2 path yields hop count and width without inference', () { + // The only populated path in the reference corpus: two hops, four hex + // characters each. + final json = { + 'contacts': [contactJson(outPathList: 'a1b2,c3d4')], + }; + + final path = StockConfig.parse(jsonEncode(json)).contacts!.first.outPath!; + + expect(path.hashWidth, 2); + expect(path.hopCount, 2); + expect(path.bytes, [0xa1, 0xb2, 0xc3, 0xd4]); + }); + + test('width 1 and width 3 parse even though only width 2 was observed', () { + Map one(String p) => { + 'contacts': [contactJson(outPathList: p)], + }; + + final narrow = StockConfig.parse( + jsonEncode(one('a1,b2,c3')), + ).contacts!.first.outPath!; + final wide = StockConfig.parse( + jsonEncode(one('a1b2c3')), + ).contacts!.first.outPath!; + + expect(narrow.hashWidth, 1); + expect(narrow.hopCount, 3); + expect(wide.hashWidth, 3); + expect(wide.hopCount, 1); + }); + + test('a path round trips to the same string', () { + final json = { + 'contacts': [contactJson(outPathList: 'a1b2,c3d4')], + }; + + final encoded = jsonDecode(StockConfig.parse(jsonEncode(json)).encode()); + + expect((encoded['contacts'] as List).first['out_path_list'], 'a1b2,c3d4'); + }); + + test('mixed hop widths are rejected rather than guessed at', () { + final json = { + 'contacts': [contactJson(outPathList: 'a1b2,c3')], + }; + + expect( + () => StockConfig.parse(jsonEncode(json)), + throwsA( + isA().having( + (e) => e.path, + 'path', + 'contacts[0].out_path_list', + ), + ), + ); + }); + + test('a hop width beyond three bytes is rejected', () { + final json = { + 'contacts': [contactJson(outPathList: 'a1b2c3d4')], + }; + + expect( + () => StockConfig.parse(jsonEncode(json)), + throwsA(isA()), + ); + }); + + test('non-hex hop content is rejected', () { + final json = { + 'contacts': [contactJson(outPathList: 'zzzz,c3d4')], + }; + + expect( + () => StockConfig.parse(jsonEncode(json)), + throwsA(isA()), + ); + }); + }); + + group('malformed input', () { + test('text that is not JSON is rejected', () { + expect( + () => StockConfig.parse('not json at all'), + throwsA(isA()), + ); + }); + + test('a top-level array is rejected', () { + expect( + () => StockConfig.parse('[]'), + throwsA(isA()), + ); + }); + + test('a public key of the wrong length is rejected', () { + expect( + () => StockConfig.parse('{"public_key":"aabb"}'), + throwsA( + isA().having( + (e) => e.path, + 'path', + 'public_key', + ), + ), + ); + }); + + test('a numeric coordinate is rejected, since stock writes strings', () { + expect( + () => StockConfig.parse( + '{"position_settings":{"latitude":39.5,"longitude":-84.6}}', + ), + throwsA( + isA().having( + (e) => e.path, + 'path', + 'position_settings.latitude', + ), + ), + ); + }); + + test('channels given as an object rather than an array is rejected', () { + expect( + () => StockConfig.parse('{"channels":{}}'), + throwsA( + isA().having( + (e) => e.path, + 'path', + 'channels', + ), + ), + ); + }); + + test('the reported path points at the offending contact', () { + final json = { + 'contacts': [contactJson(), contactJson(publicKey: 'aabb')], + }; + + expect( + () => StockConfig.parse(jsonEncode(json)), + throwsA( + isA().having( + (e) => e.path, + 'path', + 'contacts[1].public_key', + ), + ), + ); + }); + }); +}