-
Notifications
You must be signed in to change notification settings - Fork 7
Claim sent messages per-row and log thread validation failures #102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,3 +23,22 @@ func singleParticipantAddress(_ threadID: String) -> String? { | |
| func threadIDIsForGroup(_ id: String) -> Bool { | ||
| splitThreadID(id).map { $0.1 == MessagesDeepLink.groupThreadType } == true | ||
| } | ||
|
|
||
| // suffix match so "+15035550123" pairs with "5035550123"; require enough | ||
| // digits that shortcodes can't collide with a full number's tail | ||
| private let minimumPhoneSuffixDigits = 7 | ||
|
|
||
| // Messages merges conversations per contact, so a send targeted at one chat can | ||
| // land in a sibling chat whose address is the same recipient with different | ||
| // formatting (email casing, or a phone with/without the country prefix). This | ||
| // runs before the contacts-database comparison, which can't see such pairs when | ||
| // the sent handle isn't on the contact card. | ||
| func addressesReferToSameRecipient(_ a: String?, _ b: String?) -> Bool { | ||
| guard let a, let b, !a.isEmpty, !b.isEmpty else { return false } | ||
| if a.caseInsensitiveCompare(b) == .orderedSame { return true } | ||
| guard !a.contains("@"), !b.contains("@") else { return false } | ||
| let digitsA = a.filter(\.isNumber) | ||
| let digitsB = b.filter(\.isNumber) | ||
| guard min(digitsA.count, digitsB.count) >= minimumPhoneSuffixDigits else { return false } | ||
| return digitsA.hasSuffix(digitsB) || digitsB.hasSuffix(digitsA) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Latent: digit-only comparison can alias group chat GUIDs with 1-1 phone numbers
Real Messages chat GUIDs are long and effectively random, so the practical collision odds are tiny, but the helper accepts any string pair and doesn't distinguish thread type. If you want to defensively gate it, consider bailing when the input looks like a group chat GUID (e.g., |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| @testable import IMessage | ||
| import Testing | ||
|
|
||
| @Test func addressesReferToSameRecipientMatchesFormattingVariants() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Move SwiftLint flags all eight test declarations because the Example fix (apply to each flagged declaration)-@Test func addressesReferToSameRecipientMatchesFormattingVariants() {
+@Test
+func addressesReferToSameRecipientMatchesFormattingVariants() {Also applies to: 11-11, 25-25, 35-35, 48-48, 58-58, 69-69, 79-79 🧰 Tools🪛 SwiftLint (0.65.0)[Warning] 4-4: Attributes should be on their own lines in functions and types, but on the same line as variables and imports (attributes) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| #expect(addressesReferToSameRecipient("+15035550123", "+15035550123")) | ||
| #expect(addressesReferToSameRecipient("+15035550123", "5035550123")) | ||
| #expect(addressesReferToSameRecipient("5035550123", "+1 (503) 555-0123")) | ||
| #expect(addressesReferToSameRecipient("a@example.com", "A@Example.com")) | ||
| } | ||
|
|
||
| @Test func addressesReferToSameRecipientRejectsDifferentRecipients() { | ||
| #expect(!addressesReferToSameRecipient("+15035550123", "+15035550124")) | ||
| #expect(!addressesReferToSameRecipient("a@example.com", "b@example.com")) | ||
| // emails must match exactly, not by digit content | ||
| #expect(!addressesReferToSameRecipient("5550123@example.com", "5550123")) | ||
| // shortcodes may not tail-match a full number | ||
| #expect(!addressesReferToSameRecipient("50123", "+15035550123")) | ||
| #expect(!addressesReferToSameRecipient(nil, "+15035550123")) | ||
| #expect(!addressesReferToSameRecipient("", "")) | ||
| } | ||
|
|
||
| private let sentMessage = (rowID: 1, guid: "AAAAAAAA-0000-0000-0000-000000000001") | ||
| private let otherSentMessage = (rowID: 2, guid: "AAAAAAAA-0000-0000-0000-000000000002") | ||
|
|
||
| @Test func validSentMessageIDsAcceptsExactThreadMatch() { | ||
| let valid = PlatformAPI.validSentMessageIDs( | ||
| targetThreadID: "iMessage;-;+15035550123", | ||
| sentMessageIDs: [sentMessage], | ||
| sentThreadIDs: ["iMessage;-;+15035550123"], | ||
| isSameContact: { _, _ in false } | ||
| ) | ||
| #expect(valid.map(\.rowID) == [1]) | ||
| } | ||
|
|
||
| @Test func validSentMessageIDsAcceptsServiceAndFormattingFlips() { | ||
| // same recipient under a different service or handle formatting must not | ||
| // require the contacts database (DESK-28166: isSameContact false-negative | ||
| // left the client's echo stuck on "still sending") | ||
| let valid = PlatformAPI.validSentMessageIDs( | ||
| targetThreadID: "iMessage;-;+15035550123", | ||
| sentMessageIDs: [sentMessage], | ||
| sentThreadIDs: ["SMS;-;5035550123"], | ||
| isSameContact: { _, _ in false } | ||
| ) | ||
| #expect(valid.map(\.rowID) == [1]) | ||
| } | ||
|
|
||
| @Test func validSentMessageIDsFallsBackToContacts() { | ||
| let valid = PlatformAPI.validSentMessageIDs( | ||
| targetThreadID: "iMessage;-;a@example.com", | ||
| sentMessageIDs: [sentMessage], | ||
| sentThreadIDs: ["iMessage;-;+15035550123"], | ||
| isSameContact: { a, b in a == "a@example.com" && b == "+15035550123" } | ||
| ) | ||
| #expect(valid.map(\.rowID) == [1]) | ||
| } | ||
|
|
||
| @Test func validSentMessageIDsDropsForeignRowsIndividually() { | ||
| // a concurrent send to somebody else must not discard the whole batch | ||
| let valid = PlatformAPI.validSentMessageIDs( | ||
| targetThreadID: "iMessage;-;+15035550123", | ||
| sentMessageIDs: [sentMessage, otherSentMessage], | ||
| sentThreadIDs: ["iMessage;-;+15035550123", "iMessage;-;+19995550000"], | ||
| isSameContact: { _, _ in false } | ||
| ) | ||
| #expect(valid.map(\.rowID) == [1]) | ||
| } | ||
|
|
||
| @Test func validSentMessageIDsDropsUnjoinedRows() { | ||
| let valid = PlatformAPI.validSentMessageIDs( | ||
| targetThreadID: "iMessage;-;+15035550123", | ||
| sentMessageIDs: [sentMessage, otherSentMessage], | ||
| sentThreadIDs: [nil, "iMessage;-;+15035550123"], | ||
| isSameContact: { _, _ in false } | ||
| ) | ||
| #expect(valid.map(\.rowID) == [2]) | ||
| } | ||
|
|
||
| @Test func validSentMessageIDsReturnsEmptyWhenNothingMatches() { | ||
| let valid = PlatformAPI.validSentMessageIDs( | ||
| targetThreadID: "iMessage;-;+15035550123", | ||
| sentMessageIDs: [sentMessage], | ||
| sentThreadIDs: ["iMessage;-;+19995550000"], | ||
| isSameContact: { _, _ in false } | ||
| ) | ||
| #expect(valid.isEmpty) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Sentry will fire for legitimate concurrent multi-device sends
waitForSentMessageIDsmatches by text androwID > lastRowID, so a second Apple device sending the same body around the same time will produce a foreign row that we now correctly drop — but this branch will stillreportErrorMessageavalidation failedcount to Sentry.Per the commit message this reporting is intentional ("so future rageshakes show which mismatch flavor occurred"). Just calling it out in case the Sentry volume from multi-device users turns out noisier than expected — you may want to keep the hashed log line but demote the Sentry report to a rarer trigger (e.g., only when no row validated, or throttled).