crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 616df25068762910884dc8bb94e67d6edc05d961
parent 6634e575133a1cefe331300c3ffa01a66e394b1d
Author: Michael Camilleri <[email protected]>
Date:   Sat, 18 Jul 2026 08:32:52 +0900

Bound puzzle file and external provider response ingress

Every filesystem puzzle ingress could materialise an unbounded file
before rejection: the open-URL importer checked fileSizeKey but fell
through to an unchecked read when no size was reported, the Imported tab
read a file's complete contents before XD parsing or PUZ conversion, and
the Files picker copied its selection into the user's iCloud folder with
no validation at all. The external provider fetcher had the matching
response boundary — URLSession buffered the whole body before any
content-type or size check. A hostile file provider, a large file
dropped into the Crossmate folder, or an unexpected server response
could exhaust memory before the XD source cap ever ran.

This commit routes every local read through a new PuzzleFileReader,
which streams through a FileHandle up to one byte past the cap and never
consults the reported size, so an absent size can no longer bypass the
bound. Raw .puz bytes get their own cap —
PUZToXDConverter.maxSourceBytes, enforced inside convert itself so no
call site can skip it — because a .puz can hide a large ignored tail
behind a small grid that would satisfy the XD cap after conversion. The
Files-picker copy now bounds and parses its selection before it lands in
the iCloud folder, rather than copying an arbitrary blob and discovering
the problem on open.

The external provider download switches from data(for:) to a byte
stream: a supplied non-JSON Content-Type is refused before the body is
buffered, a known Content-Length over the 2 MB budget fails before
reading, and an unknown length streams against the same cap, cancelling
the transfer as soon as it is exceeded.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 20++++++++++++++++++++
MCrossmate/Services/DriveMonitor.swift | 14++++++--------
MCrossmate/Services/ImportService.swift | 25++++---------------------
MCrossmate/Services/NYTPuzzleFetcher.swift | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCrossmate/Services/PUZToXDConverter.swift | 10++++++++++
ACrossmate/Services/PuzzleFileReader.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/DriveMonitorTests.swift | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/ImportServiceTests.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/NYTPuzzleFetcherTests.swift | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/PUZToXDConverterTests.swift | 25+++++++++++++++++++++++++
ATests/Unit/PuzzleFileReaderTests.swift | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 494 insertions(+), 30 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; 0F2992C16A3A658DEA0F707E /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCACEED6A9235EC6221F4F66 /* DiagnosticsView.swift */; }; 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */; }; + 10AF4F2750E0D64AA3828F35 /* ImportServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4494C40DD593DA7CE6641027 /* ImportServiceTests.swift */; }; 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */; }; 13C0F34520828020AD825D07 /* JoiningPuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */; }; 14749A042380925B7CA902F2 /* XDMarkup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF18C52558DBD58ECAD4964 /* XDMarkup.swift */; }; @@ -124,6 +125,7 @@ 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D491B7232333AA8957732387 /* PendingEditFlagTests.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; + 82D1714DC441E4943E96C882 /* PuzzleFileReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9EE11280592CA66C2BEC71A /* PuzzleFileReaderTests.swift */; }; 83CF8376EBFD7271931AC1B7 /* GridAccessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4CA70D10760D6770907606 /* GridAccessibility.swift */; }; 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */; }; 85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */; }; @@ -133,6 +135,7 @@ 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; }; 8AE376C0726116082B15241D /* ShareLinkRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */; }; 8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; }; + 8C37D748EBFD75DACCB992C3 /* DriveMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29686FFBC5BC8BCCF8CCC49A /* DriveMonitorTests.swift */; }; 8D8A9F70731C98DD00BE1DA5 /* Layouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836B8D4B351C9225162A82C0 /* Layouts.swift */; }; 8ECA254A8BB852BF4B9CE6D7 /* MarketingScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBEE60E9D850739CA3D01AA /* MarketingScreenshots.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; @@ -154,6 +157,7 @@ 9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */; }; 9C52C48DB4996D5C83DEC144 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B1734CF731C2E405A39159 /* PuzzleView.swift */; }; 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; }; + 9DDFDF9D97FFC4F043CE84D8 /* PuzzleFileReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A1BF1EBD7D9E4584EF5FE /* PuzzleFileReader.swift */; }; 9DFE90A6D7C58823CA02CA4E /* InboundRecordIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C434DB97DE2FBD8BEB867C /* InboundRecordIdentityTests.swift */; }; 9FFD01CF6767220EEA20C0E4 /* GamePushCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78919F44C3035C48410FC894 /* GamePushCredentials.swift */; }; A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; @@ -209,6 +213,7 @@ D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD63A9B20168F3B81AF4348F /* RecordApplier.swift */; }; DAD7EA11DA7330773A485473 /* PeerChangeLedgerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D243575E32A8663B1AAF492A /* PeerChangeLedgerTests.swift */; }; DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */; }; + DCAA1C4A4E4943675EC6632A /* NYTPuzzleFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0CCED43B365363662C45A30 /* NYTPuzzleFetcherTests.swift */; }; DDC7994B951A3A7B836B36F6 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A62DA6F7138876CA5A27EF /* SuccessPanel.swift */; }; DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */; }; DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; @@ -308,6 +313,7 @@ 275DB12E9D81DD48B9025718 /* GameSyncVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSyncVersion.swift; sourceTree = "<group>"; }; 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; + 29686FFBC5BC8BCCF8CCC49A /* DriveMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitorTests.swift; sourceTree = "<group>"; }; 298A9C54A1CC753E860E174E /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = "<group>"; }; 2A832061C19BA0F073617CA2 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; @@ -329,6 +335,7 @@ 41A62DA6F7138876CA5A27EF /* SuccessPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessPanel.swift; sourceTree = "<group>"; }; 43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; }; 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerMovesTests.swift; sourceTree = "<group>"; }; + 4494C40DD593DA7CE6641027 /* ImportServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportServiceTests.swift; sourceTree = "<group>"; }; 44F86F0F1883A93F9622FB67 /* CloudZones.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudZones.swift; sourceTree = "<group>"; }; 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentityTests.swift; sourceTree = "<group>"; }; 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPreferences.swift; sourceTree = "<group>"; }; @@ -417,6 +424,7 @@ 9F13534332699C80382FE682 /* CellPatterns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellPatterns.swift; sourceTree = "<group>"; }; 9F6DB4116933BB66274AC6E8 /* XDLimitsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDLimitsTests.swift; sourceTree = "<group>"; }; 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FriendEntity+DisplayName.swift"; sourceTree = "<group>"; }; + A0CCED43B365363662C45A30 /* NYTPuzzleFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcherTests.swift; sourceTree = "<group>"; }; A24FD313982C8094CD020106 /* EngagementMessageAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementMessageAuthenticatorTests.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleScoreboard.swift; sourceTree = "<group>"; }; @@ -472,6 +480,7 @@ D243575E32A8663B1AAF492A /* PeerChangeLedgerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChangeLedgerTests.swift; sourceTree = "<group>"; }; D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + D9EE11280592CA66C2BEC71A /* PuzzleFileReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleFileReaderTests.swift; sourceTree = "<group>"; }; DAD36DEDB3D997AB47146EA9 /* AppActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActions.swift; sourceTree = "<group>"; }; DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; }; @@ -491,6 +500,7 @@ ED2D830B9EFAD753C233BEB4 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; }; ED48AD9C3A7A113D101BBD21 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthServiceTests.swift; sourceTree = "<group>"; }; + EE1A1BF1EBD7D9E4584EF5FE /* PuzzleFileReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleFileReader.swift; sourceTree = "<group>"; }; EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesInboundTests.swift; sourceTree = "<group>"; }; F5DF04E70017065DFA95B396 /* PuzzleModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleModifiers.swift; sourceTree = "<group>"; }; F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitorTests.swift; sourceTree = "<group>"; }; @@ -590,6 +600,7 @@ 99F4D2E1504784E738D2B33A /* AppDefaultsMigratorTests.swift */, B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */, 3059A4C23D040E0D1F258B92 /* CellAccessibilityDescriberTests.swift */, + 29686FFBC5BC8BCCF8CCC49A /* DriveMonitorTests.swift */, 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */, BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */, @@ -601,6 +612,7 @@ 9406C27662147CD3C0783644 /* GameViewedStoreTests.swift */, BF7062403AC9CFB4FF04BBF3 /* GridSilhouetteTests.swift */, 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */, + 4494C40DD593DA7CE6641027 /* ImportServiceTests.swift */, 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */, 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */, C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */, @@ -610,6 +622,7 @@ FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */, 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, + A0CCED43B365363662C45A30 /* NYTPuzzleFetcherTests.swift */, B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */, @@ -626,6 +639,7 @@ E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */, FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */, B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */, + D9EE11280592CA66C2BEC71A /* PuzzleFileReaderTests.swift */, C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */, 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */, 80B377D237AC14B9856579E1 /* RecentChangesTests.swift */, @@ -887,6 +901,7 @@ 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */, FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */, B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */, + EE1A1BF1EBD7D9E4584EF5FE /* PuzzleFileReader.swift */, 710BCB6A647A820B106CE666 /* PuzzleSession.swift */, 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */, 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */, @@ -1038,6 +1053,7 @@ DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */, A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 7BE53C851A311A7B6255B45E /* CellAccessibilityDescriberTests.swift in Sources */, + 8C37D748EBFD75DACCB992C3 /* DriveMonitorTests.swift in Sources */, 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */, 3B3F30AA1B7AA6D4465CB537 /* EngagementMessageAuthenticatorTests.swift in Sources */, 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, @@ -1056,6 +1072,7 @@ 1D08DDEDEF5433912CC6D4DB /* GameViewedStoreTests.swift in Sources */, 085B70680087464B8A7BA3EE /* GridSilhouetteTests.swift in Sources */, AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */, + 10AF4F2750E0D64AA3828F35 /* ImportServiceTests.swift in Sources */, 9DFE90A6D7C58823CA02CA4E /* InboundRecordIdentityTests.swift in Sources */, 0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */, 6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */, @@ -1065,6 +1082,7 @@ F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */, C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */, C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */, + DCAA1C4A4E4943675EC6632A /* NYTPuzzleFetcherTests.swift in Sources */, 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, 6850EAE474E589CE1EA2DF68 /* NicknameDirectoryTests.swift in Sources */, @@ -1089,6 +1107,7 @@ 2A273C98FE3AC5E4C9BE1D88 /* PushPayloadCipherTests.swift in Sources */, A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */, F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */, + 82D1714DC441E4943E96C882 /* PuzzleFileReaderTests.swift in Sources */, 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */, 0A7AEB93A473AFCCD9217F49 /* PuzzleSessionTests.swift in Sources */, 6E36ED34ACF047BABB3E2D69 /* RecentChangesTests.swift in Sources */, @@ -1226,6 +1245,7 @@ 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, ADBEAD1C0139BCF864CA8A1D /* PuzzleCommands.swift in Sources */, + 9DDFDF9D97FFC4F043CE84D8 /* PuzzleFileReader.swift in Sources */, 5264996E0D72A805B20985E8 /* PuzzleGridStats.swift in Sources */, D2AC1D9BD7E387B06B9B8A0E /* PuzzleHeader.swift in Sources */, 082B9BAADE3AFA54EFE30E19 /* PuzzleModifiers.swift in Sources */, diff --git a/Crossmate/Services/DriveMonitor.swift b/Crossmate/Services/DriveMonitor.swift @@ -91,14 +91,7 @@ final class DriveMonitor { coordinator.coordinate(readingItemAt: url, options: [], error: &readError) { readURL in do { - switch readURL.pathExtension.lowercased() { - case "xd": - result = try String(contentsOf: readURL, encoding: .utf8) - case "puz": - result = try PUZToXDConverter.convert(puzData: Data(contentsOf: readURL)) - default: - throw CocoaError(.fileReadUnsupportedScheme) - } + result = try PuzzleFileReader.readSource(at: readURL) } catch { innerError = error } @@ -173,6 +166,11 @@ final class DriveMonitor { error: &coordError ) { readURL, writeURL in do { + // A picked file is untrusted: bound and parse it before it + // lands in the user-visible iCloud folder, rather than + // copying an arbitrary blob and discovering the problem on + // open. + _ = try XD.parse(PuzzleFileReader.readSource(at: readURL)) try FileManager.default.copyItem(at: readURL, to: writeURL) } catch { innerError = error diff --git a/Crossmate/Services/ImportService.swift b/Crossmate/Services/ImportService.swift @@ -20,27 +20,10 @@ final class ImportService { } } - // Reject an oversized file before pulling it into memory or handing it - // to the parser/converter. The parser enforces the same bound on the - // resulting `.xd`, but checking on-disk size first avoids reading a - // large blob at all. A missing size reading falls through to the read, - // which the parser still bounds. - if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, - size > XD.maxSourceBytes { - return nil - } - - let source: String - do { - switch url.pathExtension.lowercased() { - case "xd": - source = try String(contentsOf: url, encoding: .utf8) - case "puz": - source = try PUZToXDConverter.convert(puzData: Data(contentsOf: url)) - default: - return nil - } - } catch { + // Streaming capped read: bounds the bytes pulled into memory even + // when the provider reports no file size (a `fileSizeKey` preflight + // alone would fall through to an unbounded read in that case). + guard let source = try? PuzzleFileReader.readSource(at: url) else { return nil } diff --git a/Crossmate/Services/NYTPuzzleFetcher.swift b/Crossmate/Services/NYTPuzzleFetcher.swift @@ -6,6 +6,13 @@ extension EnvironmentValues { } actor NYTPuzzleFetcher { + /// Upper bound on a puzzle response body. Real daily puzzle JSON is well + /// under 200 KB, so 2 MB rejects nothing genuine while keeping an + /// unexpected server response from buffering unbounded data. Checked + /// against `Content-Length` before reading and enforced again while + /// streaming, since the header is optional. + static let maxResponseBytes = 2_097_152 + private let cookieProvider: @Sendable () -> NYTCookieLoadResult init(cookieProvider: @escaping @Sendable () -> NYTCookieLoadResult) { @@ -25,7 +32,7 @@ actor NYTPuzzleFetcher { var request = URLRequest(url: url) request.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie") - let (data, response) = try await URLSession.shared.data(for: request) + let (bytes, response) = try await URLSession.shared.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw NYTFetchError.invalidResponse @@ -42,10 +49,57 @@ actor NYTPuzzleFetcher { throw NYTFetchError.httpError(statusCode: httpResponse.statusCode) } + // A success from this endpoint is always JSON; anything else (a + // captive portal, an HTML error page served with 200) is not a + // puzzle, so reject it before buffering the body. + guard Self.isJSONContentType(httpResponse.value(forHTTPHeaderField: "Content-Type")) else { + throw NYTFetchError.unexpectedContentType + } + + let data = try await Self.boundedResponseData( + from: bytes, + limit: Self.maxResponseBytes, + expectedLength: httpResponse.expectedContentLength + ) + // Convert NYT JSON to .xd format return try NYTToXDConverter.convert(jsonData: data) } + /// Whether a response `Content-Type` permits JSON. A missing header is + /// accepted (the body still has to survive JSON parsing); a supplied + /// non-JSON type is rejected. + static func isJSONContentType(_ header: String?) -> Bool { + guard let header else { return true } + let mime = header.split(separator: ";").first.map { + $0.trimmingCharacters(in: .whitespaces).lowercased() + } ?? "" + return mime == "application/json" || mime.hasSuffix("+json") + } + + /// Accumulates a response byte stream up to `limit`, failing as soon as + /// the budget is exceeded (throwing drops the stream, which cancels the + /// transfer). A known `expectedLength` over the budget fails before + /// reading at all; an unknown length (-1) streams against the cap. + static func boundedResponseData<S: AsyncSequence>( + from bytes: S, + limit: Int, + expectedLength: Int64 + ) async throws -> Data where S.Element == UInt8 { + guard expectedLength <= Int64(limit) else { + throw NYTFetchError.responseTooLarge + } + var data = Data() + data.reserveCapacity(expectedLength > 0 ? Int(expectedLength) : 0) + for try await byte in bytes { + guard data.count < limit else { + throw NYTFetchError.responseTooLarge + } + data.append(byte) + } + return data + } + private func currentCookie() throws -> String { switch cookieProvider() { case .available(let cookie): @@ -62,6 +116,8 @@ enum NYTFetchError: LocalizedError { case notSignedIn case sessionStatusUnavailable case invalidResponse + case unexpectedContentType + case responseTooLarge case unauthorized case rateLimited case httpError(statusCode: Int) @@ -74,6 +130,10 @@ enum NYTFetchError: LocalizedError { "Crossmate could not determine whether you are signed in to NYT. Unlock the device and try again." case .invalidResponse: "Received an invalid response." + case .unexpectedContentType: + "Received an unexpected response type." + case .responseTooLarge: + "Received an unexpectedly large response." case .unauthorized: "Your NYT session has expired. Sign in again from Settings." case .rateLimited: diff --git a/Crossmate/Services/PUZToXDConverter.swift b/Crossmate/Services/PUZToXDConverter.swift @@ -26,7 +26,17 @@ enum PUZToXDConverter { } } + /// Upper bound on raw `.puz` bytes accepted by `convert`. Real Across + /// Lite files are tens of kilobytes. Independent of `XD.maxSourceBytes` + /// because a `.puz` can hide a large ignored tail (string table, + /// extensions) behind a small grid; enforcing it here covers every call + /// site before any header parsing or string-table walk. + static let maxSourceBytes = 262_144 + static func convert(puzData data: Data) throws -> String { + guard data.count <= maxSourceBytes else { + throw ConversionError(message: "Across Lite file is too large.") + } // The offsets below are absolute, but a Data slice keeps its parent's // indices, so re-base a slice onto fresh zero-based storage first. // (The extension parsers already index relative to startIndex — they diff --git a/Crossmate/Services/PuzzleFileReader.swift b/Crossmate/Services/PuzzleFileReader.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Reads local/imported puzzle files (`.xd`, `.puz`) behind a streaming byte +/// cap. Every filesystem puzzle ingress — the open-URL importer, the Imported +/// tab, and the Files-picker copy — goes through here so no path materializes +/// an unbounded file before validation. The read never consults the reported +/// file size (a file-provider URL may not supply one); it streams up to the +/// cap and fails as soon as more bytes remain. +enum PuzzleFileReader { + enum ReadError: LocalizedError, Equatable { + case oversized(limit: Int) + case notUTF8 + case unsupportedType(String) + + var errorDescription: String? { + switch self { + case .oversized(let limit): + "The file is larger than the \(limit / 1024) KB limit for puzzle files." + case .notUTF8: + "The file is not UTF-8 text." + case .unsupportedType(let ext): + "\u{201C}.\(ext)\u{201D} files are not supported." + } + } + } + + /// Reads the puzzle file at `url` and returns its `.xd` source, bounding + /// the raw bytes first: `.xd` at `XD.maxSourceBytes` and `.puz` + /// independently at `PUZToXDConverter.maxSourceBytes` (a `.puz` can hide a + /// large ignored tail behind a small grid). The produced `.xd` is still + /// bounded by `XD.parse` downstream. + static func readSource(at url: URL) throws -> String { + switch url.pathExtension.lowercased() { + case "xd": + let data = try boundedData(at: url, limit: XD.maxSourceBytes) + guard let source = String(data: data, encoding: .utf8) else { + throw ReadError.notUTF8 + } + return source + case "puz": + let data = try boundedData(at: url, limit: PUZToXDConverter.maxSourceBytes) + return try PUZToXDConverter.convert(puzData: data) + default: + throw ReadError.unsupportedType(url.pathExtension) + } + } + + /// Streams at most `limit` bytes from `url`, throwing `.oversized` if any + /// bytes remain past the cap. + static func boundedData(at url: URL, limit: Int) throws -> Data { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var data = Data() + while data.count <= limit { + let remaining = limit + 1 - data.count + guard let chunk = try handle.read(upToCount: min(65_536, remaining)), + !chunk.isEmpty + else { return data } + data.append(chunk) + } + throw ReadError.oversized(limit: limit) + } +} diff --git a/Tests/Unit/DriveMonitorTests.swift b/Tests/Unit/DriveMonitorTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down the Imported-tab read boundary: `readSource` streams through +/// `PuzzleFileReader`, so an oversized file fails before it is materialized +/// rather than after an unbounded read. +@Suite("DriveMonitor") +@MainActor +struct DriveMonitorTests { + + private static let validXD = """ + Title: Test Puzzle + Author: Alice + + + ABC + D#E + FGH + + + A1. Across 1 ~ ABC + A4. Across 4 ~ DE + A5. Across 5 ~ FGH + D1. Down 1 ~ ADF + D2. Down 2 ~ BG + D3. Down 3 ~ CEH + """ + + private func writeTemp(_ data: Data, ext: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("drive-monitor-\(UUID().uuidString)") + .appendingPathExtension(ext) + try data.write(to: url) + return url + } + + @Test("readSource returns a valid .xd under the cap") + func readSourceReturnsValidXD() throws { + let url = try writeTemp(Data(Self.validXD.utf8), ext: "xd") + defer { try? FileManager.default.removeItem(at: url) } + + let monitor = DriveMonitor() + #expect(try monitor.readSource(at: url) == Self.validXD) + } + + @Test("readSource rejects an oversized .xd") + func readSourceRejectsOversizedXD() throws { + let url = try writeTemp( + Data(repeating: UInt8(ascii: "A"), count: XD.maxSourceBytes + 1), + ext: "xd" + ) + defer { try? FileManager.default.removeItem(at: url) } + + let monitor = DriveMonitor() + #expect(throws: DriveError.self) { + _ = try monitor.readSource(at: url) + } + } + + @Test("readSource rejects an oversized .puz") + func readSourceRejectsOversizedPUZ() throws { + let url = try writeTemp( + Data(count: PUZToXDConverter.maxSourceBytes + 1), + ext: "puz" + ) + defer { try? FileManager.default.removeItem(at: url) } + + let monitor = DriveMonitor() + #expect(throws: DriveError.self) { + _ = try monitor.readSource(at: url) + } + } +} diff --git a/Tests/Unit/ImportServiceTests.swift b/Tests/Unit/ImportServiceTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down the open-URL import boundary: an oversized or unsupported file +/// is rejected by the streaming capped read before any conversion, iCloud +/// copy, or game creation. (These rejection paths return before +/// `DriveMonitor.importFile`, so no test touches a real iCloud container.) +@Suite("ImportService") +@MainActor +struct ImportServiceTests { + + private func makeService() -> ImportService { + ImportService( + store: makeTestStore(persistence: makeTestPersistence()), + driveMonitor: DriveMonitor() + ) + } + + private func writeTemp(_ data: Data, ext: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("import-service-\(UUID().uuidString)") + .appendingPathExtension(ext) + try data.write(to: url) + return url + } + + @Test("An oversized .xd is rejected") + func rejectsOversizedXD() throws { + let url = try writeTemp( + Data(repeating: UInt8(ascii: "A"), count: XD.maxSourceBytes + 1), + ext: "xd" + ) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(makeService().importGame(from: url) == nil) + } + + @Test("An oversized .puz is rejected") + func rejectsOversizedPUZ() throws { + let url = try writeTemp( + Data(count: PUZToXDConverter.maxSourceBytes + 1), + ext: "puz" + ) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(makeService().importGame(from: url) == nil) + } + + @Test("An unsupported extension is rejected") + func rejectsUnsupportedExtension() throws { + let url = try writeTemp(Data("not a puzzle".utf8), ext: "txt") + defer { try? FileManager.default.removeItem(at: url) } + + #expect(makeService().importGame(from: url) == nil) + } +} diff --git a/Tests/Unit/NYTPuzzleFetcherTests.swift b/Tests/Unit/NYTPuzzleFetcherTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down the response bounds on the NYT download path: the byte budget +/// applied while streaming, the `Content-Length` preflight, and the +/// content-type gate. +@Suite("NYTPuzzleFetcher limits") +struct NYTPuzzleFetcherTests { + + private func byteStream(_ data: Data) -> AsyncStream<UInt8> { + AsyncStream { continuation in + for byte in data { continuation.yield(byte) } + continuation.finish() + } + } + + @Test("A body exactly at the byte budget is accepted") + func bodyAtLimitIsAccepted() async throws { + let data = try await NYTPuzzleFetcher.boundedResponseData( + from: byteStream(Data(count: 100)), + limit: 100, + expectedLength: -1 + ) + #expect(data.count == 100) + } + + @Test("A body over the byte budget is rejected mid-stream") + func bodyOverLimitIsRejected() async { + await #expect(throws: NYTFetchError.self) { + _ = try await NYTPuzzleFetcher.boundedResponseData( + from: byteStream(Data(count: 101)), + limit: 100, + expectedLength: -1 + ) + } + } + + @Test("An oversized Content-Length is rejected before reading") + func oversizedContentLengthIsRejected() async { + await #expect(throws: NYTFetchError.self) { + _ = try await NYTPuzzleFetcher.boundedResponseData( + from: byteStream(Data()), + limit: 100, + expectedLength: 101 + ) + } + } + + @Test("JSON content types pass; others are rejected; missing is allowed") + func contentTypeGate() { + #expect(NYTPuzzleFetcher.isJSONContentType(nil)) + #expect(NYTPuzzleFetcher.isJSONContentType("application/json")) + #expect(NYTPuzzleFetcher.isJSONContentType("application/json; charset=utf-8")) + #expect(NYTPuzzleFetcher.isJSONContentType("application/vnd.api+json")) + #expect(!NYTPuzzleFetcher.isJSONContentType("text/html")) + #expect(!NYTPuzzleFetcher.isJSONContentType("text/html; charset=utf-8")) + #expect(!NYTPuzzleFetcher.isJSONContentType("application/octet-stream")) + } +} diff --git a/Tests/Unit/PUZToXDConverterTests.swift b/Tests/Unit/PUZToXDConverterTests.swift @@ -310,6 +310,31 @@ struct PUZToXDConverterTests { #expect(source == (try PUZToXDConverter.convert(puzData: data))) } + @Test("A valid small grid with a tail past the raw cap is rejected") + func oversizedRawDataIsRejected() throws { + var data = try puzData( + width: 3, + height: 3, + solution: "ABCDEFGHI", + title: "Big Tail", + author: "", + copyright: "", + clues: [ + "Across 1", + "Down 1", + "Down 2", + "Down 3", + "Across 4", + "Across 5" + ] + ) + data.append(Data(count: PUZToXDConverter.maxSourceBytes + 1 - data.count)) + + #expect(throws: PUZToXDConverter.ConversionError.self) { + _ = try PUZToXDConverter.convert(puzData: data) + } + } + private func puzData( width: UInt8, height: UInt8, diff --git a/Tests/Unit/PuzzleFileReaderTests.swift b/Tests/Unit/PuzzleFileReaderTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down the streaming byte cap on filesystem puzzle ingress. The reader +/// never consults the reported file size, so a URL with no size metadata is +/// bounded the same way as any other — the cap is enforced by how many bytes +/// are actually read. +@Suite("PuzzleFileReader") +struct PuzzleFileReaderTests { + + private static let validXD = """ + Title: Test Puzzle + Author: Alice + + + ABC + D#E + FGH + + + A1. Across 1 ~ ABC + A4. Across 4 ~ DE + A5. Across 5 ~ FGH + D1. Down 1 ~ ADF + D2. Down 2 ~ BG + D3. Down 3 ~ CEH + """ + + private func writeTemp(_ data: Data, ext: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("puzzle-reader-\(UUID().uuidString)") + .appendingPathExtension(ext) + try data.write(to: url) + return url + } + + @Test("A valid .xd source is returned unchanged") + func readsValidXD() throws { + let url = try writeTemp(Data(Self.validXD.utf8), ext: "xd") + defer { try? FileManager.default.removeItem(at: url) } + + #expect(try PuzzleFileReader.readSource(at: url) == Self.validXD) + } + + @Test("A file exactly at the cap is read in full") + func boundedDataAtCap() throws { + let url = try writeTemp(Data(count: 1_000), ext: "bin") + defer { try? FileManager.default.removeItem(at: url) } + + let data = try PuzzleFileReader.boundedData(at: url, limit: 1_000) + #expect(data.count == 1_000) + } + + @Test("A file one byte over the cap is rejected") + func boundedDataOverCap() throws { + let url = try writeTemp(Data(count: 1_001), ext: "bin") + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: PuzzleFileReader.ReadError.oversized(limit: 1_000)) { + _ = try PuzzleFileReader.boundedData(at: url, limit: 1_000) + } + } + + @Test("An oversized .xd is rejected at the source cap") + func oversizedXDIsRejected() throws { + let url = try writeTemp( + Data(repeating: UInt8(ascii: "A"), count: XD.maxSourceBytes + 1), + ext: "xd" + ) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: PuzzleFileReader.ReadError.oversized(limit: XD.maxSourceBytes)) { + _ = try PuzzleFileReader.readSource(at: url) + } + } + + @Test("An oversized .puz is rejected before conversion") + func oversizedPUZIsRejected() throws { + let url = try writeTemp( + Data(count: PUZToXDConverter.maxSourceBytes + 1), + ext: "puz" + ) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: PuzzleFileReader.ReadError.oversized(limit: PUZToXDConverter.maxSourceBytes)) { + _ = try PuzzleFileReader.readSource(at: url) + } + } + + @Test("Non-UTF-8 .xd bytes are rejected") + func nonUTF8XDIsRejected() throws { + let url = try writeTemp(Data([0xFF, 0xFE, 0x41, 0x42]), ext: "xd") + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: PuzzleFileReader.ReadError.notUTF8) { + _ = try PuzzleFileReader.readSource(at: url) + } + } + + @Test("An unsupported extension is rejected") + func unsupportedExtensionIsRejected() throws { + let url = try writeTemp(Data(Self.validXD.utf8), ext: "txt") + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: PuzzleFileReader.ReadError.unsupportedType("txt")) { + _ = try PuzzleFileReader.readSource(at: url) + } + } +}