commit 8d6844130f52fa859b24500dc84ccc7c581fa45a
parent 70dee145ff565848cf0e5c112a919c982ab7042d
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 08:04:19 +0900
Cap invite fast-accept puzzle sources
This commit stops direct invites from trusting an oversized puzzleSource
payload before the shared zone has been fetched. Friend-zone invites
still carry ordinary puzzle data for fast acceptance, but empty or
over-limit sources are now treated as absent so acceptance falls back to
the canonical Game record.
The same gate is used when sending invites, storing inbound invite rows,
and reading cached invite data during acceptance. This keeps the fast
path aligned with XD.maxSourceBytes and preserves the existing fetch
fallback for older or uncapped invites.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 32 insertions(+), 8 deletions(-)
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -101,7 +101,7 @@ final class InviteCoordinator {
// zone fetch. Everything the recipient's GameEntity needs derives from
// this source (as in `GameStore.createGame`); the canonical Game record
// then merges in as a background update.
- let puzzleSource = game?.puzzleSource
+ let puzzleSource = Self.fastAcceptPuzzleSource(game?.puzzleSource)
// Make sure the pair's mailbox handshake has completed before mutating
// the game share. Otherwise the friend can be added as a participant but
@@ -224,7 +224,7 @@ final class InviteCoordinator {
invite.inviterName = ping.playerName
invite.shareURL = payload.gameShareURL
invite.gridSilhouette = payload.gridSilhouette
- invite.puzzleSource = payload.puzzleSource
+ invite.puzzleSource = Self.fastAcceptPuzzleSource(payload.puzzleSource)
invite.notification = payload.notification
invite.pingRecordName = ping.recordName
invite.status = "pending"
@@ -349,9 +349,13 @@ final class InviteCoordinator {
let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName)
req.fetchLimit = 1
- guard let source = (try? ctx.fetch(req))?.first?.puzzleSource, !source.isEmpty else {
- return nil
- }
+ let source = (try? ctx.fetch(req))?.first?.puzzleSource
+ return Self.fastAcceptPuzzleSource(source)
+ }
+
+ nonisolated static func fastAcceptPuzzleSource(_ source: String?) -> String? {
+ guard let source, !source.isEmpty else { return nil }
+ guard source.utf8.count <= XD.maxSourceBytes else { return nil }
return source
}
diff --git a/Crossmate/Sync/FriendZone.swift b/Crossmate/Sync/FriendZone.swift
@@ -128,9 +128,10 @@ enum FriendZone {
/// The puzzle's full XD source. The recipient already syncs the friend
/// zone, so this arrives with the Ping and lets the accept path build a
/// playable game immediately — the shared-zone fetch then only updates
- /// it. Even an oversized Sunday is a few KB, well under CloudKit's
- /// per-record limit, and `payload` is not an indexed field. `nil` for
- /// invites from older senders, which fall back to the fetch.
+ /// it. Recipients discard this fast-accept source when it exceeds
+ /// `XD.maxSourceBytes`, falling back to the canonical shared-zone
+ /// fetch. `nil` for invites from older senders, which fall back to the
+ /// fetch.
let puzzleSource: String?
/// The owner's shared game notification credential. Direct invites can
/// build a playable game before the shared-zone Game record has synced;
diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift
@@ -327,4 +327,23 @@ struct FriendModelTests {
let ping = declinePing(authorID: "_bob", addressee: owner, sourceZoneName: "")
#expect(!InviteCoordinator.isAuthenticDeclinePing(ping, ownerAuthorID: owner))
}
+
+ // MARK: - M12: invite fast-accept source cap
+
+ @Test("invite fast-accept source keeps ordinary puzzle data")
+ func fastAcceptPuzzleSourceKeepsOrdinarySource() {
+ let source = "Title: Fast\n\n\nAB\n\n\nA1. _ ~ AB"
+ #expect(InviteCoordinator.fastAcceptPuzzleSource(source) == source)
+ }
+
+ @Test("invite fast-accept source drops empty and oversized payloads")
+ func fastAcceptPuzzleSourceDropsEmptyAndOversizedSource() {
+ let exactCap = String(repeating: "A", count: XD.maxSourceBytes)
+ let oversized = String(repeating: "A", count: XD.maxSourceBytes + 1)
+
+ #expect(InviteCoordinator.fastAcceptPuzzleSource(nil) == nil)
+ #expect(InviteCoordinator.fastAcceptPuzzleSource("") == nil)
+ #expect(InviteCoordinator.fastAcceptPuzzleSource(exactCap) == exactCap)
+ #expect(InviteCoordinator.fastAcceptPuzzleSource(oversized) == nil)
+ }
}