From 6ed0180aa405642990e6475a805de14ccfa4e80e Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 19:20:02 -0700 Subject: [PATCH] Drop identifiers that are empty after normalization An identifier whose value normalizes to nothing, like .postalCode(" ") or .custom(2, ""), was sent as a bare "prefix:". .emailAddress("") and .phoneNumber(" ") were worse: they sent the SHA256 of an empty string, which looks like a real ID. All of these now encode to an empty string and are dropped from identify and targeting. Targeting hids now skip empty identifiers, as ids already did, instead of sending an empty hid=. Co-Authored-By: Claude Opus 5.5 --- Source/Core/EdgeAPI.swift | 3 ++- Source/Core/OptableIdentifierEncoder.swift | 15 +++++++++---- Tests/Unit/EdgeAPITests.swift | 10 +++++++++ .../Unit/OptableIdentifierEncoderTests.swift | 21 +++++++++++++++++++ Tests/Unit/OptableIdentifiersTests.swift | 6 +++--- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 5e61c85..f5f35e9 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -81,7 +81,8 @@ final class EdgeAPI: Sendable { .compactMap({ URLQueryItem(name: "id", value: $0) }) let hidQueryItems = hids - .compactMap({ $0.extendedIdentifier }) + .map({ $0.extendedIdentifier }) + .filter({ $0.isEmpty == false }) .compactMap({ URLQueryItem(name: "hid", value: $0) }) queryItems.append(contentsOf: hidQueryItems) diff --git a/Source/Core/OptableIdentifierEncoder.swift b/Source/Core/OptableIdentifierEncoder.swift index 8a65694..8aa0ab9 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -31,6 +31,9 @@ enum OptableIdentifierEncoder { case let .hem(value): hem(prefix, value) case let .hashedPhoneNumber(value): hashedPhoneNumber(prefix, value) } + + // An identifier whose value is empty once normalized is dropped rather than sent as "prefix:" + guard let separator = eid.firstIndex(of: ":"), eid.index(after: separator) < eid.endIndex else { return "" } return eid } @@ -50,16 +53,20 @@ enum OptableIdentifierEncoder { } /// Builds Extended Identifier from Email address + /// Returns an empty string when nothing is left to hash, so the SHA256 of an empty string is never sent. static func email(_ prefix: String, _ email: String) -> String { - let normalizedData = Data(email.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased().utf8) - let identifier = sha256(data: normalizedData) + let normalized = email.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased() + guard normalized.isEmpty == false else { return "" } + let identifier = sha256(data: Data(normalized.utf8)) return "\(prefix):\(identifier)" } /// Builds Extended Identifier from Phone number + /// Returns an empty string when nothing is left to hash, so the SHA256 of an empty string is never sent. static func phoneNumber(_ prefix: String, _ phoneNumber: String) -> String { - let normalizedData = Data(phoneNumber.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased().utf8) - let identifier = sha256(data: normalizedData) + let normalized = phoneNumber.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased() + guard normalized.isEmpty == false else { return "" } + let identifier = sha256(data: Data(normalized.utf8)) return "\(prefix):\(identifier)" } diff --git a/Tests/Unit/EdgeAPITests.swift b/Tests/Unit/EdgeAPITests.swift index 0894270..bace3e6 100644 --- a/Tests/Unit/EdgeAPITests.swift +++ b/Tests/Unit/EdgeAPITests.swift @@ -368,6 +368,16 @@ class EdgeAPITests: XCTestCase { /** For more info check: [](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/optable-real-time-api-endpoints/profile) */ + func test_targeting_request_drops_blank_identifiers() throws { + let postalCode: OptableIdentifier = .postalCode("H2X 1Y4") + let blank: OptableIdentifier = .postalCode(" ") + let urlRequest = try sdk.api.targeting(ids: [postalCode, blank], hids: [postalCode, blank]) + let queryItems = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)!.queryItems ?? [] + + XCTAssertEqual(queryItems.filter({ $0.name == "id" }).map(\.value), [postalCode.extendedIdentifier]) + XCTAssertEqual(queryItems.filter({ $0.name == "hid" }).map(\.value), [postalCode.extendedIdentifier]) + } + func test_profile_request_generation() throws { let urlRequest = try sdk.api.profile(traits: ["test-key": "test-value"], id: "c:id2", neighbors: ["c:id1", "c:id3"]) diff --git a/Tests/Unit/OptableIdentifierEncoderTests.swift b/Tests/Unit/OptableIdentifierEncoderTests.swift index ea44ed1..5a59ff0 100644 --- a/Tests/Unit/OptableIdentifierEncoderTests.swift +++ b/Tests/Unit/OptableIdentifierEncoderTests.swift @@ -142,6 +142,27 @@ class OptableIdentifierEncoderTests: XCTestCase { XCTAssertNotEqual(unexpected, SUT.custom(prefix, "foobarBAZ-01234#98765.!!!")) } + func test_emptyAfterNormalization_isDropped() { + let blank = " \n " + let identifiers: [OptableIdentifier] = [ + .emailAddress(blank), .phoneNumber(blank), .postalCode(blank), + .ipv4Address(blank), .ipv6Address(blank), + .appleIDFA(blank), .googleGAID(blank), .rokuRIDA(blank), .samsungTIFA(blank), .amazonFireAFAI(blank), + .netID(blank), .id5(blank), .utiq(blank), + .custom(nil, blank), .custom(2, blank), .optableVID(blank), + .hem(blank), .hashedPhoneNumber(blank), + ] + + for identifier in identifiers { + XCTAssertEqual("", identifier.extendedIdentifier, "\(identifier) should be dropped") + } + } + + func test_email_and_phoneNumber_neverSendTheHashOfAnEmptyString() { + XCTAssertEqual("", SUT.email("e", "")) + XCTAssertEqual("", SUT.phoneNumber("p", " ")) + } + func test_hem() { let prefix = OptableIdentifier.hem("").prefix let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" diff --git a/Tests/Unit/OptableIdentifiersTests.swift b/Tests/Unit/OptableIdentifiersTests.swift index a839baf..73d0fa2 100644 --- a/Tests/Unit/OptableIdentifiersTests.swift +++ b/Tests/Unit/OptableIdentifiersTests.swift @@ -27,7 +27,7 @@ class OptableIdentifiersTests: XCTestCase { .id5("ID5*UDWnp3JOtWV0ky-bHvEeU4xOVHXCmYeg24YigF8iAymUHplfYSElM3fy79h8p-Fg"), .utiq("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), .custom(nil, "d29c551097b9dd0b82423827f65161232efaf7fc"), - .custom(2, ""), + .custom(2, "two"), .custom(1, "AaaZza.dh012"), .custom(1, "another c1"), ] @@ -52,7 +52,7 @@ class OptableIdentifiersTests: XCTestCase { XCTAssertTrue(decodedData.contains(where: { $0 == "f:6e853799-ef31-4a30-8706-9742be254d38" })) XCTAssertTrue(decodedData.contains(where: { $0 == "n:_YV2v2Uhx3vqeH47Rrhzgr-4c3VNsxis4M1WY9qn--QTbVapax5VM2HJykoGAyWcwS5lKQ" })) XCTAssertTrue(decodedData.contains(where: { $0 == "c:d29c551097b9dd0b82423827f65161232efaf7fc" })) - XCTAssertTrue(decodedData.contains(where: { $0 == "c2:" })) + XCTAssertTrue(decodedData.contains(where: { $0 == "c2:two" })) XCTAssertTrue(decodedData.contains(where: { $0 == "c1:AaaZza.dh012" })) // Test multiple similar ids existance ( double c1 ) @@ -62,7 +62,7 @@ class OptableIdentifiersTests: XCTestCase { let c_Idx = try XCTUnwrap(decodedData.firstIndex(of: "c:d29c551097b9dd0b82423827f65161232efaf7fc")) let c1_Idx = try XCTUnwrap(decodedData.firstIndex(of: "c1:AaaZza.dh012")) - let c2_Idx = try XCTUnwrap(decodedData.firstIndex(of: "c2:")) + let c2_Idx = try XCTUnwrap(decodedData.firstIndex(of: "c2:two")) XCTAssert(c_Idx < c2_Idx) XCTAssert(c2_Idx < c1_Idx)