Skip to content

Commit 9265743

Browse files
authored
Add validation to CollectionDifference decoder (#76876)
The `CollectionDifference` type has a few different invariants that were not being validated when initializing using the type's `Decodable` conformance, since the type was using the autogenerated `Codable` implementation. This change provides manual implementations of the `Encodable` and `Decodable` requirements, and adds tests that validate the failure when trying to decode invalid JSON for CollectionDifference (and a few other types).
1 parent 3050916 commit 9265743

File tree

2 files changed

+179
-10
lines changed

2 files changed

+179
-10
lines changed

stdlib/public/core/CollectionDifference.swift

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ public struct CollectionDifference<ChangeElement> {
6363
}
6464
}
6565
}
66+
internal var _isRemoval: Bool {
67+
switch self {
68+
case .insert: false
69+
case .remove: true
70+
}
71+
}
6672
}
6773

6874
/// The insertions contained by this difference, from lowest offset to
@@ -404,21 +410,45 @@ extension CollectionDifference.Change: Codable where ChangeElement: Codable {
404410

405411
public func encode(to encoder: Encoder) throws {
406412
var container = encoder.container(keyedBy: _CodingKeys.self)
407-
switch self {
408-
case .remove(_, _, _):
409-
try container.encode(true, forKey: .isRemove)
410-
case .insert(_, _, _):
411-
try container.encode(false, forKey: .isRemove)
412-
}
413-
413+
try container.encode(_isRemoval, forKey: .isRemove)
414414
try container.encode(_offset, forKey: .offset)
415415
try container.encode(_element, forKey: .element)
416416
try container.encode(_associatedOffset, forKey: .associatedOffset)
417417
}
418418
}
419419

420420
@available(SwiftStdlib 5.1, *)
421-
extension CollectionDifference: Codable where ChangeElement: Codable {}
421+
extension CollectionDifference: Codable where ChangeElement: Codable {
422+
private enum _CodingKeys: String, CodingKey {
423+
case insertions
424+
case removals
425+
}
426+
427+
public init(from decoder: Decoder) throws {
428+
let container = try decoder.container(keyedBy: _CodingKeys.self)
429+
var changes = try container.decode([Change].self, forKey: .removals)
430+
let removalCount = changes.count
431+
try changes.append(contentsOf: container.decode([Change].self, forKey: .insertions))
432+
433+
guard changes[..<removalCount].allSatisfy({ $0._isRemoval }),
434+
changes[removalCount...].allSatisfy({ !$0._isRemoval }),
435+
Self._validateChanges(changes)
436+
else {
437+
throw DecodingError.dataCorrupted(
438+
DecodingError.Context(
439+
codingPath: decoder.codingPath,
440+
debugDescription: "Cannot decode an invalid collection difference"))
441+
}
442+
443+
self.init(_validatedChanges: changes)
444+
}
445+
446+
public func encode(to encoder: Encoder) throws {
447+
var container = encoder.container(keyedBy: _CodingKeys.self)
448+
try container.encode(insertions, forKey: .insertions)
449+
try container.encode(removals, forKey: .removals)
450+
}
451+
}
422452

423453
@available(SwiftStdlib 5.1, *)
424454
extension CollectionDifference: Sendable where ChangeElement: Sendable { }

test/stdlib/CodableTests.swift

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,23 @@ func expectRoundTripEqualityThroughPlist<T : Codable>(for value: T, lineNumber:
118118
expectRoundTripEquality(of: value, encode: encode, decode: decode, lineNumber: lineNumber)
119119
}
120120

121+
func expectDecodingErrorViaJSON<T : Codable>(
122+
type: T.Type,
123+
json: String,
124+
errorKind: DecodingErrorKind,
125+
lineNumber: Int = #line)
126+
{
127+
let data = json.data(using: .utf8)!
128+
do {
129+
let value = try JSONDecoder().decode(T.self, from: data)
130+
expectUnreachable(":\(lineNumber): Successfully decoded invalid \(T.self) <\(debugDescription(value))>")
131+
} catch let error as DecodingError {
132+
expectEqual(error.errorKind, errorKind, "\(#file):\(lineNumber): Incorrect error kind <\(error.errorKind)> not equal to expected <\(errorKind)>")
133+
} catch {
134+
expectUnreachableCatch(error, ":\(lineNumber): Unexpected error type when decoding \(T.self)")
135+
}
136+
}
137+
121138
// MARK: - Helper Types
122139
// A wrapper around a UUID that will allow it to be encoded at the top level of an encoder.
123140
struct UUIDCodingWrapper : Codable, Equatable, Hashable, CodingKeyRepresentable {
@@ -141,6 +158,24 @@ struct UUIDCodingWrapper : Codable, Equatable, Hashable, CodingKeyRepresentable
141158
}
142159
}
143160

161+
enum DecodingErrorKind {
162+
case dataCorrupted
163+
case keyNotFound
164+
case typeMismatch
165+
case valueNotFound
166+
}
167+
168+
extension DecodingError {
169+
var errorKind: DecodingErrorKind {
170+
switch self {
171+
case .dataCorrupted: .dataCorrupted
172+
case .keyNotFound: .keyNotFound
173+
case .typeMismatch: .typeMismatch
174+
case .valueNotFound: .valueNotFound
175+
}
176+
}
177+
}
178+
144179
// MARK: - Tests
145180
class TestCodable : TestCodableSuper {
146181
// MARK: - AffineTransform
@@ -392,6 +427,90 @@ class TestCodable : TestCodableSuper {
392427
expectEqual(value.upperBound, decoded.upperBound, "\(#file):\(#line): Decoded ClosedRange upperBound <\(debugDescription(decoded))> not equal to original <\(debugDescription(value))>")
393428
expectEqual(value.lowerBound, decoded.lowerBound, "\(#file):\(#line): Decoded ClosedRange lowerBound <\(debugDescription(decoded))> not equal to original <\(debugDescription(value))>")
394429
}
430+
431+
func test_ClosedRange_JSON_Errors() {
432+
expectDecodingErrorViaJSON(
433+
type: ClosedRange<Int>.self,
434+
json: "[5,0]",
435+
errorKind: .dataCorrupted)
436+
expectDecodingErrorViaJSON(
437+
type: ClosedRange<Int>.self,
438+
json: "[5,]",
439+
errorKind: .valueNotFound)
440+
expectDecodingErrorViaJSON(
441+
type: ClosedRange<Int>.self,
442+
json: "[0,Hello]",
443+
errorKind: .dataCorrupted)
444+
}
445+
446+
// MARK: - CollectionDifference
447+
lazy var collectionDifferenceValues: [Int : CollectionDifference<Int>] = [
448+
#line : [1, 2, 3].difference(from: [1, 2, 3]),
449+
#line : [1, 2, 3].difference(from: [1, 2]),
450+
#line : [1, 2, 3].difference(from: [2, 3, 4]),
451+
#line : [1, 2, 3].difference(from: [6, 7, 8]),
452+
]
453+
454+
func test_CollectionDifference_JSON() {
455+
for (testLine, difference) in collectionDifferenceValues {
456+
expectRoundTripEqualityThroughJSON(for: difference, lineNumber: testLine)
457+
}
458+
}
459+
460+
func test_CollectionDifference_Plist() {
461+
for (testLine, difference) in collectionDifferenceValues {
462+
expectRoundTripEqualityThroughPlist(for: difference, lineNumber: testLine)
463+
}
464+
}
465+
466+
func test_CollectionDifference_JSON_Errors() {
467+
// Valid serialization:
468+
// {
469+
// "insertions" : [ { "associatedOffset" : null, "element" : 1, "isRemove" : false, "offset" : 0 } ],
470+
// "removals" : [ { "associatedOffset" : null, "element" : 4, "isRemove" : true, "offset" : 2 } ]
471+
// }
472+
473+
// Removal in insertion
474+
expectDecodingErrorViaJSON(
475+
type: CollectionDifference<Int>.self,
476+
json: #"""
477+
{
478+
"insertions" : [ { "associatedOffset" : null, "element" : 1, "isRemove" : true, "offset" : 0 } ],
479+
"removals" : [ { "associatedOffset" : null, "element" : 4, "isRemove" : true, "offset" : 2 } ]
480+
}
481+
"""#,
482+
errorKind: .dataCorrupted)
483+
// Repeated offset
484+
expectDecodingErrorViaJSON(
485+
type: CollectionDifference<Int>.self,
486+
json: #"""
487+
{
488+
"insertions" : [ { "associatedOffset" : null, "element" : 1, "isRemove" : true, "offset" : 2 } ],
489+
"removals" : [ { "associatedOffset" : null, "element" : 4, "isRemove" : true, "offset" : 2 } ]
490+
}
491+
"""#,
492+
errorKind: .dataCorrupted)
493+
// Invalid offset
494+
expectDecodingErrorViaJSON(
495+
type: CollectionDifference<Int>.self,
496+
json: #"""
497+
{
498+
"insertions" : [ { "associatedOffset" : null, "element" : 1, "isRemove" : true, "offset" : -2 } ],
499+
"removals" : [ { "associatedOffset" : null, "element" : 4, "isRemove" : true, "offset" : 2 } ]
500+
}
501+
"""#,
502+
errorKind: .dataCorrupted)
503+
// Invalid associated offset
504+
expectDecodingErrorViaJSON(
505+
type: CollectionDifference<Int>.self,
506+
json: #"""
507+
{
508+
"insertions" : [ { "associatedOffset" : 2, "element" : 1, "isRemove" : true, "offset" : 0 } ],
509+
"removals" : [ { "associatedOffset" : null, "element" : 4, "isRemove" : true, "offset" : 2 } ]
510+
}
511+
"""#,
512+
errorKind: .dataCorrupted)
513+
}
395514

396515
// MARK: - ContiguousArray
397516
lazy var contiguousArrayValues: [Int : ContiguousArray<String>] = [
@@ -789,6 +908,21 @@ class TestCodable : TestCodableSuper {
789908
expectEqual(value.upperBound, decoded.upperBound, "\(#file):\(#line): Decoded Range upperBound<\(debugDescription(decoded))> not equal to original <\(debugDescription(value))>")
790909
expectEqual(value.lowerBound, decoded.lowerBound, "\(#file):\(#line): Decoded Range lowerBound<\(debugDescription(decoded))> not equal to original <\(debugDescription(value))>")
791910
}
911+
912+
func test_Range_JSON_Errors() {
913+
expectDecodingErrorViaJSON(
914+
type: Range<Int>.self,
915+
json: "[5,0]",
916+
errorKind: .dataCorrupted)
917+
expectDecodingErrorViaJSON(
918+
type: Range<Int>.self,
919+
json: "[5,]",
920+
errorKind: .valueNotFound)
921+
expectDecodingErrorViaJSON(
922+
type: Range<Int>.self,
923+
json: "[0,Hello]",
924+
errorKind: .dataCorrupted)
925+
}
792926

793927
// MARK: - TimeZone
794928
lazy var timeZoneValues: [Int : TimeZone] = [
@@ -808,7 +942,7 @@ class TestCodable : TestCodableSuper {
808942
expectRoundTripEqualityThroughPlist(for: timeZone, lineNumber: testLine)
809943
}
810944
}
811-
945+
812946
// MARK: - URL
813947
lazy var urlValues: [Int : URL] = {
814948
var values: [Int : URL] = [
@@ -845,7 +979,7 @@ class TestCodable : TestCodableSuper {
845979
expectRoundTripEqualityThroughPlist(for: url, lineNumber: testLine)
846980
}
847981
}
848-
982+
849983
// MARK: - URLComponents
850984
lazy var urlComponentsValues: [Int : URLComponents] = [
851985
#line : URLComponents(),
@@ -1016,6 +1150,10 @@ var tests = [
10161150
"test_CGVector_Plist" : TestCodable.test_CGVector_Plist,
10171151
"test_ClosedRange_JSON" : TestCodable.test_ClosedRange_JSON,
10181152
"test_ClosedRange_Plist" : TestCodable.test_ClosedRange_Plist,
1153+
"test_ClosedRange_JSON_Errors" : TestCodable.test_ClosedRange_JSON_Errors,
1154+
"test_CollectionDifference_JSON" : TestCodable.test_CollectionDifference_JSON,
1155+
"test_CollectionDifference_Plist" : TestCodable.test_CollectionDifference_Plist,
1156+
"test_CollectionDifference_JSON_Errors" : TestCodable.test_CollectionDifference_JSON_Errors,
10191157
"test_ContiguousArray_JSON" : TestCodable.test_ContiguousArray_JSON,
10201158
"test_ContiguousArray_Plist" : TestCodable.test_ContiguousArray_Plist,
10211159
"test_DateComponents_JSON" : TestCodable.test_DateComponents_JSON,
@@ -1038,6 +1176,7 @@ var tests = [
10381176
"test_PartialRangeUpTo_Plist" : TestCodable.test_PartialRangeUpTo_Plist,
10391177
"test_Range_JSON" : TestCodable.test_Range_JSON,
10401178
"test_Range_Plist" : TestCodable.test_Range_Plist,
1179+
"test_Range_JSON_Errors" : TestCodable.test_Range_JSON_Errors,
10411180
"test_TimeZone_JSON" : TestCodable.test_TimeZone_JSON,
10421181
"test_TimeZone_Plist" : TestCodable.test_TimeZone_Plist,
10431182
"test_URL_JSON" : TestCodable.test_URL_JSON,

0 commit comments

Comments
 (0)