crossmate

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

commit 7bb71f25ea102117aa53ccbeafbb70abbea810fd
parent 85509e32c9d7703346dd45e607972f5f7de3d33c
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 02:33:35 +0900

Reject malformed puzzle geometry

This commit makes the JSON to XD converter fail closed before malformed
puzzle metadata can reach unchecked grid operations. Non-positive
dimensions are rejected before grid ranges are built, and the expected
cell count now uses overflow-aware multiplication instead of trusting
`width * height` to be representable.

Clue cell references are also validated before answers are read. A clue
that points outside the parsed grid now throws ConversionError rather
than trapping while building the clue answer, while valid block cells
still contribute no answer text as before.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Services/NYTToXDConverter.swift | 19+++++++++++++++++--
MTests/Unit/NYTToXDConverterTests.swift | 46++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 63 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -52,11 +52,16 @@ enum NYTToXDConverter { throw ConversionError(message: "Missing dimensions.") } + guard width > 0, height > 0 else { + throw ConversionError(message: "Invalid dimensions (\(width)x\(height)).") + } + guard let cells = body["cells"] as? [Any] else { throw ConversionError(message: "Missing cells.") } - guard cells.count == width * height else { + let (expectedCellCount, overflowed) = width.multipliedReportingOverflow(by: height) + guard !overflowed, cells.count == expectedCellCount else { throw ConversionError(message: "Cell count (\(cells.count)) does not match dimensions (\(width)x\(height)).") } @@ -216,7 +221,17 @@ enum NYTToXDConverter { // Build answer from cell indices let cellIndices = clue["cells"] as? [Int] ?? [] - let answerStr = cellIndices.compactMap { answers[$0] }.joined() + var answerStr = "" + for cellIndex in cellIndices { + guard answers.indices.contains(cellIndex) else { + throw ConversionError( + message: "Clue \(label) \(direction) references invalid cell index \(cellIndex)." + ) + } + if let answer = answers[cellIndex] { + answerStr += answer + } + } let prefix = direction == "Across" ? "A" : "D" let line = "\(prefix)\(label). \(clueText) ~ \(answerStr)" diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -122,6 +122,52 @@ struct NYTToXDConverterTests { return values } + // MARK: - Malformed input + + @Test("Invalid dimensions are rejected before grid ranges are built") + func invalidDimensionsRejected() throws { + let root: [String: Any] = [ + "publicationDate": "2025-04-04", + "body": [[ + "dimensions": ["width": -1, "height": -1], + "cells": [["answer": "A"]], + "clues": [[ + "label": "1", + "direction": "Across", + "cells": [0], + "text": [["plain": "malformed"]] + ]] + ]] + ] + let data = try JSONSerialization.data(withJSONObject: root) + + #expect(throws: NYTToXDConverter.ConversionError.self) { + try NYTToXDConverter.convert(jsonData: data) + } + } + + @Test("Clue cell indices outside the grid are rejected") + func invalidClueCellIndicesRejected() throws { + let root: [String: Any] = [ + "publicationDate": "2025-04-05", + "body": [[ + "dimensions": ["width": 1, "height": 1], + "cells": [["answer": "A"]], + "clues": [[ + "label": "1", + "direction": "Across", + "cells": [0, 1], + "text": [["plain": "malformed"]] + ]] + ]] + ] + let data = try JSONSerialization.data(withJSONObject: root) + + #expect(throws: NYTToXDConverter.ConversionError.self) { + try NYTToXDConverter.convert(jsonData: data) + } + } + // MARK: - Header emission @Test("NYT metadata uses weekday title and publisher")