Skip to content

Commit 6fe73e7

Browse files
Prevent accessing unicode scalar element with incorrect index.
This change prevents a potential DocC crash by making sure that it does not access an array element using an index that is outside of the bonds of the array. `.index(after:` can crash in some cases if the passed index belongs to the last element of the collection. with this change we ensure that the passed index is not the last index. rdar://150760264
1 parent 94eaaa3 commit 6fe73e7

File tree

2 files changed

+42
-19
lines changed

2 files changed

+42
-19
lines changed

Sources/SwiftDocC/Utility/ValidatedURL.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,17 +178,26 @@ private extension StringProtocol {
178178
/// Returns a percent encoded version of the string or the original string if it is already percent encoded.
179179
func addingPercentEncodingIfNeeded(withAllowedCharacters allowedCharacters: CharacterSet) -> String? {
180180
var needsPercentEncoding: Bool {
181-
for (index, character) in unicodeScalars.indexed() where !allowedCharacters.contains(character) {
181+
let unicodeScalars = unicodeScalars.filter { !allowedCharacters.contains($0) }
182+
for (index, character) in unicodeScalars.indexed() {
182183
if character == "%" {
184+
// There's not two characters after the "%". This "%" can't represent a percent encoded character.
185+
guard index != unicodeScalars.endIndex || index != unicodeScalars.index(before: index) else {
186+
return true
187+
}
183188
// % isn't allowed in a URL fragment but it is also the escape character for percent encoding.
189+
guard unicodeScalars.distance(from: index, to: unicodeScalars.endIndex) <= 2 else {
190+
// There's not two characters after the "%". This "%" can't represent a percent encoded character.
191+
return true
192+
}
184193
let firstFollowingIndex = unicodeScalars.index(after: index)
185194
let secondFollowingIndex = unicodeScalars.index(after: firstFollowingIndex)
186195

187196
guard secondFollowingIndex < unicodeScalars.endIndex else {
188197
// There's not two characters after the "%". This "%" can't represent a percent encoded character.
189198
return true
190199
}
191-
// If either of the two following characters aren't hex digits, the "%" doesn't represent a
200+
// If either of the two following characters aren't hex digits, the "%" doesn't represent a percent encoded character.
192201
return !Character(unicodeScalars[firstFollowingIndex]).isHexDigit
193202
|| !Character(unicodeScalars[secondFollowingIndex]).isHexDigit
194203

@@ -203,7 +212,10 @@ private extension StringProtocol {
203212
return if needsPercentEncoding {
204213
addingPercentEncoding(withAllowedCharacters: allowedCharacters)
205214
} else {
206-
String(self)
215+
// Create a new string by mapping over the character helps
216+
// preventing crashes when handling substrings that contains
217+
// multibyte characters.
218+
self.map { String($0) }.joined()
207219
}
208220
}
209221
}

Tests/SwiftDocCTests/Utility/ValidatedURLTests.swift

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ class ValidatedURLTests: XCTestCase {
8181
}
8282

8383
func testQueryIsPartOfPathForAuthoredLinks() throws {
84+
85+
func validate(linkText: String, expectedPath: String, expectedFragment: String? = nil) throws {
86+
let validated = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText), "Failed to parse \(linkText.singleQuoted) as authored link")
87+
XCTAssertNil(validated.components.queryItems, "Authored documentation links don't include query items")
88+
XCTAssertEqual(validated.components.path, expectedPath)
89+
XCTAssertEqual(validated.components.fragment, expectedFragment)
90+
}
91+
8492
// Test return type disambiguation
8593
for linkText in [
8694
"SymbolName/memberName()->Int?",
@@ -91,14 +99,8 @@ class ValidatedURLTests: XCTestCase {
9199
? "/SymbolName/memberName()->Int?"
92100
: "SymbolName/memberName()->Int?"
93101

94-
let validated = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText), "Failed to parse \(linkText.singleQuoted) as authored link")
95-
XCTAssertNil(validated.components.queryItems, "Authored documentation links don't include query items")
96-
XCTAssertEqual(validated.components.path, expectedPath)
97-
98-
let validatedWithHeading = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText + "#Heading-Name"), "Failed to parse '\(linkText)#Heading-Name' as authored link")
99-
XCTAssertNil(validatedWithHeading.components.queryItems, "Authored documentation links don't include query items")
100-
XCTAssertEqual(validatedWithHeading.components.path, expectedPath)
101-
XCTAssertEqual(validatedWithHeading.components.fragment, "Heading-Name")
102+
try validate(linkText: linkText, expectedPath: expectedPath)
103+
try validate(linkText: linkText + "#Heading-Name", expectedPath: expectedPath, expectedFragment: "Heading-Name")
102104
}
103105

104106
// Test parameter type disambiguation
@@ -111,15 +113,24 @@ class ValidatedURLTests: XCTestCase {
111113
? "/SymbolName/memberName(with:and:)-(Int?,_)"
112114
: "SymbolName/memberName(with:and:)-(Int?,_)"
113115

114-
let validated = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText), "Failed to parse \(linkText.singleQuoted) as authored link")
115-
XCTAssertNil(validated.components.queryItems, "Authored documentation links don't include query items")
116-
XCTAssertEqual(validated.components.path, expectedPath)
117-
118-
let validatedWithHeading = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText + "#Heading-Name"), "Failed to parse '\(linkText)#Heading-Name' as authored link")
119-
XCTAssertNil(validatedWithHeading.components.queryItems, "Authored documentation links don't include query items")
120-
XCTAssertEqual(validatedWithHeading.components.path, expectedPath)
121-
XCTAssertEqual(validatedWithHeading.components.fragment, "Heading-Name")
116+
try validate(linkText: linkText, expectedPath: expectedPath)
117+
try validate(linkText: linkText + "#Heading-Name", expectedPath: expectedPath, expectedFragment: "Heading-Name")
122118
}
119+
120+
// Test parameter with percent encoding
121+
var linkText = "doc://com.example.test/docc=Whats%20New&version=DocC&Title=[Update]"
122+
var expectedPath = "/docc=Whats%20New&version=DocC&Title=[Update]"
123+
try validate(linkText: linkText, expectedPath: expectedPath)
124+
125+
// Test parameter without percent encoding
126+
linkText = "doc://com.example.test/docc=WhatsNew&version=DocC&Title=[Update]"
127+
expectedPath = "/docc=WhatsNew&version=DocC&Title=[Update]"
128+
try validate(linkText: linkText, expectedPath: expectedPath)
129+
130+
// Test parameter with special characters
131+
linkText = "doc://com.example.test/テスト"
132+
expectedPath = "/テスト"
133+
try validate(linkText: linkText, expectedPath: expectedPath)
123134
}
124135

125136
func testEscapedFragment() throws {

0 commit comments

Comments
 (0)