Skip to content

Commit e78a689

Browse files
committed
Add tests for Bundle swizzling to prevent recursion in localizedString
1 parent e90495c commit e78a689

File tree

2 files changed

+146
-4
lines changed

2 files changed

+146
-4
lines changed
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
//
2+
// BundleSwizzleReentrancyTests.swift
3+
// CrowdinSDK-Unit-Core_Tests
4+
//
5+
// Validates that swizzled Bundle.localizedString avoids recursion when system APIs
6+
// like NSError.localizedDescription trigger nested localization during resolution.
7+
//
8+
9+
import XCTest
10+
@testable import CrowdinSDK
11+
12+
private class DummyLocalStorage: LocalLocalizationStorageProtocol {
13+
var localization: String
14+
var localizations: [String] { [] }
15+
var strings: [String : String] = [:]
16+
var plurals: [AnyHashable : Any] = [:]
17+
18+
init(localization: String) {
19+
self.localization = localization
20+
}
21+
22+
func fetchData(completion: LocalizationStorageCompletion, errorHandler: LocalizationStorageError?) {
23+
completion(localizations, localization, strings, plurals)
24+
}
25+
26+
func saveLocalizaion(strings: [String : String]?, plurals: [AnyHashable : Any]?, for localization: String) { }
27+
func save() { }
28+
func fetchData() { }
29+
func deintegrate() { }
30+
}
31+
32+
private class DummyRemoteStorage: RemoteLocalizationStorageProtocol {
33+
var localization: String
34+
var localizations: [String] = []
35+
var name: String = "DummyRemoteStorage"
36+
37+
init(localization: String) {
38+
self.localization = localization
39+
}
40+
41+
func prepare(with completion: @escaping () -> Void) { completion() }
42+
43+
func fetchData(completion: @escaping LocalizationStorageCompletion, errorHandler: LocalizationStorageError?) {
44+
completion(localizations, localization, [:], [:])
45+
}
46+
47+
func deintegrate() { }
48+
}
49+
50+
private class ReentrantProvider: LocalizationProviderProtocol {
51+
var localStorage: LocalLocalizationStorageProtocol
52+
var remoteStorage: RemoteLocalizationStorageProtocol
53+
var localization: String
54+
var localizations: [String] { [] }
55+
56+
required init(localization: String, localStorage: LocalLocalizationStorageProtocol, remoteStorage: RemoteLocalizationStorageProtocol) {
57+
self.localization = localization
58+
self.localStorage = localStorage
59+
self.remoteStorage = remoteStorage
60+
}
61+
62+
func refreshLocalization() { }
63+
func refreshLocalization(completion: @escaping ((Error?) -> Void)) { completion(nil) }
64+
func prepare(with completion: @escaping () -> Void) { completion() }
65+
func deintegrate() { }
66+
67+
func localizedString(for key: String) -> String? {
68+
// Force a direct recursive path through the swizzled method
69+
_ = Bundle.main.localizedString(forKey: "__reentrancy_inner__", value: nil, table: nil)
70+
return nil
71+
}
72+
73+
func key(for string: String) -> String? { nil }
74+
func values(for string: String, with format: String) -> [Any]? { nil }
75+
func set(string: String, for key: String) { }
76+
}
77+
78+
private class NSErrorReentrantProvider: LocalizationProviderProtocol {
79+
var localStorage: LocalLocalizationStorageProtocol
80+
var remoteStorage: RemoteLocalizationStorageProtocol
81+
var localization: String
82+
var localizations: [String] { [] }
83+
84+
required init(localization: String, localStorage: LocalLocalizationStorageProtocol, remoteStorage: RemoteLocalizationStorageProtocol) {
85+
self.localization = localization
86+
self.localStorage = localStorage
87+
self.remoteStorage = remoteStorage
88+
}
89+
90+
func refreshLocalization() { }
91+
func refreshLocalization(completion: @escaping ((Error?) -> Void)) { completion(nil) }
92+
func prepare(with completion: @escaping () -> Void) { completion() }
93+
func deintegrate() { }
94+
95+
func localizedString(for key: String) -> String? {
96+
// Trigger system error description which internally performs localized string lookup
97+
_ = NSError(domain: "test-domain", code: 999, userInfo: nil).localizedDescription
98+
return nil
99+
}
100+
101+
func key(for string: String) -> String? { nil }
102+
func values(for string: String, with format: String) -> [Any]? { nil }
103+
func set(string: String, for key: String) { }
104+
}
105+
106+
class BundleSwizzleReentrancyTests: XCTestCase {
107+
override func tearDown() {
108+
Bundle.unswizzle()
109+
Localization.current = nil
110+
}
111+
112+
func testSwizzledBundleLocalizedStringDoesNotRecurseOnNSError() {
113+
Bundle.swizzle()
114+
defer {
115+
Bundle.unswizzle()
116+
Localization.current = nil
117+
}
118+
119+
let local = DummyLocalStorage(localization: "en")
120+
let remote = DummyRemoteStorage(localization: "en")
121+
let provider = ReentrantProvider(localization: "en", localStorage: local, remoteStorage: remote)
122+
Localization.current = Localization(provider: provider)
123+
124+
// If recursion occurs, this would crash; with the guard it should safely return the key
125+
let key = "__reentrancy_test_key__"
126+
let value = Bundle.main.localizedString(forKey: key, value: nil, table: nil)
127+
XCTAssertEqual(value, key)
128+
}
129+
130+
func testSwizzledBundleLocalizedStringHandlesNSErrorReentrancy() {
131+
Bundle.swizzle()
132+
defer {
133+
Bundle.unswizzle()
134+
Localization.current = nil
135+
}
136+
137+
let local = DummyLocalStorage(localization: "en")
138+
let remote = DummyRemoteStorage(localization: "en")
139+
let provider = NSErrorReentrantProvider(localization: "en", localStorage: local, remoteStorage: remote)
140+
Localization.current = Localization(provider: provider)
141+
142+
let key = "__nserror_reentrancy_test_key__"
143+
let value = Bundle.main.localizedString(forKey: key, value: nil, table: nil)
144+
XCTAssertEqual(value, key)
145+
}
146+
}

Tests/Tests.xcodeproj/project.pbxproj

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -404,14 +404,10 @@
404404
inputFileListPaths = (
405405
"${PODS_ROOT}/Target Support Files/Pods-Tests/Pods-Tests-frameworks-${CONFIGURATION}-input-files.xcfilelist",
406406
);
407-
inputPaths = (
408-
);
409407
name = "[CP] Embed Pods Frameworks";
410408
outputFileListPaths = (
411409
"${PODS_ROOT}/Target Support Files/Pods-Tests/Pods-Tests-frameworks-${CONFIGURATION}-output-files.xcfilelist",
412410
);
413-
outputPaths = (
414-
);
415411
runOnlyForDeploymentPostprocessing = 0;
416412
shellPath = /bin/sh;
417413
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Tests/Pods-Tests-frameworks.sh\"\n";

0 commit comments

Comments
 (0)