fix(mobile): treat push target IDs as opaque

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-08-20 13:53:29 -07:00
parent 669d86c081
commit 8e7f5dc32b
4 changed files with 51 additions and 80 deletions

View File

@@ -10,9 +10,9 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
public let channelID: String
public init(eventID: String, communityID: String, channelID: String) {
self.eventID = eventID.lowercased()
self.eventID = eventID
self.communityID = communityID
self.channelID = channelID.lowercased()
self.channelID = channelID
}
public var userInfoValue: [String: String] {
@@ -32,9 +32,9 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
let eventID = raw["event_id"] as? String,
let communityID = raw["community_id"] as? String,
let channelID = raw["channel_id"] as? String,
!eventID.isEmpty,
!communityID.isEmpty,
isLowercaseHex64(eventID.lowercased()),
isChannelID(channelID.lowercased())
!channelID.isEmpty
else {
return nil
}
@@ -45,24 +45,6 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
)
}
private static func isLowercaseHex64(_ value: String) -> Bool {
value.utf8.count == 64
&& value.utf8.allSatisfy {
($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102)
}
}
private static func isChannelID(_ value: String) -> Bool {
let bytes = Array(value.utf8)
guard bytes.count == 36,
bytes[8] == 45, bytes[13] == 45, bytes[18] == 45, bytes[23] == 45,
[56, 57, 97, 98].contains(bytes[19])
else { return false }
return bytes.enumerated().allSatisfy { index, byte in
if [8, 13, 18, 23].contains(index) { return byte == 45 }
return (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
}
}
}
/// Thread-safe one-item buffer spanning notification delivery and Flutter

View File

@@ -3,11 +3,11 @@ import Testing
@testable import BuzzPushKit
@Test func `Round-trip UUIDv4 navigation target through notification user info`() {
@Test func `Round-trip opaque navigation target through notification user info`() {
let target = BuzzPushNavigationTarget(
eventID: String(repeating: "A", count: 64),
eventID: "MESSAGE-ID",
communityID: "community-id",
channelID: "123E4567-E89B-42D3-A456-426614174000"
channelID: "CHANNEL/GENERAL"
)
#expect(
@@ -15,23 +15,8 @@ import Testing
from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue]
) == target
)
#expect(target.eventID == String(repeating: "a", count: 64))
#expect(target.channelID == "123e4567-e89b-42d3-a456-426614174000")
}
@Test func `Decode UUIDv5 navigation target`() {
let target = BuzzPushNavigationTarget(
eventID: String(repeating: "B", count: 64),
communityID: "community-id",
channelID: "9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50"
)
#expect(
BuzzPushNavigationTarget.decodeIfPresent(
from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue]
) == target
)
#expect(target.channelID == "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50")
#expect(target.eventID == "MESSAGE-ID")
#expect(target.channelID == "CHANNEL/GENERAL")
}
@Test func `Reject incomplete or malformed navigation target`() {
@@ -39,7 +24,7 @@ import Testing
BuzzPushNavigationTarget.decodeIfPresent(
from: [
BuzzPushNavigationTarget.userInfoKey: [
"event_id": "event-id",
"event_id": "message-id",
"community_id": "community-id",
]
]
@@ -50,9 +35,9 @@ import Testing
BuzzPushNavigationTarget.decodeIfPresent(
from: [
BuzzPushNavigationTarget.userInfoKey: [
"event_id": String(repeating: "a", count: 64),
"event_id": "",
"community_id": "community-id",
"channel_id": "not-a-channel",
"channel_id": "channel-id",
]
]
) == nil
@@ -62,9 +47,9 @@ import Testing
BuzzPushNavigationTarget.decodeIfPresent(
from: [
BuzzPushNavigationTarget.userInfoKey: [
"event_id": String(repeating: "a", count: 64),
"event_id": "message-id",
"community_id": "community-id",
"channel_id": "9a1657ac-f7aa-5db0-7632-d8bbeb6dfb50",
"channel_id": "",
]
]
) == nil

View File

@@ -24,32 +24,23 @@ final pushEndpointGrantError = ValueNotifier<String?>(null);
/// notifier also carries warm responses into the existing deep-link pipeline.
final pendingPushNotificationLink = ValueNotifier<MessageDeepLink?>(null);
final _pushEventIdPattern = RegExp(r'^[0-9a-f]{64}$');
final _pushChannelIdPattern = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
);
MessageDeepLink? _pushNotificationLink(Object? arguments) {
if (arguments is! Map) return null;
final eventId = arguments['eventId'];
final communityId = arguments['communityId'];
final channelId = arguments['channelId'];
final normalizedEventId = eventId is String ? eventId.toLowerCase() : '';
final normalizedChannelId = channelId is String
? channelId.toLowerCase()
: '';
if (eventId is! String ||
!_pushEventIdPattern.hasMatch(normalizedEventId) ||
eventId.isEmpty ||
communityId is! String ||
communityId.isEmpty ||
channelId is! String ||
!_pushChannelIdPattern.hasMatch(normalizedChannelId)) {
channelId.isEmpty) {
return null;
}
return MessageDeepLink(
communityId: communityId,
channelId: normalizedChannelId,
messageId: normalizedEventId,
channelId: channelId,
messageId: eventId,
);
}

View File

@@ -150,16 +150,16 @@ void main() {
expect(apnsRegistrationError.value, 'denied');
});
test('routes a warm notification response with a UUIDv4 channel', () async {
test('routes a warm notification response with opaque IDs', () async {
final response = Completer<ByteData?>();
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
_channel.name,
_channel.codec.encodeMethodCall(
MethodCall('notificationOpened', {
'eventId': 'a' * 64,
'eventId': 'MESSAGE-ID',
'communityId': 'community-id',
'channelId': '123e4567-e89b-42d3-a456-426614174000',
'channelId': 'CHANNEL/GENERAL',
}),
),
response.complete,
@@ -172,22 +172,22 @@ void main() {
pendingPushNotificationLink.value,
MessageDeepLink(
communityId: 'community-id',
channelId: '123e4567-e89b-42d3-a456-426614174000',
messageId: 'a' * 64,
channelId: 'CHANNEL/GENERAL',
messageId: 'MESSAGE-ID',
),
);
});
test('routes a warm notification response with a UUIDv5 channel', () async {
test('rejects a notification response with an empty channel ID', () async {
final response = Completer<ByteData?>();
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
_channel.name,
_channel.codec.encodeMethodCall(
MethodCall('notificationOpened', {
'eventId': 'b' * 64,
'eventId': 'message-id',
'communityId': 'community-id',
'channelId': '9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50',
'channelId': '',
}),
),
response.complete,
@@ -195,19 +195,12 @@ void main() {
final envelope = await response.future;
expect(envelope, isNotNull);
expect(_channel.codec.decodeEnvelope(envelope!), 'handled');
expect(
pendingPushNotificationLink.value,
MessageDeepLink(
communityId: 'community-id',
channelId: '9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50',
messageId: 'b' * 64,
),
);
expect(_channel.codec.decodeEnvelope(envelope!), 'ignored');
expect(pendingPushNotificationLink.value, isNull);
});
test(
'rejects a notification response with a malformed channel UUID',
'rejects a notification response with a non-string message ID',
() async {
final response = Completer<ByteData?>();
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -215,9 +208,9 @@ void main() {
_channel.name,
_channel.codec.encodeMethodCall(
MethodCall('notificationOpened', {
'eventId': 'c' * 64,
'eventId': 42,
'communityId': 'community-id',
'channelId': '9a1657ac-f7aa-5db0-7632-d8bbeb6dfb50',
'channelId': 'channel-id',
}),
),
response.complete,
@@ -230,6 +223,26 @@ void main() {
},
);
test('rejects a notification response with an absent message ID', () async {
final response = Completer<ByteData?>();
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
_channel.name,
_channel.codec.encodeMethodCall(
const MethodCall('notificationOpened', {
'communityId': 'community-id',
'channelId': 'channel-id',
}),
),
response.complete,
);
final envelope = await response.future;
expect(envelope, isNotNull);
expect(_channel.codec.decodeEnvelope(envelope!), 'ignored');
expect(pendingPushNotificationLink.value, isNull);
});
test('pulls a cold-start notification response from native iOS', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger