From 88121acfc1fb41916a099e0bf7ad8ccf7fa04ea1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 26 May 2026 23:49:47 +0700 Subject: [PATCH 1/2] fix(plugin-mongodb): prevent crash on NaN or infinite document values (#1418) --- CHANGELOG.md | 1 + .../BsonDocumentFlattener.swift | 55 ++++++--- .../MongoDBPluginDriver.swift | 4 +- .../MongoDB/BsonDocumentFlattenerTests.swift | 115 +++++++++++++++--- 4 files changed, 142 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adb1b9564..c973fcbd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Custom and OpenAI-compatible AI providers now work when the base URL already ends in `/v1`, instead of building a doubled `/v1/v1/` path that failed. (#1400) +- MongoDB: opening a collection no longer crashes when a document contains a NaN or infinite number. (#1418) ## [0.45.0] - 2026-05-26 diff --git a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift index cf8ff5f37..81ced8778 100644 --- a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift +++ b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift @@ -77,6 +77,9 @@ struct BsonDocumentFlattener { if CFBooleanGetTypeID() == CFGetTypeID(num) { return num.boolValue ? "true" : "false" } + if isFloatingPoint(num), !num.doubleValue.isFinite { + return nonFiniteToken(num.doubleValue) + } return num.stringValue case let int as Int: return String(int) @@ -85,7 +88,7 @@ struct BsonDocumentFlattener { case let int64 as Int64: return String(int64) case let double as Double: - return String(double) + return double.isFinite ? String(double) : nonFiniteToken(double) case let bool as Bool: return bool ? "true" : "false" case let date as Date: @@ -121,24 +124,20 @@ struct BsonDocumentFlattener { /// Serialize a dictionary or array to compact JSON string static func serializeToJson(_ value: Any) -> String { let sanitized = sanitizeForJson(value) - do { - let data = try JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]) - if let json = String(data: data, encoding: .utf8) { - // Cap at 10k chars to prevent mega-document display issues - let nsJson = json as NSString - if nsJson.length > 10_000 { - return String(json.prefix(10_000)) + "..." - } - return json - } - } catch { - // Fall through to description + guard JSONSerialization.isValidJSONObject(sanitized), + let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { + return String(describing: value) + } + let nsJson = json as NSString + if nsJson.length > 10_000 { + return String(json.prefix(10_000)) + "..." } - return String(describing: value) + return json } - /// Recursively convert non-JSON-safe types (Data, Date, etc.) to JSON-safe representations - private static func sanitizeForJson(_ value: Any) -> Any { + /// Recursively convert every value into a JSON-safe representation + static func sanitizeForJson(_ value: Any) -> Any { switch value { case let dict as [String: Any]: return dict.mapValues { sanitizeForJson($0) } @@ -148,11 +147,33 @@ struct BsonDocumentFlattener { return formatBinaryData(data) case let date as Date: return ISO8601DateFormatter().string(from: date) - default: + case is NSNull: return value + case let str as String: + return str + case let num as NSNumber: + return sanitizeNumber(num) + default: + return String(describing: value) } } + private static func sanitizeNumber(_ num: NSNumber) -> Any { + guard CFBooleanGetTypeID() != CFGetTypeID(num) else { return num } + guard isFloatingPoint(num), !num.doubleValue.isFinite else { return num } + return nonFiniteToken(num.doubleValue) + } + + private static func isFloatingPoint(_ num: NSNumber) -> Bool { + let objCType = String(cString: num.objCType) + return objCType == "d" || objCType == "f" + } + + private static func nonFiniteToken(_ value: Double) -> String { + if value.isNaN { return "NaN" } + return value > 0 ? "Infinity" : "-Infinity" + } + /// Format binary data: 16-byte values as UUID, otherwise as hex string private static func formatBinaryData(_ data: Data) -> String { if data.count == 16 { diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 8bb8ec57d..b440299ea 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -908,7 +908,9 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func prettyJson(_ value: Any) -> String { - guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .prettyPrinted]), + let sanitized = BsonDocumentFlattener.sanitizeForJson(value) + guard JSONSerialization.isValidJSONObject(sanitized), + let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys, .prettyPrinted]), let json = String(data: data, encoding: .utf8) else { return String(describing: value) } diff --git a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift index fd08bc9d3..c6f0ea763 100644 --- a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift +++ b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift @@ -125,6 +125,15 @@ struct BsonDocumentFlattenerTests { let expected = ISO8601DateFormatter().string(from: date) #expect(result[0][1] == expected) } + + @Test("Nested non-finite double does not crash and renders as a token") + func nestedNonFiniteDouble() { + let metrics: [String: Any] = ["score": Double.nan, "ratio": Double.infinity] + let doc: [String: Any] = ["_id": "1", "metrics": metrics] + let columns = ["_id", "metrics"] + let result = BsonDocumentFlattener.flatten(documents: [doc], columns: columns) + #expect(result[0][1] == "{\"ratio\":\"Infinity\",\"score\":\"NaN\"}") + } } // MARK: - columnTypes(for:documents:) @@ -261,6 +270,24 @@ struct BsonDocumentFlattenerTests { #expect(result == "3.14") } + @Test("NaN double returns NaN token") + func nanValue() { + let result = BsonDocumentFlattener.stringValue(for: Double.nan) + #expect(result == "NaN") + } + + @Test("Positive infinity returns Infinity token") + func positiveInfinityValue() { + let result = BsonDocumentFlattener.stringValue(for: Double.infinity) + #expect(result == "Infinity") + } + + @Test("Negative infinity returns -Infinity token") + func negativeInfinityValue() { + let result = BsonDocumentFlattener.stringValue(for: -Double.infinity) + #expect(result == "-Infinity") + } + @Test("Bool true via NSNumber returns true") func boolTrueValue() { let result = BsonDocumentFlattener.stringValue(for: NSNumber(value: true)) @@ -343,6 +370,42 @@ struct BsonDocumentFlattenerTests { #expect(nsResult.length <= 10_003) // 10000 + "..." #expect(result.hasSuffix("...")) } + + @Test("NaN double in a dictionary serializes to NaN token instead of crashing") + func nanInDictionary() { + let dict: [String: Any] = ["v": Double.nan] + let result = BsonDocumentFlattener.serializeToJson(dict) + #expect(result == "{\"v\":\"NaN\"}") + } + + @Test("Positive infinity in a dictionary serializes to Infinity token") + func positiveInfinityInDictionary() { + let dict: [String: Any] = ["v": Double.infinity] + let result = BsonDocumentFlattener.serializeToJson(dict) + #expect(result == "{\"v\":\"Infinity\"}") + } + + @Test("Negative infinity in a dictionary serializes to -Infinity token") + func negativeInfinityInDictionary() { + let dict: [String: Any] = ["v": -Double.infinity] + let result = BsonDocumentFlattener.serializeToJson(dict) + #expect(result == "{\"v\":\"-Infinity\"}") + } + + @Test("Non-finite double in an array keeps finite siblings as numbers") + func nonFiniteInArray() { + let array: [Any] = [Double.nan, 1.5, Double.infinity] + let result = BsonDocumentFlattener.serializeToJson(array) + #expect(result == "[\"NaN\",1.5,\"Infinity\"]") + } + + @Test("Unsupported nested type is stringified instead of crashing") + func unsupportedTypeStringified() { + struct Custom: CustomStringConvertible { var description: String { "custom" } } + let dict: [String: Any] = ["v": Custom()] + let result = BsonDocumentFlattener.serializeToJson(dict) + #expect(result == "{\"v\":\"custom\"}") + } } } @@ -402,6 +465,9 @@ private struct BsonDocumentFlattener { if CFBooleanGetTypeID() == CFGetTypeID(num) { return num.boolValue ? "true" : "false" } + if isFloatingPoint(num), !num.doubleValue.isFinite { + return nonFiniteToken(num.doubleValue) + } return num.stringValue case let int as Int: return String(int) @@ -410,7 +476,7 @@ private struct BsonDocumentFlattener { case let int64 as Int64: return String(int64) case let double as Double: - return String(double) + return double.isFinite ? String(double) : nonFiniteToken(double) case let bool as Bool: return bool ? "true" : "false" case let date as Date: @@ -441,22 +507,19 @@ private struct BsonDocumentFlattener { static func serializeToJson(_ value: Any) -> String { let sanitized = sanitizeForJson(value) - do { - let data = try JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]) - if let json = String(data: data, encoding: .utf8) { - let nsJson = json as NSString - if nsJson.length > 10_000 { - return String(json.prefix(10_000)) + "..." - } - return json - } - } catch { - // Fall through to description + guard JSONSerialization.isValidJSONObject(sanitized), + let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { + return String(describing: value) + } + let nsJson = json as NSString + if nsJson.length > 10_000 { + return String(json.prefix(10_000)) + "..." } - return String(describing: value) + return json } - private static func sanitizeForJson(_ value: Any) -> Any { + static func sanitizeForJson(_ value: Any) -> Any { switch value { case let dict as [String: Any]: return dict.mapValues { sanitizeForJson($0) } @@ -466,11 +529,33 @@ private struct BsonDocumentFlattener { return formatBinaryData(data) case let date as Date: return ISO8601DateFormatter().string(from: date) - default: + case is NSNull: return value + case let str as String: + return str + case let num as NSNumber: + return sanitizeNumber(num) + default: + return String(describing: value) } } + private static func sanitizeNumber(_ num: NSNumber) -> Any { + guard CFBooleanGetTypeID() != CFGetTypeID(num) else { return num } + guard isFloatingPoint(num), !num.doubleValue.isFinite else { return num } + return nonFiniteToken(num.doubleValue) + } + + private static func isFloatingPoint(_ num: NSNumber) -> Bool { + let objCType = String(cString: num.objCType) + return objCType == "d" || objCType == "f" + } + + private static func nonFiniteToken(_ value: Double) -> String { + if value.isNaN { return "NaN" } + return value > 0 ? "Infinity" : "-Infinity" + } + private static func formatBinaryData(_ data: Data) -> String { if data.count == 16 { let uuid = UUID(uuid: ( From 55e0864ee6f900bc942f3e03344dfe4ae6a8bf88 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 27 May 2026 00:03:45 +0700 Subject: [PATCH 2/2] refactor(plugin-mongodb): dedupe number handling and drop unreachable type cases in BSON flattener --- .../BsonDocumentFlattener.swift | 60 ++++++++---------- .../MongoDB/BsonDocumentFlattenerTests.swift | 61 ++++++++----------- 2 files changed, 49 insertions(+), 72 deletions(-) diff --git a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift index 81ced8778..30fcb83bc 100644 --- a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift +++ b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift @@ -73,26 +73,9 @@ struct BsonDocumentFlattener { case let str as String: return str case let num as NSNumber: - // Check if it's a boolean (NSNumber wraps booleans too) - if CFBooleanGetTypeID() == CFGetTypeID(num) { - return num.boolValue ? "true" : "false" - } - if isFloatingPoint(num), !num.doubleValue.isFinite { - return nonFiniteToken(num.doubleValue) - } - return num.stringValue - case let int as Int: - return String(int) - case let int32 as Int32: - return String(int32) - case let int64 as Int64: - return String(int64) - case let double as Double: - return double.isFinite ? String(double) : nonFiniteToken(double) - case let bool as Bool: - return bool ? "true" : "false" + return displayString(for: num) case let date as Date: - return ISO8601DateFormatter().string(from: date) + return iso8601Formatter.string(from: date) case let data as Data: return formatBinaryData(data) case let dict as [String: Any]: @@ -146,7 +129,7 @@ struct BsonDocumentFlattener { case let data as Data: return formatBinaryData(data) case let date as Date: - return ISO8601DateFormatter().string(from: date) + return iso8601Formatter.string(from: date) case is NSNull: return value case let str as String: @@ -158,12 +141,28 @@ struct BsonDocumentFlattener { } } + private static let iso8601Formatter = ISO8601DateFormatter() + + private static func displayString(for num: NSNumber) -> String { + if isBoolean(num) { + return num.boolValue ? "true" : "false" + } + if isFloatingPoint(num), !num.doubleValue.isFinite { + return nonFiniteToken(num.doubleValue) + } + return num.stringValue + } + private static func sanitizeNumber(_ num: NSNumber) -> Any { - guard CFBooleanGetTypeID() != CFGetTypeID(num) else { return num } + guard !isBoolean(num) else { return num } guard isFloatingPoint(num), !num.doubleValue.isFinite else { return num } return nonFiniteToken(num.doubleValue) } + private static func isBoolean(_ num: NSNumber) -> Bool { + CFBooleanGetTypeID() == CFGetTypeID(num) + } + private static func isFloatingPoint(_ num: NSNumber) -> Bool { let objCType = String(cString: num.objCType) return objCType == "d" || objCType == "f" @@ -214,27 +213,16 @@ struct BsonDocumentFlattener { switch value { case let num as NSNumber: - if CFBooleanGetTypeID() == CFGetTypeID(num) { + if isBoolean(num) { return 8 // Boolean } - let objCType = String(cString: num.objCType) - if objCType == "d" || objCType == "f" { + if isFloatingPoint(num) { return 1 // Double } - if objCType == "q" || objCType == "l" { - return 18 // Int64 - } - return 16 // Int32 + let objCType = String(cString: num.objCType) + return objCType == "q" || objCType == "l" ? 18 : 16 // Int64 : Int32 case is String: return 2 // String - case is Bool: - return 8 // Boolean - case is Int, is Int32: - return 16 // Int32 - case is Int64: - return 18 // Int64 - case is Double, is Float: - return 1 // Double case is Date: return 9 // Date case is Data: diff --git a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift index c6f0ea763..374632df3 100644 --- a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift +++ b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift @@ -362,7 +362,7 @@ struct BsonDocumentFlattenerTests { func capsAtTenThousandChars() { // Build a large dictionary that serializes to >10k chars var largeDict: [String: Any] = [:] - for i in 0 ..< 2000 { + for i in 0 ..< 2_000 { largeDict["key_\(String(format: "%04d", i))"] = String(repeating: "x", count: 10) } let result = BsonDocumentFlattener.serializeToJson(largeDict) @@ -462,25 +462,9 @@ private struct BsonDocumentFlattener { case let str as String: return str case let num as NSNumber: - if CFBooleanGetTypeID() == CFGetTypeID(num) { - return num.boolValue ? "true" : "false" - } - if isFloatingPoint(num), !num.doubleValue.isFinite { - return nonFiniteToken(num.doubleValue) - } - return num.stringValue - case let int as Int: - return String(int) - case let int32 as Int32: - return String(int32) - case let int64 as Int64: - return String(int64) - case let double as Double: - return double.isFinite ? String(double) : nonFiniteToken(double) - case let bool as Bool: - return bool ? "true" : "false" + return displayString(for: num) case let date as Date: - return ISO8601DateFormatter().string(from: date) + return iso8601Formatter.string(from: date) case let data as Data: return formatBinaryData(data) case let dict as [String: Any]: @@ -528,7 +512,7 @@ private struct BsonDocumentFlattener { case let data as Data: return formatBinaryData(data) case let date as Date: - return ISO8601DateFormatter().string(from: date) + return iso8601Formatter.string(from: date) case is NSNull: return value case let str as String: @@ -540,12 +524,28 @@ private struct BsonDocumentFlattener { } } + private static let iso8601Formatter = ISO8601DateFormatter() + + private static func displayString(for num: NSNumber) -> String { + if isBoolean(num) { + return num.boolValue ? "true" : "false" + } + if isFloatingPoint(num), !num.doubleValue.isFinite { + return nonFiniteToken(num.doubleValue) + } + return num.stringValue + } + private static func sanitizeNumber(_ num: NSNumber) -> Any { - guard CFBooleanGetTypeID() != CFGetTypeID(num) else { return num } + guard !isBoolean(num) else { return num } guard isFloatingPoint(num), !num.doubleValue.isFinite else { return num } return nonFiniteToken(num.doubleValue) } + private static func isBoolean(_ num: NSNumber) -> Bool { + CFBooleanGetTypeID() == CFGetTypeID(num) + } + private static func isFloatingPoint(_ num: NSNumber) -> Bool { let objCType = String(cString: num.objCType) return objCType == "d" || objCType == "f" @@ -588,27 +588,16 @@ private struct BsonDocumentFlattener { switch value { case let num as NSNumber: - if CFBooleanGetTypeID() == CFGetTypeID(num) { + if isBoolean(num) { return 8 } - let objCType = String(cString: num.objCType) - if objCType == "d" || objCType == "f" { + if isFloatingPoint(num) { return 1 } - if objCType == "q" || objCType == "l" { - return 18 - } - return 16 + let objCType = String(cString: num.objCType) + return objCType == "q" || objCType == "l" ? 18 : 16 case is String: return 2 - case is Bool: - return 8 - case is Int, is Int32: - return 16 - case is Int64: - return 18 - case is Double, is Float: - return 1 case is Date: return 9 case is Data: