From f008b97b907648801056031712a6443d9f05126b Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 28 Apr 2026 12:47:08 +0500 Subject: [PATCH 1/3] MOBILE-120: Limit errorDetails to 1000 characters in InappShowFailureManager --- .../InappShowFailureManager.swift | 4 +- .../Tests/InappShowFailureManagerTests.swift | 76 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/Mindbox/InAppMessages/InappShowFailureManager.swift b/Mindbox/InAppMessages/InappShowFailureManager.swift index 27b09e7f..8379c6ef 100644 --- a/Mindbox/InAppMessages/InappShowFailureManager.swift +++ b/Mindbox/InAppMessages/InappShowFailureManager.swift @@ -16,6 +16,8 @@ protocol InappShowFailureManagerProtocol { } final class InappShowFailureManager: InappShowFailureManagerProtocol { + static let errorDetailsLimit = 1000 + private struct InAppShowFailuresBody: Codable { let failures: [InAppShowFailure] } @@ -100,7 +102,7 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { InAppShowFailure( inappId: inappId, failureReason: reason, - errorDetails: details, + errorDetails: details.map { String($0.prefix(Self.errorDetailsLimit)) }, dateTimeUtc: Date().toString(withFormat: .utc) ) } diff --git a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift index a1defff9..bf8712ce 100644 --- a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift +++ b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift @@ -200,6 +200,82 @@ final class InappShowFailureManagerTests: XCTestCase { assertCreatedEventsCountEventually(0) } + func testAddFailure_errorDetailsBelowLimit_isNotTruncated() throws { + let details = String(repeating: "a", count: InappShowFailureManager.errorDetailsLimit - 1) + + manager.addFailure(inappId: "inapp-below-limit", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.errorDetails?.count, InappShowFailureManager.errorDetailsLimit - 1) + XCTAssertEqual(failure.errorDetails, details) + } + + func testAddFailure_errorDetailsAtLimit_isNotTruncated() throws { + let details = String(repeating: "b", count: InappShowFailureManager.errorDetailsLimit) + + manager.addFailure(inappId: "inapp-at-limit", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.errorDetails?.count, InappShowFailureManager.errorDetailsLimit) + XCTAssertEqual(failure.errorDetails, details) + } + + func testAddFailure_errorDetailsAboveLimit_isTruncatedToLimit() throws { + let limit = InappShowFailureManager.errorDetailsLimit + let details = String(repeating: "c", count: limit + 500) + + manager.addFailure(inappId: "inapp-above-limit", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.errorDetails?.count, limit) + XCTAssertEqual(failure.errorDetails, String(details.prefix(limit))) + } + + func testAddFailure_errorDetailsNil_remainsNil() throws { + manager.addFailure(inappId: "inapp-nil-details", reason: .unknownError, details: nil) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertNil(failure.errorDetails) + } + + func testAddFailure_errorDetailsEmpty_remainsEmpty() throws { + manager.addFailure(inappId: "inapp-empty-details", reason: .unknownError, details: "") + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.errorDetails, "") + } + + func testAddFailure_priorityReplacement_truncatesNewDetails() throws { + let limit = InappShowFailureManager.errorDetailsLimit + let longDetails = String(repeating: "d", count: limit + 200) + + manager.addFailure(inappId: "inapp-priority-truncate", reason: .productSegmentRequestFailed, details: "short") + manager.addFailure(inappId: "inapp-priority-truncate", reason: .customerSegmentRequestFailed, details: longDetails) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.failureReason, .customerSegmentRequestFailed) + XCTAssertEqual(failure.errorDetails?.count, limit) + XCTAssertEqual(failure.errorDetails, String(longDetails.prefix(limit))) + } + func testSendFailures_whenFeatureDisabled_doesNotSendAndKeepsBufferedFailures() throws { manager.addFailure( inappId: "inapp-toggle-disabled", From d8338a9ffda2f0e3778567de4f32c886c8f4d686 Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 29 Apr 2026 13:41:37 +0500 Subject: [PATCH 2/3] MOBILE-120: Truncate errorDetails by UTF-8 byte limit and log truncation --- Mindbox.xcodeproj/project.pbxproj | 41 ++------------ .../String+Extensions.swift | 21 ++++++++ .../InappShowFailureManager.swift | 23 ++++++-- .../Extensions/StringExtensionsTests.swift | 17 ++++++ .../Tests/InappShowFailureManagerTests.swift | 53 +++++++++++++++++++ 5 files changed, 113 insertions(+), 42 deletions(-) rename Mindbox/{Validators => Extensions}/String+Extensions.swift (62%) diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 026c0f19..68b62b07 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -1437,39 +1437,9 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = InappConfigurationDataFacade; - sourceTree = ""; - }; - F397DE1C2CFF568800B72DA9 /* JSONs */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = JSONs; - sourceTree = ""; - }; - F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = InappSessionManagerTests; - sourceTree = ""; - }; + F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; + F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; + F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappSessionManagerTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2280,7 +2250,6 @@ F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, ); - name = Network; path = Network; sourceTree = ""; }; @@ -2289,7 +2258,6 @@ children = ( 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */, ); - name = TrackVisitManager; path = TrackVisitManager; sourceTree = ""; }; @@ -2440,7 +2408,6 @@ F3A8B9912A3A408C00E9C055 /* SDKVersionValidator.swift */, F3A8B9992A3A471800E9C055 /* ABTestVariantsValidator.swift */, F3A8B99D2A3A4FD600E9C055 /* ABTestValidator.swift */, - F3482F292A65DCFC002A41EC /* String+Extensions.swift */, F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */, F31A947F2BC7E61800E6C978 /* InappFrequencyValidator.swift */, ); @@ -3016,7 +2983,6 @@ children = ( A1B2C3D4E5F6A7B8C9D0E1F3 /* MotionService.swift */, ); - name = Motion; path = Motion; sourceTree = ""; }; @@ -3591,6 +3557,7 @@ BB65630F2BE3BA430090C473 /* UIApplication+Extensions.swift */, F31DB4072F56A50E00DCEB85 /* NSError+Extensions.swift */, 326423031CA9C6BF0E62BEFD /* Date+Extensions.swift */, + F3482F292A65DCFC002A41EC /* String+Extensions.swift */, ); path = Extensions; sourceTree = ""; diff --git a/Mindbox/Validators/String+Extensions.swift b/Mindbox/Extensions/String+Extensions.swift similarity index 62% rename from Mindbox/Validators/String+Extensions.swift rename to Mindbox/Extensions/String+Extensions.swift index 1b48b8c5..48cbf815 100644 --- a/Mindbox/Validators/String+Extensions.swift +++ b/Mindbox/Extensions/String+Extensions.swift @@ -48,3 +48,24 @@ extension String { return hexValue.unicodeScalars.allSatisfy { hexCharacterSet.contains($0) } } } + +extension String { + /// Truncates the string so its UTF-8 byte representation does not exceed `limit`. + /// Cuts at extended grapheme cluster boundaries to keep multi-byte characters intact. + func truncated(toUTF8ByteLimit limit: Int) -> String { + guard limit >= 0 else { return "" } + guard utf8.count > limit else { return self } + + var result = "" + var byteCount = 0 + for character in self { + let characterByteCount = character.utf8.count + if byteCount + characterByteCount > limit { + break + } + byteCount += characterByteCount + result.append(character) + } + return result + } +} diff --git a/Mindbox/InAppMessages/InappShowFailureManager.swift b/Mindbox/InAppMessages/InappShowFailureManager.swift index 8379c6ef..2711c313 100644 --- a/Mindbox/InAppMessages/InappShowFailureManager.swift +++ b/Mindbox/InAppMessages/InappShowFailureManager.swift @@ -16,6 +16,7 @@ protocol InappShowFailureManagerProtocol { } final class InappShowFailureManager: InappShowFailureManagerProtocol { + /// Backend payload limit for errorDetails. static let errorDetailsLimit = 1000 private struct InAppShowFailuresBody: Codable { @@ -38,7 +39,19 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { Logger.common(message: "[InappShowFailureManager] addFailure ignored, feature is disabled", category: .inAppMessages) return } - + + let truncatedDetails = details.map { original -> String in + let truncated = original.truncated(toUTF8ByteLimit: Self.errorDetailsLimit) + if truncated.utf8.count < original.utf8.count { + Logger.common( + message: "[InappShowFailureManager] errorDetails truncated from \(original.utf8.count) to \(truncated.utf8.count) bytes (limit \(Self.errorDetailsLimit)). inappId=\(inappId)", + level: .debug, + category: .inAppMessages + ) + } + return truncated + } + queue.async { [self] in if let existingIndex = failures.firstIndex(where: { $0.inappId == inappId }) { guard shouldReplaceFailure(currentReason: failures[existingIndex].failureReason, newReason: reason) else { @@ -50,13 +63,13 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { ) return } - failures[existingIndex] = makeFailure(inappId: inappId, reason: reason, details: details) + failures[existingIndex] = makeFailure(inappId: inappId, reason: reason, details: truncatedDetails) Logger.common(message: "[InappShowFailureManager] Failure reason updated. inappId=\(inappId), reason=\(reason.rawValue)", category: .inAppMessages) return } - - failures.append(makeFailure(inappId: inappId, reason: reason, details: details)) + + failures.append(makeFailure(inappId: inappId, reason: reason, details: truncatedDetails)) } } @@ -102,7 +115,7 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { InAppShowFailure( inappId: inappId, failureReason: reason, - errorDetails: details.map { String($0.prefix(Self.errorDetailsLimit)) }, + errorDetails: details, dateTimeUtc: Date().toString(withFormat: .utc) ) } diff --git a/MindboxTests/Extensions/StringExtensionsTests.swift b/MindboxTests/Extensions/StringExtensionsTests.swift index 6abdda11..1485bb4c 100644 --- a/MindboxTests/Extensions/StringExtensionsTests.swift +++ b/MindboxTests/Extensions/StringExtensionsTests.swift @@ -58,6 +58,23 @@ final class StringExtensionsTests: XCTestCase { } } + func test_truncatedToUTF8ByteLimit_zeroLimit_returnsEmptyString() { + XCTAssertEqual("hello".truncated(toUTF8ByteLimit: 0), "") + } + + func test_truncatedToUTF8ByteLimit_zeroLimit_onEmptyString_returnsEmptyString() { + XCTAssertEqual("".truncated(toUTF8ByteLimit: 0), "") + } + + func test_truncatedToUTF8ByteLimit_negativeLimit_returnsEmptyString() { + XCTAssertEqual("hello".truncated(toUTF8ByteLimit: -1), "") + XCTAssertEqual("hello".truncated(toUTF8ByteLimit: -100), "") + } + + func test_truncatedToUTF8ByteLimit_negativeLimit_onEmptyString_returnsEmptyString() { + XCTAssertEqual("".truncated(toUTF8ByteLimit: -1), "") + } + func test_parseTimeSpanToMillisNegative() throws { let testCases: Array = [ "12345678901234567890.00:00:00.00", diff --git a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift index bf8712ce..0e670d3c 100644 --- a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift +++ b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift @@ -260,6 +260,59 @@ final class InappShowFailureManagerTests: XCTestCase { XCTAssertEqual(failure.errorDetails, "") } + func testAddFailure_errorDetailsMultibyte_truncatesByUTF8Bytes() throws { + let limit = InappShowFailureManager.errorDetailsLimit + // Cyrillic 'а' is 2 bytes in UTF-8: total = 2 * limit bytes. + let details = String(repeating: "а", count: limit) + + manager.addFailure(inappId: "inapp-multibyte", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + let truncated = try XCTUnwrap(failure.errorDetails) + + XCTAssertEqual(truncated.utf8.count, limit) + XCTAssertEqual(truncated.count, limit / 2) + } + + func testAddFailure_errorDetailsMultibyte_doesNotSplitCharacter() throws { + let limit = InappShowFailureManager.errorDetailsLimit + let asciiPrefix = String(repeating: "x", count: limit - 1) + // Cyrillic 'ё' is 2 bytes — appending it would overflow by 1 byte. + let details = asciiPrefix + "ё" + + manager.addFailure(inappId: "inapp-no-split", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + let truncated = try XCTUnwrap(failure.errorDetails) + + XCTAssertEqual(truncated, asciiPrefix) + XCTAssertEqual(truncated.utf8.count, limit - 1) + } + + func testAddFailure_errorDetailsEmoji_isNotSplit() throws { + let limit = InappShowFailureManager.errorDetailsLimit + // "🙂" is 4 UTF-8 bytes. Fill almost to the limit, then append an emoji. + let asciiPrefix = String(repeating: "y", count: limit - 2) + let details = asciiPrefix + "🙂" + + manager.addFailure(inappId: "inapp-emoji", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + let truncated = try XCTUnwrap(failure.errorDetails) + + XCTAssertEqual(truncated, asciiPrefix) + XCTAssertLessThanOrEqual(truncated.utf8.count, limit) + } + func testAddFailure_priorityReplacement_truncatesNewDetails() throws { let limit = InappShowFailureManager.errorDetailsLimit let longDetails = String(repeating: "d", count: limit + 200) From f73cbf1a35ef9465c54cee3cc18925de936f93b2 Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Wed, 29 Apr 2026 14:02:56 +0500 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Mindbox/InAppMessages/InappShowFailureManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mindbox/InAppMessages/InappShowFailureManager.swift b/Mindbox/InAppMessages/InappShowFailureManager.swift index 2711c313..e35e0c80 100644 --- a/Mindbox/InAppMessages/InappShowFailureManager.swift +++ b/Mindbox/InAppMessages/InappShowFailureManager.swift @@ -42,9 +42,9 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { let truncatedDetails = details.map { original -> String in let truncated = original.truncated(toUTF8ByteLimit: Self.errorDetailsLimit) - if truncated.utf8.count < original.utf8.count { + if truncated != original { Logger.common( - message: "[InappShowFailureManager] errorDetails truncated from \(original.utf8.count) to \(truncated.utf8.count) bytes (limit \(Self.errorDetailsLimit)). inappId=\(inappId)", + message: "[InappShowFailureManager] errorDetails truncated to \(truncated.utf8.count) bytes (limit \(Self.errorDetailsLimit)). inappId=\(inappId)", level: .debug, category: .inAppMessages )