Skip to content

Commit 558c7ff

Browse files
authored
SWIFT-1126 / SWIFT-1137 Add legacy extended JSON parsing, fix Date-related crashes (#64)
1 parent c5343c6 commit 558c7ff

13 files changed

+448
-54
lines changed

Sources/SwiftBSON/BSON.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public enum BSON {
2323
case bool(Bool)
2424

2525
/// A BSON UTC datetime.
26+
/// When serialized to actual BSON bytes, the `Date` must be representable by a 64-bit signed integer
27+
/// of milliseconds since the epoch. If the `Date` cannot be represented in that manner (i.e. it is too far in the
28+
/// future or too far in the past), it will be serialized as either the minimum or maximum possible `Date`,
29+
/// whichever is closer.
2630
/// - SeeAlso: https://docs.mongodb.com/manual/reference/bson-types/#date
2731
case datetime(Date)
2832

Sources/SwiftBSON/BSONBinary.swift

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ExtrasBase64
2+
import ExtrasJSON
23
import Foundation
34
import NIO
45

@@ -146,6 +147,7 @@ public struct BSONBinary: Equatable, Hashable {
146147

147148
extension BSONBinary: BSONValue {
148149
internal static let extJSONTypeWrapperKeys: [String] = ["$binary", "$uuid"]
150+
internal static let extJSONLegacyTypeWrapperKeys: [String] = ["$type"]
149151

150152
/*
151153
* Initializes a `Binary` from ExtendedJSON.
@@ -188,38 +190,104 @@ extension BSONBinary: BSONValue {
188190
}
189191
}
190192

191-
// canonical and relaxed extended JSON
192-
guard let binary = try json.value.unwrapObject(withKey: "$binary", keyPath: keyPath) else {
193+
guard case let .object(obj) = json.value, let binary = obj["$binary"] else {
193194
return nil
194195
}
195-
guard
196-
let (base64, subTypeInput) = try binary.unwrapObject(withKeys: "base64", "subType", keyPath: keyPath)
197-
else {
198-
throw Swift.DecodingError._extendedJSONError(
199-
keyPath: keyPath,
200-
debugDescription: "Missing \"base64\" or \"subType\" in \(binary)"
201-
)
202-
}
203-
guard let base64Str = base64.stringValue else {
204-
throw Swift.DecodingError._extendedJSONError(
205-
keyPath: keyPath,
206-
debugDescription: "Could not parse `base64` from \"\(base64)\", " +
207-
"input must be a base64-encoded (with padding as =) payload as a string"
208-
)
209-
}
210-
guard
211-
let subTypeStr = subTypeInput.stringValue,
212-
let subTypeInt = UInt8(subTypeStr, radix: 16),
213-
let subType = Subtype(rawValue: subTypeInt)
214-
else {
196+
197+
let subtype: Subtype
198+
let base64Str: String
199+
200+
switch binary {
201+
// extended JSON v2
202+
case .object:
203+
guard obj.count == 1 else {
204+
throw Swift.DecodingError._extraKeysError(
205+
keyPath: keyPath,
206+
expectedKeys: ["$binary"],
207+
allKeys: Set(obj.keys)
208+
)
209+
}
210+
guard
211+
let (base64, subTypeInput) = try binary.unwrapObject(withKeys: "base64", "subType", keyPath: keyPath)
212+
else {
213+
throw Swift.DecodingError._extendedJSONError(
214+
keyPath: keyPath,
215+
debugDescription: "Missing \"base64\" or \"subType\" in \(binary)"
216+
)
217+
}
218+
guard let b64Str = base64.stringValue else {
219+
throw Swift.DecodingError._extendedJSONError(
220+
keyPath: keyPath,
221+
debugDescription: "Could not parse `base64` from \"\(base64)\", " +
222+
"input must be a base64-encoded (with padding as =) payload as a string"
223+
)
224+
}
225+
226+
guard
227+
let subtypeString = subTypeInput.stringValue,
228+
let subtypeInt = UInt8(subtypeString, radix: 16),
229+
let s = Subtype(rawValue: subtypeInt)
230+
else {
231+
throw Swift.DecodingError._extendedJSONError(
232+
keyPath: keyPath,
233+
debugDescription: "Could not parse `SubType` from \"\(json)\", subtype must"
234+
+ "be a BSON binary type as a one- or two-character hex string"
235+
)
236+
}
237+
238+
base64Str = b64Str
239+
subtype = s
240+
case let .string(base64):
241+
guard obj.count == 2 else {
242+
throw Swift.DecodingError._extraKeysError(
243+
keyPath: keyPath,
244+
expectedKeys: ["$binary"],
245+
allKeys: Set(obj.keys)
246+
)
247+
}
248+
249+
// extended JSON v1 (legacy)
250+
guard let subtypeInput = obj["$type"] else {
251+
throw Swift.DecodingError._extendedJSONError(
252+
keyPath: keyPath,
253+
debugDescription: "missing \"$type\" key in BSON binary legacy extended JSON representation"
254+
)
255+
}
256+
257+
let subtypeString: String
258+
if let str = subtypeInput.stringValue {
259+
subtypeString = str
260+
} else if case let .number(n) = subtypeInput {
261+
subtypeString = n
262+
} else {
263+
throw Swift.DecodingError._extendedJSONError(
264+
keyPath: keyPath,
265+
debugDescription: "expected \"$type\" to be a string or number, got \(subtypeInput) instead"
266+
)
267+
}
268+
269+
guard
270+
let subtypeInt = UInt8(subtypeString, radix: 16),
271+
let s = Subtype(rawValue: subtypeInt)
272+
else {
273+
throw Swift.DecodingError._extendedJSONError(
274+
keyPath: keyPath,
275+
debugDescription: "Could not parse `SubType` from \"\(json)\", subtype must be a BSON binary"
276+
+ "type as a one-or-two character hex string or a number"
277+
)
278+
}
279+
280+
base64Str = base64
281+
subtype = s
282+
default:
215283
throw Swift.DecodingError._extendedJSONError(
216284
keyPath: keyPath,
217-
debugDescription: "Could not parse `SubType` from \"\(subTypeInput)\", " +
218-
"input must be a BSON binary type as a one- or two-character hex string"
285+
debugDescription: "expected extended JSON object for \"$binary\", got \(binary) instead"
219286
)
220287
}
288+
221289
do {
222-
self = try BSONBinary(base64: base64Str, subtype: subType)
290+
self = try BSONBinary(base64: base64Str, subtype: subtype)
223291
} catch {
224292
throw Swift.DecodingError._extendedJSONError(
225293
keyPath: keyPath,

Sources/SwiftBSON/BSONEncoder.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ public class BSONEncoder {
1616
case deferredToDate
1717

1818
/// Encode the `Date` as a BSON datetime object (default).
19+
/// Throws an `EncodingError` if the `Date` is further away from January 1, 1970 than can be represented
20+
/// by a 64-bit signed integer of milliseconds.
1921
case bsonDateTime
2022

2123
/// Encode the `Date` as a 64-bit integer counting the number of milliseconds since January 1, 1970.
24+
/// Throws an `EncodingError` if the `Date` is too far away from then to be represented this way.
2225
case millisecondsSince1970
2326

2427
/// Encode the `Date` as a BSON double counting the number of seconds since January 1, 1970.
@@ -440,13 +443,27 @@ extension _BSONEncoder {
440443

441444
/// Returns the date as a `BSONValue`, or nil if no values were encoded by the custom encoder strategy.
442445
fileprivate func boxDate(_ date: Date) throws -> BSONValue? {
446+
func validateDate() throws {
447+
guard date.isValidBSONDate() else {
448+
throw EncodingError.invalidValue(
449+
date,
450+
EncodingError.Context(
451+
codingPath: self.codingPath,
452+
debugDescription: "Date must be representable as an Int64 number of milliseconds since epoch"
453+
)
454+
)
455+
}
456+
}
457+
443458
switch self.options.dateEncodingStrategy {
444459
case .bsonDateTime:
460+
try validateDate()
445461
return date
446462
case .deferredToDate:
447463
try date.encode(to: self)
448464
return self.storage.popContainer()
449465
case .millisecondsSince1970:
466+
try validateDate()
450467
return date.msSinceEpoch
451468
case .secondsSince1970:
452469
return date.timeIntervalSince1970

Sources/SwiftBSON/BSONError.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ extension DecodingError {
5555
debugDescription: debugStart + debugDescription
5656
))
5757
}
58+
59+
internal static func _extraKeysError(
60+
keyPath: [String],
61+
expectedKeys: Set<String>,
62+
allKeys: Set<String>
63+
) -> DecodingError {
64+
let extra = allKeys.subtracting(expectedKeys)
65+
66+
return Self._extendedJSONError(
67+
keyPath: keyPath,
68+
debugDescription: "Expected only the following keys, \(Array(expectedKeys)), instead got extra " +
69+
"key(s): \(extra)"
70+
)
71+
}
5872
}
5973

6074
/// Standardize the errors emitted from the BSON Iterator.

Sources/SwiftBSON/BSONRegularExpression.swift

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public struct BSONRegularExpression: Equatable, Hashable {
6565

6666
extension BSONRegularExpression: BSONValue {
6767
internal static let extJSONTypeWrapperKeys: [String] = ["$regularExpression"]
68+
internal static let extJSONLegacyTypeWrapperKeys: [String] = ["$regex", "$options"]
6869

6970
/*
7071
* Initializes a `BSONRegularExpression` from ExtendedJSON.
@@ -81,22 +82,36 @@ extension BSONRegularExpression: BSONValue {
8182
* - `DecodingError` if `json` is a partial match or is malformed.
8283
*/
8384
internal init?(fromExtJSON json: JSON, keyPath: [String]) throws {
84-
// canonical and relaxed extended JSON
85-
guard let value = try json.value.unwrapObject(withKey: "$regularExpression", keyPath: keyPath) else {
86-
return nil
87-
}
88-
guard
89-
let (pattern, options) = try value.unwrapObject(withKeys: "pattern", "options", keyPath: keyPath),
90-
let patternStr = pattern.stringValue,
91-
let optionsStr = options.stringValue
92-
else {
93-
throw DecodingError._extendedJSONError(
94-
keyPath: keyPath,
95-
debugDescription: "Could not parse `BSONRegularExpression` from \"\(value)\", " +
96-
"\"pattern\" and \"options\" must be strings"
97-
)
85+
// canonical and relaxed extended JSON v2
86+
if let regex = try json.value.unwrapObject(withKey: "$regularExpression", keyPath: keyPath) {
87+
guard
88+
let (pattern, options) = try regex.unwrapObject(withKeys: "pattern", "options", keyPath: keyPath),
89+
let patternStr = pattern.stringValue,
90+
let optionsStr = options.stringValue
91+
else {
92+
throw DecodingError._extendedJSONError(
93+
keyPath: keyPath,
94+
debugDescription: "Could not parse `BSONRegularExpression` from \"\(regex)\", " +
95+
"\"pattern\" and \"options\" must be strings"
96+
)
97+
}
98+
self = BSONRegularExpression(pattern: patternStr, options: optionsStr)
99+
return
100+
} else {
101+
// legacy / v1 extended JSON
102+
guard
103+
let (pattern, options) = try? json.value.unwrapObject(withKeys: "$regex", "$options", keyPath: keyPath),
104+
let patternStr = pattern.stringValue,
105+
let optionsStr = options.stringValue
106+
else {
107+
// instead of a throwing an error here or as part of unwrapObject, we just return nil to avoid erroring
108+
// when a $regex query operator is being parsed from extended JSON. See the
109+
// "Regular expression as value of $regex query operator with $options" corpus test.
110+
return nil
111+
}
112+
self = BSONRegularExpression(pattern: patternStr, options: optionsStr)
113+
return
98114
}
99-
self = BSONRegularExpression(pattern: patternStr, options: optionsStr)
100115
}
101116

102117
/// Converts this `BSONRegularExpression` to a corresponding `JSON` in relaxed extendedJSON format.

Sources/SwiftBSON/BSONValue.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ internal protocol BSONValue: Codable {
1111
/// for this `BSONValue`. (e.g. for Int32, this value is ["$numberInt"]).
1212
static var extJSONTypeWrapperKeys: [String] { get }
1313

14+
/// The `$`-prefixed keys that indicate an object may be a legacy extended JSON object wrapper.
15+
/// Because these keys can conflict with query operators (e.g. "$regex"), they are not always part of
16+
/// an object wrapper and may sometimes be parsed as normal BSON.
17+
static var extJSONLegacyTypeWrapperKeys: [String] { get }
18+
1419
/// Initializes a corresponding `BSON` from the provided `ByteBuffer`,
1520
/// moving the buffer's readerIndex forward to the byte beyond the end
1621
/// of this value.
@@ -35,6 +40,8 @@ internal protocol BSONValue: Codable {
3540

3641
/// Convenience extension to get static bsonType from an instance
3742
extension BSONValue {
43+
internal static var extJSONLegacyTypeWrapperKeys: [String] { [] }
44+
3845
internal var bsonType: BSONType {
3946
Self.bsonType
4047
}

Sources/SwiftBSON/Date+BSONValue.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import NIO
44
extension Date: BSONValue {
55
internal static let extJSONTypeWrapperKeys: [String] = ["$date"]
66

7+
/// The range of datetimes that can be represented in BSON.
8+
private static let VALID_BSON_DATES: Range<Date> = Date(msSinceEpoch: Int64.min)..<Date(msSinceEpoch: Int64.max)
9+
710
/*
811
* Initializes a `Date` from ExtendedJSON.
912
*
@@ -48,6 +51,15 @@ extension Date: BSONValue {
4851
)
4952
}
5053
self = date
54+
case let .number(ms):
55+
// legacy extended JSON
56+
guard let msInt64 = Int64(ms) else {
57+
throw DecodingError._extendedJSONError(
58+
keyPath: keyPath,
59+
debugDescription: "Expected \(ms) to be valid Int64 representing milliseconds since epoch"
60+
)
61+
}
62+
self = Date(msSinceEpoch: msInt64)
5163
default:
5264
throw DecodingError._extendedJSONError(
5365
keyPath: keyPath,
@@ -87,7 +99,20 @@ extension Date: BSONValue {
8799
internal var bson: BSON { .datetime(self) }
88100

89101
/// The number of milliseconds after the Unix epoch that this `Date` occurs.
90-
internal var msSinceEpoch: Int64 { Int64((self.timeIntervalSince1970 * 1000.0).rounded()) }
102+
/// If the date is further in the future than Int64.max milliseconds from the epoch,
103+
/// Int64.max is returned to prevent a crash.
104+
internal var msSinceEpoch: Int64 {
105+
// to prevent the application from crashing, we simply clamp the date to the range representable
106+
// by an Int64 ms since epoch
107+
guard self > Self.VALID_BSON_DATES.lowerBound else {
108+
return Int64.min
109+
}
110+
guard self < Self.VALID_BSON_DATES.upperBound else {
111+
return Int64.max
112+
}
113+
114+
return Int64((self.timeIntervalSince1970 * 1000.0).rounded())
115+
}
91116

92117
/// Initializes a new `Date` representing the instance `msSinceEpoch` milliseconds
93118
/// since the Unix epoch.
@@ -105,4 +130,8 @@ extension Date: BSONValue {
105130
internal func write(to buffer: inout ByteBuffer) {
106131
buffer.writeInteger(self.msSinceEpoch, endianness: .little, as: Int64.self)
107132
}
133+
134+
internal func isValidBSONDate() -> Bool {
135+
Self.VALID_BSON_DATES.contains(self)
136+
}
108137
}

Sources/SwiftBSON/ExtendedJSONDecoder.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,15 @@ public class ExtendedJSONDecoder {
1717
}()
1818

1919
/// A set of all the possible extendedJSON wrapper keys.
20+
/// This does not include the legacy extended JSON wrapper keys.
2021
private static var wrapperKeySet: Set<String> = {
21-
Set(ExtendedJSONDecoder.wrapperKeyMap.keys)
22+
var keys: Set<String> = []
23+
for t in BSON.allBSONTypes.values {
24+
for k in t.extJSONTypeWrapperKeys {
25+
keys.insert(k)
26+
}
27+
}
28+
return keys
2229
}()
2330

2431
/// A map from extended JSON wrapper keys (e.g. "$numberLong") to the BSON type(s) that they correspond to.
@@ -33,6 +40,9 @@ public class ExtendedJSONDecoder {
3340
for k in t.extJSONTypeWrapperKeys {
3441
map[k, default: []].append(t.self)
3542
}
43+
for k in t.extJSONLegacyTypeWrapperKeys {
44+
map[k, default: []].append(t.self)
45+
}
3646
}
3747
return map
3848
}()

0 commit comments

Comments
 (0)