Skip to content

Commit 8697ad1

Browse files
committed
feat: add sanitization for format variable names in XcstringsParser and implement unit tests
1 parent e2c07c1 commit 8697ad1

File tree

2 files changed

+197
-2
lines changed

2 files changed

+197
-2
lines changed

Sources/CrowdinSDK/Providers/Crowdin/LocalizationDownloader/Operations/CrowdinXcstringsDownloadOperation.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@ class XcstringsParser {
8989
pluralDict[Keys.NSStringFormatValueTypeKey.rawValue] = pluralDict.values.map({ Self.formats(from: $0) }).filter({ $0.count > 0 }).first?.first ?? "u"
9090

9191
var dict = [String: Any]()
92-
dict[Keys.NSStringLocalizedFormatKey.rawValue] = "%#@\(key)@"
93-
dict[key] = pluralDict
92+
// Use a sanitized variable name to avoid issues with format specifiers in the key
93+
let variableName = Self.sanitizeFormatVariable(key)
94+
dict[Keys.NSStringLocalizedFormatKey.rawValue] = "%#@\(variableName)@"
95+
dict[variableName] = pluralDict
9496

9597
plurals[key] = dict
9698
} else if let stringUnit = value.stringUnit {
@@ -150,6 +152,21 @@ class XcstringsParser {
150152

151153
return specifiers
152154
}
155+
156+
/// Sanitizes a key name to be used as a variable name in stringsdict format string.
157+
/// Removes % characters and other special characters that could interfere with format string parsing.
158+
/// - Parameter key: The original key name
159+
/// - Returns: A sanitized variable name safe to use in format strings
160+
static func sanitizeFormatVariable(_ key: String) -> String {
161+
// Replace % and @ characters with underscores to avoid conflicts with format specifiers
162+
let sanitized = key.replacingOccurrences(of: "%", with: "_")
163+
.replacingOccurrences(of: "@", with: "_")
164+
// If the result is empty or starts with a number, prefix with "var_"
165+
if sanitized.isEmpty || sanitized.first?.isNumber == true {
166+
return "var_" + sanitized
167+
}
168+
return sanitized
169+
}
153170
}
154171

155172
class XCStringsStorage {
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//
2+
// XcstringsParserTests.swift
3+
// TestsTests
4+
//
5+
// Created by Serhii Londar on 22.02.2026.
6+
//
7+
8+
import XCTest
9+
@testable import CrowdinSDK
10+
11+
class XcstringsParserTests: XCTestCase {
12+
13+
override func setUp() {
14+
super.setUp()
15+
}
16+
17+
override func tearDown() {
18+
super.tearDown()
19+
}
20+
21+
func testSanitizeFormatVariable() {
22+
// Test removing % characters
23+
XCTAssertEqual(XcstringsParser.sanitizeFormatVariable("creditsValid%lld"), "creditsValid_lld")
24+
25+
// Test removing @ characters
26+
XCTAssertEqual(XcstringsParser.sanitizeFormatVariable("test@key"), "test_key")
27+
28+
// Test with both % and @
29+
XCTAssertEqual(XcstringsParser.sanitizeFormatVariable("test%lld@extra"), "test_lld_extra")
30+
31+
// Test with normal key (no special characters)
32+
XCTAssertEqual(XcstringsParser.sanitizeFormatVariable("normalKey"), "normalKey")
33+
34+
// Test with key starting with number
35+
XCTAssertEqual(XcstringsParser.sanitizeFormatVariable("1test"), "var_1test")
36+
}
37+
38+
func testParseXcstringsWithPluralsContainingFormatSpecifiers() {
39+
// Create a sample .xcstrings structure similar to the user's example
40+
let jsonString = """
41+
{
42+
"sourceLanguage": "en",
43+
"version": "1.0",
44+
"strings": {
45+
"creditsValid%lld": {
46+
"extractionState": "manual",
47+
"localizations": {
48+
"en": {
49+
"variations": {
50+
"plural": {
51+
"one": {
52+
"stringUnit": {
53+
"state": "translated",
54+
"value": "Valid for %lld day"
55+
}
56+
},
57+
"other": {
58+
"stringUnit": {
59+
"state": "translated",
60+
"value": "Valid for %lld days"
61+
}
62+
}
63+
}
64+
}
65+
},
66+
"de": {
67+
"variations": {
68+
"plural": {
69+
"one": {
70+
"stringUnit": {
71+
"state": "translated",
72+
"value": "Gültig für %lld Tag"
73+
}
74+
},
75+
"other": {
76+
"stringUnit": {
77+
"state": "translated",
78+
"value": "Gültig für %lld Tage"
79+
}
80+
}
81+
}
82+
}
83+
}
84+
}
85+
}
86+
}
87+
}
88+
"""
89+
90+
guard let data = jsonString.data(using: .utf8) else {
91+
XCTFail("Failed to create data from JSON string")
92+
return
93+
}
94+
95+
// Parse for English
96+
let resultEN = XcstringsParser.parse(data: data, localization: "en")
97+
98+
XCTAssertNil(resultEN.error, "Parsing should not produce an error")
99+
XCTAssertNotNil(resultEN.plurals, "Plurals should be parsed")
100+
101+
if let plurals = resultEN.plurals {
102+
// Check that the key is present in the plurals dictionary
103+
XCTAssertNotNil(plurals["creditsValid%lld"], "Plural entry should exist for key 'creditsValid%lld'")
104+
105+
if let pluralDict = plurals["creditsValid%lld"] as? [String: Any] {
106+
// Check NSStringLocalizedFormatKey is present and uses sanitized variable name
107+
let formatKey = pluralDict["NSStringLocalizedFormatKey"] as? String
108+
XCTAssertNotNil(formatKey, "NSStringLocalizedFormatKey should exist")
109+
110+
// The format key should use the sanitized variable name (creditsValid_lld)
111+
XCTAssertEqual(formatKey, "%#@creditsValid_lld@", "Format key should use sanitized variable name")
112+
113+
// Check that the sanitized variable name dictionary exists
114+
let variableDict = pluralDict["creditsValid_lld"] as? [String: Any]
115+
XCTAssertNotNil(variableDict, "Variable dictionary with sanitized name should exist")
116+
117+
if let variableDict = variableDict {
118+
// Check plural variations
119+
XCTAssertEqual(variableDict["one"] as? String, "Valid for %lld day")
120+
XCTAssertEqual(variableDict["other"] as? String, "Valid for %lld days")
121+
XCTAssertEqual(variableDict["NSStringFormatSpecTypeKey"] as? String, "NSStringPluralRuleType")
122+
XCTAssertEqual(variableDict["NSStringFormatValueTypeKey"] as? String, "lld")
123+
}
124+
}
125+
}
126+
127+
// Parse for German
128+
let resultDE = XcstringsParser.parse(data: data, localization: "de")
129+
130+
XCTAssertNil(resultDE.error, "Parsing should not produce an error")
131+
XCTAssertNotNil(resultDE.plurals, "Plurals should be parsed")
132+
133+
if let plurals = resultDE.plurals {
134+
if let pluralDict = plurals["creditsValid%lld"] as? [String: Any] {
135+
if let variableDict = pluralDict["creditsValid_lld"] as? [String: Any] {
136+
// Check German plural variations
137+
XCTAssertEqual(variableDict["one"] as? String, "Gültig für %lld Tag")
138+
XCTAssertEqual(variableDict["other"] as? String, "Gültig für %lld Tage")
139+
}
140+
}
141+
}
142+
}
143+
144+
func testParseXcstringsWithSimpleStrings() {
145+
let jsonString = """
146+
{
147+
"sourceLanguage": "en",
148+
"version": "1.0",
149+
"strings": {
150+
"hello": {
151+
"localizations": {
152+
"en": {
153+
"stringUnit": {
154+
"state": "translated",
155+
"value": "Hello"
156+
}
157+
}
158+
}
159+
}
160+
}
161+
}
162+
"""
163+
164+
guard let data = jsonString.data(using: .utf8) else {
165+
XCTFail("Failed to create data from JSON string")
166+
return
167+
}
168+
169+
let result = XcstringsParser.parse(data: data, localization: "en")
170+
171+
XCTAssertNil(result.error, "Parsing should not produce an error")
172+
XCTAssertNotNil(result.strings, "Strings should be parsed")
173+
174+
if let strings = result.strings {
175+
XCTAssertEqual(strings["hello"], "Hello")
176+
}
177+
}
178+
}

0 commit comments

Comments
 (0)