Skip to content

Commit 2c5fbfe

Browse files
committed
Ban assigning KeyValuePairs between different URLs
1 parent cc20ccb commit 2c5fbfe

File tree

2 files changed

+150
-82
lines changed

2 files changed

+150
-82
lines changed

Sources/WebURL/WebURL+KeyValuePairs.swift

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ extension WebURL {
259259
/// // ^^^^^^
260260
/// ```
261261
///
262-
/// Do not reassign the given `KeyValuePairs` to a value taken from another URL.
262+
/// Do not reassign the given `KeyValuePairs` to a view from another URL or component.
263263
///
264264
/// > Tip:
265265
/// >
@@ -277,16 +277,29 @@ extension WebURL {
277277
schema: Schema,
278278
_ body: (inout KeyValuePairs<Schema>) throws -> Result
279279
) rethrows -> Result {
280-
// TODO: How can we protect against reassignment?
281-
// ```
282-
// let kvps = urlA.keyValuePairs(...)
283-
// urlB.withMutableKeyValuePairs(...) { $0 = kvps }
284-
// print(urlB) // !!! - reassigns the entire URL.
285-
// ```
286-
// Maybe stash a unique token in the KeyValuePairs view.
287-
var view = KeyValuePairs(storage: storage, component: component, schema: schema)
280+
281+
// Since the entire URL storage is being moved in to the view, we cannot allow reassignment,
282+
// otherwise when we pick up the storage at the end of the scope, we would adopt the storage
283+
// of an entirely different URL, and other components such as the scheme/hostname/etc could change.
284+
// To protect against this, each time a mutating view is offered in a scope, it is assigned a unique token,
285+
// which is checked again when the scope ends.
286+
//
287+
// Unfortunately, producing fully unique tokens would require syscalls (expensive, non-portable),
288+
// or atomics (not built-in to Swift). A cheaper alternative is to use the storage address at view creation.
289+
// This can alias with other views created from the same URL, so reassignment is still possible,
290+
// but it at least allows us to guarantee that other components (e.g. scheme/hostname)
291+
// cannot change if reassignment is successful.
292+
293+
let token = storage.codeUnits.withUnsafeBufferPointer { UInt(bitPattern: $0.baseAddress) }
294+
var view = KeyValuePairs(storage: storage, component: component, schema: schema, mutatingScopeID: token)
288295
storage = _tempStorage
289-
defer { storage = view.storage }
296+
defer {
297+
precondition(
298+
view.mutatingScopeID == token && view.component.value == component.value,
299+
"KeyValuePairs was reassigned from another URL or component"
300+
)
301+
storage = view.storage
302+
}
290303
return try body(&view)
291304
}
292305
}
@@ -345,9 +358,16 @@ extension WebURL {
345358
KeyValuePairs(storage: storage, component: .query, schema: .formEncoded)
346359
}
347360
_modify {
348-
var view = KeyValuePairs(storage: storage, component: .query, schema: .formEncoded)
361+
let token = storage.codeUnits.withUnsafeBufferPointer { UInt(bitPattern: $0.baseAddress) }
362+
var view = KeyValuePairs(storage: storage, component: .query, schema: .formEncoded, mutatingScopeID: token)
349363
storage = _tempStorage
350-
defer { storage = view.storage }
364+
defer {
365+
precondition(
366+
view.mutatingScopeID == token && view.component.value == .query,
367+
"KeyValuePairs view was reassigned from another URL or component"
368+
)
369+
storage = view.storage
370+
}
351371
yield &view
352372
}
353373
set {
@@ -955,6 +975,14 @@ extension WebURL {
955975
@usableFromInline
956976
internal var schema: Schema
957977

978+
/// A token for mutable views in to which storage has been temporarily moved.
979+
///
980+
/// See ``WebURL/WebURL/withMutableKeyValuePairs(in:schema:_:)`` for usage.
981+
/// Views which do not contain moved storage should set this to 0.
982+
///
983+
@usableFromInline
984+
internal let mutatingScopeID: UInt
985+
958986
/// Data about the key-value string which is derived from the other properties,
959987
/// stored to accelerate certain operations which may access it frequently.
960988
///
@@ -965,10 +993,16 @@ extension WebURL {
965993
internal var cache: Cache
966994

967995
@inlinable
968-
internal init(storage: URLStorage, component: KeyValuePairsSupportedComponent, schema: Schema) {
996+
internal init(
997+
storage: URLStorage,
998+
component: KeyValuePairsSupportedComponent,
999+
schema: Schema,
1000+
mutatingScopeID: UInt = 0
1001+
) {
9691002
self.storage = storage
9701003
self.component = component
9711004
self.schema = schema
1005+
self.mutatingScopeID = mutatingScopeID
9721006
self.cache = .calculate(storage: storage, component: component, schema: schema)
9731007
}
9741008
}

Tests/WebURLTests/KeyValuePairs/KeyValuePairsTests.swift

Lines changed: 103 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -3770,14 +3770,6 @@ extension KeyValuePairsTests {
37703770
)
37713771
}
37723772

3773-
func testAssignment() {
3774-
// FIXME: Test assigning a KeyValuePairs instance from another URL.
3775-
}
3776-
}
3777-
3778-
3779-
extension KeyValuePairsTests {
3780-
37813773
/// Tests using a key-value string schema with ASCII alpha delimiters.
37823774
///
37833775
/// While not recommended, in theory it should work.
@@ -3812,75 +3804,117 @@ extension KeyValuePairsTests {
38123804
}
38133805
}
38143806

3807+
extension KeyValuePairsTests {
3808+
3809+
/// Tests assigning a KeyValuePairs view from one URL to another.
3810+
///
3811+
/// This is not generally allowed, but there are a handful of situations where it works.
3812+
/// Unfortunately we can't test the situations where it doesn't work, because they trigger a fatal error
3813+
/// and XCTest doesn't support testing such things.
3814+
///
3815+
func testAssignment() {
3816+
3817+
// Straightforward 'queryParams' assignment.
3818+
// This works because 'queryParams' includes a 'set' accessor, in addition to 'modify'.
38153819

3820+
do {
3821+
let srcQuery = "test=src&foo=bar&specials=\(Self.SpecialCharacters_Escaped_PrctEnc_Query)"
38163822

3823+
let src = WebURL("http://src/?\(srcQuery)")!
3824+
var dst = WebURL("file://dst/")!
3825+
XCTAssertEqual(src.queryParams.count, 3)
3826+
XCTAssertEqual(dst.queryParams.count, 0)
38173827

3828+
dst.queryParams = src.queryParams
38183829

3830+
XCTAssertEqual(src.serialized(), "http://src/?\(srcQuery)")
3831+
XCTAssertEqual(dst.serialized(), "file://dst/?\(srcQuery)")
3832+
XCTAssertEqual(src.queryParams.count, 3)
3833+
XCTAssertEqual(dst.queryParams.count, 3)
3834+
}
38193835

3836+
// Assigning different views originating from the same storage.
3837+
// This works basically as a side-effect of how the mutating scope ID is calculated 😕,
3838+
// but it's also not _really_ a problem because data outside the viewed component cannot be changed.
38203839

3840+
do {
3841+
var url1 = WebURL("file://xyz/")!
3842+
var url2 = url1
3843+
3844+
url2.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { kvps2 in
3845+
url1.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { kvps1 in
3846+
kvps2 += [("hello", "world")]
3847+
kvps1 = kvps2
3848+
kvps2 += [("after", "wards")]
3849+
}
3850+
}
3851+
3852+
XCTAssertEqual(url1.serialized(), "file://xyz/?hello=world")
3853+
XCTAssertEqual(url2.serialized(), "file://xyz/?hello=world&after=wards")
3854+
}
38213855

3856+
// These should all cause runtime traps.
3857+
// Unfortunately we can't test that with XCTest :(
38223858

3859+
#if false
38233860

3861+
let check = 0
38243862

3863+
// Reassignment via modify accessor. Different source URLs.
38253864

3826-
// func testAssignment() {
3827-
//
3828-
// do {
3829-
// var url0 = WebURL("http://example.com?a=b&c+is the key=d&&e=&=foo&e=g&e&h=👀&e=f")!
3830-
// XCTAssertEqual(url0.serialized(), "http://example.com/?a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f")
3831-
// XCTAssertEqual(url0.query, "a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f")
3832-
// XCTAssertFalse(url0.storage.structure.queryIsKnownFormEncoded)
3833-
//
3834-
// var url1 = WebURL("foo://bar")!
3835-
// XCTAssertEqual(url1.serialized(), "foo://bar")
3836-
// XCTAssertNil(url1.query)
3837-
// XCTAssertTrue(url1.storage.structure.queryIsKnownFormEncoded)
3838-
//
3839-
// // Set url1's formParams from empty to url0's non-empty formParams.
3840-
// // url1's query string should be the form-encoded version version of url0's query, which itself remains unchanged.
3841-
// url1.queryParams = url0.queryParams
3842-
// XCTAssertEqual(url1.serialized(), "foo://bar?a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f")
3843-
// XCTAssertEqual(url1.query, "a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f")
3844-
// XCTAssertEqual(url0.serialized(), "http://example.com/?a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f")
3845-
// XCTAssertEqual(url0.query, "a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f")
3846-
// XCTAssertFalse(url0.storage.structure.queryIsKnownFormEncoded)
3847-
// XCTAssertFalse(url1.storage.structure.queryIsKnownFormEncoded)
3848-
// XCTAssertURLIsIdempotent(url1)
3849-
//
3850-
// // Reset url1 to a nil query. Set url0's non-empty params to url1's empty params.
3851-
// // url0 should now have a nil query, and url1 remains unchanged.
3852-
// url1 = WebURL("foo://bar")!
3853-
// XCTAssertEqual(url1.serialized(), "foo://bar")
3854-
// XCTAssertNil(url1.query)
3855-
// XCTAssertTrue(url1.storage.structure.queryIsKnownFormEncoded)
3856-
//
3857-
// url0.queryParams = url1.queryParams
3858-
// XCTAssertEqual(url0.serialized(), "http://example.com/")
3859-
// XCTAssertNil(url0.query)
3860-
// XCTAssertTrue(url0.storage.structure.queryIsKnownFormEncoded)
3861-
// XCTAssertEqual(url1.serialized(), "foo://bar")
3862-
// XCTAssertNil(url1.query)
3863-
// XCTAssertTrue(url1.storage.structure.queryIsKnownFormEncoded)
3864-
// XCTAssertURLIsIdempotent(url0)
3865-
// }
3866-
//
3867-
// // Assigning a URL's query parameters to itself has no effect.
3868-
// do {
3869-
// var url = WebURL("http://example.com?a=b&c+is the key=d&&e=&=foo&e=g&e&h=👀&e=f&&&")!
3870-
// XCTAssertEqual(
3871-
// url.serialized(), "http://example.com/?a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f&&&"
3872-
// )
3873-
// XCTAssertEqual(url.query, "a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f&&&")
3874-
// XCTAssertFalse(url.storage.structure.queryIsKnownFormEncoded)
3875-
//
3876-
// url.queryParams = url.queryParams
3877-
// XCTAssertEqual(
3878-
// url.serialized(), "http://example.com/?a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f&&&"
3879-
// )
3880-
// XCTAssertEqual(url.query, "a=b&c+is%20the%20key=d&&e=&=foo&e=g&e&h=%F0%9F%91%80&e=f&&&")
3881-
// XCTAssertFalse(url.storage.structure.queryIsKnownFormEncoded)
3882-
// XCTAssertURLIsIdempotent(url)
3883-
// }
3884-
// }
3885-
//
3886-
//}
3865+
if check == 0 {
3866+
let src = WebURL("http://src/")!
3867+
var dst = WebURL("file://dst/")!
3868+
3869+
@inline(never)
3870+
func assign<T>(_ x: inout T, to y: T) {
3871+
x = y
3872+
}
3873+
3874+
assign(&dst.queryParams, to: src.queryParams)
3875+
XCTFail("Should have trapped")
3876+
}
3877+
3878+
// Reassignment via modify accessor. Same source URLs, different components.
3879+
3880+
if check == 1 {
3881+
let url1 = WebURL("http://xyz/")!
3882+
var url2 = url1
3883+
3884+
@inline(never)
3885+
func assign<T>(_ x: inout T, to y: T) {
3886+
x = y
3887+
}
3888+
3889+
assign(&url2.queryParams, to: url1.keyValuePairs(in: .fragment, schema: .formEncoded))
3890+
XCTFail("Should have trapped")
3891+
}
3892+
3893+
// Reassignment via scoped method. Different source URLs.
3894+
3895+
if check == 2 {
3896+
let src = WebURL("http://src/")!
3897+
var dst = WebURL("file://dst/")!
3898+
3899+
dst.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { $0 = src.queryParams }
3900+
XCTFail("Should have trapped")
3901+
}
3902+
3903+
// Reassignment via scoped method. Same source URLs, different components.
3904+
3905+
if check == 3 {
3906+
var url1 = WebURL("file://xyz/")!
3907+
var url2 = url1
3908+
3909+
url1.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { kvps1 in
3910+
url2.withMutableKeyValuePairs(in: .fragment, schema: .formEncoded) { kvps2 in
3911+
kvps2 += [("hello", "world")]
3912+
kvps1 = kvps2
3913+
}
3914+
}
3915+
XCTFail("Should have trapped")
3916+
}
3917+
3918+
#endif
3919+
}
3920+
}

0 commit comments

Comments
 (0)