diff --git a/mobile/lib/shared/identity_names/identity_name_policy.dart b/mobile/lib/shared/identity_names/identity_name_policy.dart index 62d09438b1..d04c0fb0e6 100644 --- a/mobile/lib/shared/identity_names/identity_name_policy.dart +++ b/mobile/lib/shared/identity_names/identity_name_policy.dart @@ -80,10 +80,8 @@ String trimIdentityName(String value) { return value.substring(start, end); } -final RegExp _hexKey = RegExp(r'^[0-9a-fA-F]{64}$'); - void _requireKey(String key, String name) { - if (!_hexKey.hasMatch(key)) { + if (!isHexPubkey(key)) { throw ArgumentError.value(key, name, 'not a 64-character hex public key'); } } diff --git a/mobile/lib/shared/identity_names/identity_names.dart b/mobile/lib/shared/identity_names/identity_names.dart index 3b4a687bda..57d4af0a6e 100644 --- a/mobile/lib/shared/identity_names/identity_names.dart +++ b/mobile/lib/shared/identity_names/identity_names.dart @@ -4,8 +4,6 @@ import '../profile/user_profile.dart'; import '../utils/string_utils.dart'; import 'identity_name_policy.dart'; -final RegExp _hexPubkey = RegExp(r'^[0-9a-f]{64}$'); - /// Immutable naming facts known to this client, independent of any view. /// /// Mobile's owner hint is the verified NIP-OA owner from the profile `auth` @@ -44,7 +42,7 @@ class IdentityNameSources { bool isAgent = false, }) { final key = pubkey.toLowerCase(); - if (!_hexPubkey.hasMatch(key)) return null; + if (!isHexPubkey(key)) return null; final profile = profiles[key]; final owner = (profile?.ownerPubkey ?? agentOwners[key] ?? fallbackOwner) ?.toLowerCase(); @@ -57,7 +55,7 @@ class IdentityNameSources { _nonBlank(fallbackName) ?? shortPubkey(key), isAgent: isAgent || owner != null || agentPubkeys.contains(key), - ownerPubkey: owner != null && _hexPubkey.hasMatch(owner) ? owner : null, + ownerPubkey: owner != null && isHexPubkey(owner) ? owner : null, ); } @@ -85,7 +83,7 @@ class IdentityNameSources { this, { for (final key in candidates) - if (_hexPubkey.hasMatch(key.toLowerCase())) key.toLowerCase(), + if (isHexPubkey(key.toLowerCase())) key.toLowerCase(), }, {for (final key in agentPubkeys) key.toLowerCase()}, { @@ -112,6 +110,7 @@ class IdentityNames { final Map _fallbackNames; final Map _ownerPubkeys; final Map _outside = {}; + final Map _facts = {}; late final Map _members = _resolve(_candidates); IdentityNames._( @@ -151,11 +150,16 @@ class IdentityNames { .where((owner) => !_sources.profiles.containsKey(owner)) .toSet(); - NamingIdentity? _factFor(String key) => _sources.factFor( + /// Facts are fixed for this scope's sources, so each key is derived once + /// even though owner loading and resolution both read the whole context. + NamingIdentity? _factFor(String key) => _facts.putIfAbsent( key, - fallbackName: _fallbackNames[key], - fallbackOwner: _ownerPubkeys[key], - isAgent: _agentPubkeys.contains(key), + () => _sources.factFor( + key, + fallbackName: _fallbackNames[key], + fallbackOwner: _ownerPubkeys[key], + isAgent: _agentPubkeys.contains(key), + ), ); /// The resolved label and qualifier for [pubkey], or null for an invalid key. @@ -163,7 +167,7 @@ class IdentityNames { final key = pubkey.toLowerCase(); if (_candidates.contains(key)) return _members[key]; return _outside.putIfAbsent(key, () { - if (!_hexPubkey.hasMatch(key)) return null; + if (!isHexPubkey(key)) return null; return _resolve({..._candidates, key})[key]; }); } diff --git a/mobile/lib/shared/utils/string_utils.dart b/mobile/lib/shared/utils/string_utils.dart index b9fb720b51..df9fa2c238 100644 --- a/mobile/lib/shared/utils/string_utils.dart +++ b/mobile/lib/shared/utils/string_utils.dart @@ -4,7 +4,24 @@ import 'package:nostr/nostr.dart' as nostr; /// public key — malformed identities never leak truncated raw hex into the UI. const String unknownIdentityLabel = 'Unknown identity'; -final RegExp _hexPubkeyPattern = RegExp(r'^[0-9a-fA-F]{64}$'); +/// Whether [value] is a 64-character hex public key (any case). +/// +/// A character loop rather than a regular expression: AOT builds on iOS run +/// regular expressions in an interpreter, and identity views check every key +/// of a large roster on each rebuild. +bool isHexPubkey(String value) { + if (value.length != 64) return false; + for (var i = 0; i < 64; i++) { + final c = value.codeUnitAt(i); + if (!((c >= 0x30 && c <= 0x39) || // 0-9 + (c >= 0x61 && c <= 0x66) || // a-f + (c >= 0x41 && c <= 0x46))) { + // A-F + return false; + } + } + return true; +} /// The canonical full NIP-19 npub for [identity], or null when [identity] is /// not a valid public key. @@ -18,12 +35,12 @@ final RegExp _hexPubkeyPattern = RegExp(r'^[0-9a-fA-F]{64}$'); String? fullNpub(String identity) { final trimmed = identity.trim(); try { - if (_hexPubkeyPattern.hasMatch(trimmed)) { + if (isHexPubkey(trimmed)) { return _encodeNpub(trimmed.toLowerCase()); } final decoded = nostr.Nip19.decode(payload: trimmed); if (decoded.prefix != nostr.Nip19Prefix.npub || - !_hexPubkeyPattern.hasMatch(decoded.data)) { + !isHexPubkey(decoded.data)) { return null; } return _encodeNpub(decoded.data.toLowerCase()); diff --git a/mobile/test/shared/identity_names/identity_names_test.dart b/mobile/test/shared/identity_names/identity_names_test.dart index 8d12c39dbf..c2bcb30729 100644 --- a/mobile/test/shared/identity_names/identity_names_test.dart +++ b/mobile/test/shared/identity_names/identity_names_test.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; @@ -223,4 +225,53 @@ void main() { // The owner is the viewer, whose profile is cached: nothing to load. expect(names.missingOwnerProfiles(), isEmpty); }); + + test('a scope derives the naming facts of each key once', () { + final keys = [for (final d in '1234'.split('')) _key(d)]; + final profiles = _CountingProfiles({ + for (final key in keys) key: UserProfile(pubkey: key, displayName: key), + }); + final names = IdentityNameSources(profiles: profiles).scope(keys); + + // Owner loading, every label, and an outside key all read the whole + // context; each member's profile is still read once. + names.missingOwnerProfiles(); + for (final key in keys) { + names.labelFor(key); + } + names.labelFor(_key('9')); + + for (final key in keys) { + expect(profiles.reads[key], 1, reason: key); + } + }); +} + +/// A profile map that counts lookups per key. +class _CountingProfiles extends MapBase { + _CountingProfiles(this._inner); + + final Map _inner; + final Map reads = {}; + + @override + UserProfile? operator [](Object? key) { + if (key is String) reads[key] = (reads[key] ?? 0) + 1; + return _inner[key]; + } + + @override + void operator []=(String key, UserProfile value) => _inner[key] = value; + + @override + void clear() => _inner.clear(); + + @override + Iterable get keys => _inner.keys; + + @override + UserProfile? remove(Object? key) => _inner.remove(key); + + @override + bool containsKey(Object? key) => _inner.containsKey(key); } diff --git a/mobile/test/shared/utils/string_utils_test.dart b/mobile/test/shared/utils/string_utils_test.dart index f5188c2a68..d11754d52d 100644 --- a/mobile/test/shared/utils/string_utils_test.dart +++ b/mobile/test/shared/utils/string_utils_test.dart @@ -77,6 +77,43 @@ void main() { }); }); + group('isHexPubkey', () { + test('accepts exactly 64 hex characters in either case', () { + expect(isHexPubkey(canonicalHex), isTrue); + expect(isHexPubkey(canonicalHex.toUpperCase()), isTrue); + expect(isHexPubkey('0' * 64), isTrue); + expect(isHexPubkey('F' * 64), isTrue); + }); + + test('rejects other lengths and characters', () { + expect(isHexPubkey(''), isFalse); + expect(isHexPubkey(canonicalHex.substring(1)), isFalse); + expect(isHexPubkey('${canonicalHex}0'), isFalse); + expect(isHexPubkey(' ${canonicalHex.substring(1)}'), isFalse); + // Characters just outside each accepted range. + for (final c in ['/', ':', '@', 'G', '`', 'g', 'n']) { + expect(isHexPubkey('$c${'0' * 63}'), isFalse, reason: c); + } + expect(isHexPubkey(canonicalNpub), isFalse); + }); + + test('rejects an invalid character at any position', () { + // Every index, so a check that only inspects some positions fails. + for (final c in ['g', 'G', ':', '/', ' ']) { + for (var i = 0; i < 64; i++) { + final value = '${'a' * i}$c${'b' * (63 - i)}'; + expect(isHexPubkey(value), isFalse, reason: '"$c" at index $i'); + } + } + // Middle and final positions of an otherwise valid key. + final middle = canonicalHex.replaceRange(32, 33, 'z'); + final last = canonicalHex.replaceRange(63, 64, 'z'); + expect(isHexPubkey(middle), isFalse); + expect(isHexPubkey(last), isFalse); + expect(isHexPubkey(canonicalHex), isTrue); + }); + }); + group('copy to paste roundtrip', () { test('a copied npub parses back to the original hex key', () { final copied = fullNpub(canonicalHex);