Skip to content

Commit 2d24224

Browse files
rickhohlermattt
andauthored
Prevent stack overflow crashes with recursion limit (#21)
* Save all fixes * fix: Prevent stack overflow crashes with recursion limit * fix: address review feedback (cleanup + max+1 tests) * test: Reduce recursion limit in test to avoid stack overflow on CI * Reduce recursion test depth to avoid CI stack limits. * chore: remove test output artifact * Remove BugHuntingTests.swift * Add recursionLimitTriggersOnDeepEncoding test * Add EncodingLimits type and reframe recursion limits in terms of that Reorder declarations --------- Co-authored-by: Mattt Zmuda <mattt@me.com>
1 parent d17c428 commit 2d24224

3 files changed

Lines changed: 134 additions & 43 deletions

File tree

Sources/ToonFormat/Encoder.swift

Lines changed: 101 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,57 @@ import Foundation
55
/// This encoder conforms to the TOON (Token-Oriented Object Notation) specification version 3.0.
66
/// For more information, see https://github.com/toon-format/spec
77
public final class TOONEncoder {
8-
98
/// The number of spaces per indentation level.
109
public var indent: Int = 2
1110

11+
/// The delimiter character used to separate array values and tabular row cells.
12+
///
13+
/// The delimiter determines how multiple values are separated in inline arrays
14+
/// and tabular data rows.
15+
///
16+
/// Example with `.comma`:
17+
/// ```toon
18+
/// tags[3]: reading,gaming,coding
19+
/// ```
20+
///
21+
/// Example with `.tab`:
22+
/// ```toon
23+
/// items[2 ]{sku name}:
24+
/// A1 Widget
25+
/// B2 Gadget
26+
/// ```
27+
///
28+
/// Example with `.pipe`:
29+
/// ```toon
30+
/// items[2|]{sku|name}:
31+
/// A1|Widget
32+
/// B2|Gadget
33+
/// ```
34+
public enum Delimiter: String, CaseIterable, Hashable, Sendable {
35+
/// Comma separator (`,`).
36+
case comma = ","
37+
38+
/// Tab separator (`\t`).
39+
case tab = "\t"
40+
41+
/// Pipe separator (`|`).
42+
case pipe = "|"
43+
}
44+
1245
/// The delimiter to use for array values and tabular rows.
1346
public var delimiter: Delimiter = .comma
1447

48+
/// Key folding mode.
49+
public enum KeyFolding: Hashable, Sendable {
50+
/// No key folding.
51+
case disabled
52+
53+
/// Safe key folding.
54+
///
55+
/// Only folds when all segments are valid identifiers.
56+
case safe
57+
}
58+
1559
/// Key folding mode for collapsing single-key object chains into dotted paths.
1660
///
1761
/// When enabled, single-key nested objects like `{ a: { b: { c: 1 } } }`
@@ -39,58 +83,41 @@ public final class TOONEncoder {
3983
/// - Output: `a.b.c: 1`
4084
public var flattenDepth: Int = .max
4185

42-
/// Key folding mode.
43-
public enum KeyFolding: Hashable, Sendable {
44-
/// No key folding.
45-
case disabled
86+
/// Limits for encoding to prevent resource exhaustion.
87+
public struct EncodingLimits: Hashable, Sendable {
88+
/// Maximum nesting depth.
89+
public var maxDepth: Int
4690

47-
/// Safe key folding.
91+
/// Default limits suitable for most use cases.
4892
///
49-
/// Only folds when all segments are valid identifiers.
50-
case safe
51-
}
52-
53-
/// The delimiter character used to separate array values and tabular row cells.
54-
///
55-
/// The delimiter determines how multiple values are separated in inline arrays
56-
/// and tabular data rows.
57-
///
58-
/// Example with `.comma`:
59-
/// ```toon
60-
/// tags[3]: reading,gaming,coding
61-
/// ```
62-
///
63-
/// Example with `.tab`:
64-
/// ```toon
65-
/// items[2 ]{sku name}:
66-
/// A1 Widget
67-
/// B2 Gadget
68-
/// ```
69-
///
70-
/// Example with `.pipe`:
71-
/// ```toon
72-
/// items[2|]{sku|name}:
73-
/// A1|Widget
74-
/// B2|Gadget
75-
/// ```
76-
public enum Delimiter: String, CaseIterable, Hashable, Sendable {
77-
/// Comma separator (`,`).
78-
case comma = ","
93+
/// - `maxDepth`: 32 (prevents stack overflow from deep nesting)
94+
public static let `default` = EncodingLimits(maxDepth: 32)
7995

80-
/// Tab separator (`\t`).
81-
case tab = "\t"
96+
/// Encoding limits that impose no restrictions.
97+
///
98+
/// - Warning: This configuration is unsafe for untrusted input
99+
/// and should only be used with trusted data.
100+
public static let unlimited = EncodingLimits(maxDepth: .max)
82101

83-
/// Pipe separator (`|`).
84-
case pipe = "|"
102+
public init(maxDepth: Int) {
103+
self.maxDepth = maxDepth
104+
}
85105
}
86106

107+
/// Limits for encoding to prevent resource exhaustion.
108+
///
109+
/// Use this to protect against accidental or malicious deep nesting
110+
/// when encoding untrusted data.
111+
public var limits: EncodingLimits = .default
112+
87113
/// Creates a new TOON encoder with default configuration.
88114
///
89115
/// Default settings:
90116
/// - `indent`: 2 spaces
91117
/// - `delimiter`: `.comma`
92118
/// - `keyFolding`: `.disabled`
93119
/// - `flattenDepth`: `Int.max`
120+
/// - `limits`: `.default`
94121
public init() {}
95122

96123
/// Encodes the given value into TOON format.
@@ -114,7 +141,9 @@ public final class TOONEncoder {
114141
} else if mirror.subjectType == Data.self, let data = value as? Data {
115142
v = .data(data)
116143
} else {
117-
let encoder = Encoder(userInfo: [:])
144+
var userInfo = [CodingUserInfoKey: Any]()
145+
userInfo[.toonEncodingMaxDepth] = limits.maxDepth
146+
let encoder = Encoder(userInfo: userInfo)
118147
try value.encode(to: encoder)
119148
v = encoder.encodedValue
120149
}
@@ -720,6 +749,10 @@ public final class TOONEncoder {
720749
}
721750
}
722751

752+
fileprivate extension CodingUserInfoKey {
753+
static let toonEncodingMaxDepth = CodingUserInfoKey(rawValue: "toonEncodingMaxDepth")!
754+
}
755+
723756
// MARK: - Internal Encoder
724757

725758
extension TOONEncoder {
@@ -869,6 +902,15 @@ extension TOONEncoder {
869902
}
870903

871904
func encode<T: Encodable>(_ value: T, forKey key: Key) throws {
905+
if let limit = encoder.userInfo[.toonEncodingMaxDepth] as? Int, encoder.codingPath.count > limit {
906+
throw EncodingError.invalidValue(
907+
value,
908+
EncodingError.Context(
909+
codingPath: codingPath + [key],
910+
debugDescription: "Recursion limit of \(limit) exceeded"
911+
)
912+
)
913+
}
872914
trackKey(key.stringValue)
873915

874916
// Handle special types by checking the mirror of the value
@@ -1146,6 +1188,15 @@ extension TOONEncoder {
11461188
}
11471189

11481190
func encode<T: Encodable>(_ value: T) throws {
1191+
if let limit = encoder.userInfo[.toonEncodingMaxDepth] as? Int, encoder.codingPath.count > limit {
1192+
throw EncodingError.invalidValue(
1193+
value,
1194+
EncodingError.Context(
1195+
codingPath: codingPath + [IndexedCodingKey(intValue: count)],
1196+
debugDescription: "Recursion limit of \(limit) exceeded"
1197+
)
1198+
)
1199+
}
11491200
// Handle special types
11501201
let mirror = Mirror(reflecting: value)
11511202
if mirror.subjectType == Date.self, let date = value as? Date {
@@ -1288,6 +1339,15 @@ extension TOONEncoder {
12881339
}
12891340

12901341
func encode<T: Encodable>(_ value: T) throws {
1342+
if let limit = encoder.userInfo[.toonEncodingMaxDepth] as? Int, encoder.codingPath.count > limit {
1343+
throw EncodingError.invalidValue(
1344+
value,
1345+
EncodingError.Context(
1346+
codingPath: codingPath,
1347+
debugDescription: "Recursion limit of \(limit) exceeded"
1348+
)
1349+
)
1350+
}
12911351
// Handle special types
12921352
let mirror = Mirror(reflecting: value)
12931353
if mirror.subjectType == Date.self, let date = value as? Date {

Tests/ToonFormatTests/DecoderTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,7 +1368,7 @@ struct DecoderTests {
13681368
let value: Int8
13691369
}
13701370

1371-
let toon = "value: 200" // Exceeds Int8.max (127)
1371+
let toon = "value: 128" // Int8.max + 1
13721372
let data = toon.data(using: .utf8)!
13731373

13741374
#expect(throws: TOONDecodingError.self) {
@@ -1394,7 +1394,7 @@ struct DecoderTests {
13941394
let value: UInt8
13951395
}
13961396

1397-
let toon = "value: 300" // Exceeds UInt8.max (255)
1397+
let toon = "value: 256" // UInt8.max + 1
13981398
let data = toon.data(using: .utf8)!
13991399

14001400
#expect(throws: TOONDecodingError.self) {

Tests/ToonFormatTests/EncoderTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,37 @@ struct EncoderTests {
15721572
#expect(result == expected)
15731573
}
15741574

1575+
@Test func recursionLimitTriggersOnDeepEncoding() async throws {
1576+
indirect enum NestableValue: Codable, Equatable {
1577+
case int(Int)
1578+
case array([NestableValue])
1579+
}
1580+
1581+
struct Container: Codable, Equatable {
1582+
let value: NestableValue
1583+
}
1584+
1585+
func makeDeepNest(depth: Int) -> NestableValue {
1586+
var value: NestableValue = .int(1)
1587+
for _ in 0 ..< depth {
1588+
value = .array([value])
1589+
}
1590+
return value
1591+
}
1592+
1593+
let encoder = TOONEncoder()
1594+
encoder.limits.maxDepth = 10
1595+
1596+
do {
1597+
_ = try encoder.encode(Container(value: makeDeepNest(depth: 20)))
1598+
#expect(Bool(false))
1599+
} catch EncodingError.invalidValue(_, let context) {
1600+
#expect(context.debugDescription.contains("Recursion limit"))
1601+
} catch {
1602+
#expect(Bool(false))
1603+
}
1604+
}
1605+
15751606
// MARK: - Collision Avoidance Tests (TOON 3.0)
15761607

15771608
@Test func keyFoldingCollisionAvoidance() async throws {

0 commit comments

Comments
 (0)