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:
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")