From c1fb7a5f63c94619ef57a947b8e9b1b5f7d82ae4 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Tue, 4 Aug 2026 12:06:34 -0400 Subject: [PATCH] Only use remote localized format strings when explicitly allowed - Introduce RemoteLocalizedFormatStringPolicy to allow consumers to customize whether a specific remote format string should be used - Introduce RemoteLocalizedFormatStringPolicies to act as a namespace for policies. Provide a default which always disallows remote format strings - Update remoteLocalizedString(format:...) to consult a policy before using a remote format string --- CHANGELOG.md | 9 + CLAUDE.md | 10 + .../Documentation.docc/Documentation.md | 3 + .../RemoteLocalizedFormatStringPolicy.swift | 128 +++++++++++++ .../RemoteLocalizedString.swift | 63 ++++++- .../CurrentValueMulticaster.swift | 2 +- ...RemoteLocalizedStringWithFormatMacro.swift | 59 +++--- ...moteLocalizedFormatStringPolicyTests.swift | 47 +++++ .../RemoteLocalizedFormatStringTests.swift | 171 ++++++++++++++++++ .../RemoteLocalizedStringTests.swift | 14 -- ...ockRemoteLocalizedFormatStringPolicy.swift | 36 ++++ .../RemoteContentTestBundle.swift | 26 +++ .../CurrentValueMulticasterTests.swift | 2 +- 13 files changed, 526 insertions(+), 44 deletions(-) create mode 100644 Sources/DevFoundation/Remote Localization/RemoteLocalizedFormatStringPolicy.swift create mode 100644 Tests/DevFoundationTests/Remote Localization/RemoteLocalizedFormatStringPolicyTests.swift create mode 100644 Tests/DevFoundationTests/Remote Localization/RemoteLocalizedFormatStringTests.swift create mode 100644 Tests/DevFoundationTests/Testing Helpers/MockRemoteLocalizedFormatStringPolicy.swift create mode 100644 Tests/DevFoundationTests/Testing Helpers/RemoteContentTestBundle.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4778c83..96d1d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # DevFoundation Changelog +## 1.11.0: August 4, 2026 + +This release adds `RemoteLocalizedFormatStringPolicy` for controlling whether content from a remote +bundle may be used as a format string. Since a mismatched or malicious format string can crash your +app or leak memory, `#remoteLocalizedString(format:bundle:_:)` now always uses your local format +string by default. To opt back in for keys you’ve vetted, implement a type that conforms to +`RemoteLocalizedFormatStringPolicy` and set it via `RemoteLocalizedFormatStringPolicies.current`. + + ## 1.10.0: June 30, 2026 This release adds a new utility type called `CurrentValueMulticaster`. The type, similar to diff --git a/CLAUDE.md b/CLAUDE.md index a0cd582..1447f05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,16 @@ A comprehensive utility library with the following major components: - **RandomAccessPageLoader**: Protocol for loading pages at specific offsets - **RandomAccessPager**: Concrete pager for random access to pages +#### Remote Localization + + - **Bundle+RemoteContent**: `Bundle.makeRemoteContentBundle(at:localizedStrings:)` and + `Bundle.defaultRemoteContentBundle` for supplying and configuring remote content + - **remoteLocalizedString/remoteLocalizedFormatString**: Resolve a key against the remote + content bundle, falling back to the local bundle + - **#remoteLocalizedString**: Macros wrapping the above for plain and format-string lookups + - **RemoteLocalizedFormatStringPolicy**: Decides whether a remote format string may be used; + disallowed by default, since an unvetted remote format string can crash the app or leak memory + #### Utility Types - **AnySendableHashable**: Type-erased sendable hashable wrapper diff --git a/Sources/DevFoundation/Documentation.docc/Documentation.md b/Sources/DevFoundation/Documentation.docc/Documentation.md index 04a3ef8..7cd6566 100644 --- a/Sources/DevFoundation/Documentation.docc/Documentation.md +++ b/Sources/DevFoundation/Documentation.docc/Documentation.md @@ -35,6 +35,9 @@ for paging through data, and essential utility types for building robust applica - ``remoteLocalizedString(_:bundle:)`` - ``remoteLocalizedString(format:bundle:_:)`` - ``remoteLocalizedString(_:key:bundle:remoteContentBundle:)`` +- ``remoteLocalizedFormatString(_:key:bundle:remoteContentBundle:policy:)`` +- ``RemoteLocalizedFormatStringPolicy`` +- ``RemoteLocalizedFormatStringPolicies`` - ``Foundation/Bundle`` ### Caching diff --git a/Sources/DevFoundation/Remote Localization/RemoteLocalizedFormatStringPolicy.swift b/Sources/DevFoundation/Remote Localization/RemoteLocalizedFormatStringPolicy.swift new file mode 100644 index 0000000..81ae925 --- /dev/null +++ b/Sources/DevFoundation/Remote Localization/RemoteLocalizedFormatStringPolicy.swift @@ -0,0 +1,128 @@ +// +// RemoteLocalizedFormatStringPolicy.swift +// DevFoundation +// +// Created by Prachi Gauriar on 8/4/26. +// + +import Foundation +import Synchronization +import os + +/// DevFoundation’s logger for outputting information about remote localized format string policies. +let remoteLocalizedFormatStringPoliciesLogger = Logger( + subsystem: "DevFoundation", + category: "remoteLocalizedFormatStringPolicies", +) + + +/// A type that decides whether format strings from remote content may be used. +/// +/// Remote localization resolves plain strings from content fetched at runtime, falling back to the app’s compiled +/// catalog when the remote content has no entry for a key. This is safe for plain strings, but can result in incorrect +/// or insecure behavior when used with _format_ strings. For example, if a format string has more conversion specifiers +/// than passed-in parameters, the process can crash or adjacent stack memory can be output to the string. +/// +/// By default, DevFoundation never uses remote format strings; see ``RemoteLocalizedFormatStringPolicies/current``, +/// which defaults to ``RemoteLocalizedFormatStringPolicies/disallowed``. A conforming policy is how an app opts back +/// in for keys it has decided are safe. +/// +/// +/// ## Writing a Policy +/// +/// ``allowsRemoteFormatString(_:localFormatString:key:)`` receives both format strings already resolved, so a +/// policy can compare them. At minimum, a policy that isn’t simply `true` or `false` for every key should check that +/// the remote string has the same number and types of conversions as the local one: +/// +/// struct SpecifierCountPolicy: RemoteLocalizedFormatStringPolicy { +/// func allowsRemoteFormatString( +/// _ remoteFormatString: String, +/// localFormatString: String, +/// key: String, +/// ) -> Bool { +/// return conversions(in: remoteFormatString) == conversions(in: localFormatString) +/// } +/// +/// private func conversions(in formatString: String) -> [Character] { +/// // Parse `formatString` and return its conversions, e.g., ["@", "d"] for "%@ has %d lives". +/// } +/// } +/// +/// +/// ## Logging Refusals +/// +/// A policy is also the natural place to log or emit telemetry when a remote format string is refused: +/// +/// struct LoggingPolicy: RemoteLocalizedFormatStringPolicy { +/// func allowsRemoteFormatString( +/// _ remoteFormatString: String, +/// localFormatString: String, +/// key: String, +/// ) -> Bool { +/// let isAllowed = // ... +/// if !isAllowed { +/// logger.warning("Refused remote format string for key \(key)") +/// } +/// return isAllowed +/// } +/// } +public protocol RemoteLocalizedFormatStringPolicy: Sendable { + /// Returns whether a remote format string may be used in place of the local one. + /// + /// Because format strings may be used in user-facing code, take care to ensure this function is as fast as + /// possible. + /// + /// - Parameters: + /// - remoteFormatString: The format string from the remote content bundle. + /// - localFormatString: The format string from the app’s local bundle. + /// - key: The localization key for which both strings are values. + func allowsRemoteFormatString( + _ remoteFormatString: String, + localFormatString: String, + key: String, + ) -> Bool +} + + +/// A namespace for accessing remote localized format string policies. +public enum RemoteLocalizedFormatStringPolicies { + /// A mutex that synchronizes access to the current policy. + private static let currentPolicy: Mutex = .init( + DisallowedRemoteLocalizedFormatStringPolicy() + ) + + + /// The current remote localized format string policy. + /// + /// Defaults to ``disallowed``. + public static var current: any RemoteLocalizedFormatStringPolicy { + get { + return currentPolicy.withLock { $0 } + } + set { + remoteLocalizedFormatStringPoliciesLogger.info( + "Setting current remote localized format string policy to \(String(describing: newValue))" + ) + currentPolicy.withLock { $0 = newValue } + } + } + + + /// A policy that never allows remote format strings. + public static var disallowed: some RemoteLocalizedFormatStringPolicy { + return DisallowedRemoteLocalizedFormatStringPolicy() + } +} + + +/// A policy that never allows remote format strings. +struct DisallowedRemoteLocalizedFormatStringPolicy: RemoteLocalizedFormatStringPolicy { + /// Always returns `false`. + func allowsRemoteFormatString( + _ remoteFormatString: String, + localFormatString: String, + key: String, + ) -> Bool { + return false + } +} diff --git a/Sources/DevFoundation/Remote Localization/RemoteLocalizedString.swift b/Sources/DevFoundation/Remote Localization/RemoteLocalizedString.swift index c56ba4e..85339f3 100644 --- a/Sources/DevFoundation/Remote Localization/RemoteLocalizedString.swift +++ b/Sources/DevFoundation/Remote Localization/RemoteLocalizedString.swift @@ -14,6 +14,10 @@ import Foundation /// /// You should generally use the ``#remoteLocalizedString(_:bundle:)`` macro instead of using this function directly. /// +/// - Warning: Do not use the returned string as a format string, e.g., with `String(format:)`. Remote content here +/// is not vetted for that use. Use ``remoteLocalizedFormatString(_:key:bundle:remoteContentBundle:policy:)`` for +/// format strings instead. +/// /// - Parameters: /// - keyAndValue: A `String.LocalizationValue` that provides the localization key to look up. This parameter also /// serves as the default value if the system can’t find a localized string. @@ -54,6 +58,9 @@ public func remoteLocalizedString( /// bundle: #bundle /// ) /// +/// - Warning: Do not use the returned string as a format string, e.g., with `String(format:)`. Remote content here +/// is not vetted for that use. Use ``#remoteLocalizedString(format:bundle:_:)`` for format strings instead. +/// /// - Parameters: /// - key: A string literal containing the localization key. /// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle. @@ -63,6 +70,57 @@ public macro remoteLocalizedString(_ key: String, bundle: Bundle = #bundle) -> S #externalMacro(module: "RemoteLocalizationMacros", type: "RemoteLocalizedStringMacro") +/// Returns a formatted, localized version of the key using a combination of remote- and local localization data. +/// +/// You should generally use the ``#remoteLocalizedString(format:bundle:_:)`` macro instead of using this function +/// directly. +/// +/// - Warning: The returned string is only ever a remote value if `policy` allows it. The default policy, +/// ``RemoteLocalizedFormatStringPolicies/disallowed``, never does. +/// +/// - Parameters: +/// - keyAndValue: A `String.LocalizationValue` that provides the localization key to look up. This parameter also +/// serves as the default value if the system can’t find a localized string. +/// - key: A string representation of the localization key. +/// - bundle: The bundle to use for looking up the local format string. +/// - remoteContentBundle: The bundle to use to look up a remote format string. If `nil`, no remote content is used. +/// Defaults to ``Foundation/Bundle/defaultRemoteContentBundle``. +/// - policy: The policy that decides whether the remote format string may be used. Defaults to +/// ``RemoteLocalizedFormatStringPolicies/current``. +public func remoteLocalizedFormatString( + _ keyAndValue: String.LocalizationValue, + key: String, + bundle: Bundle, + remoteContentBundle: Bundle? = .defaultRemoteContentBundle, + policy: any RemoteLocalizedFormatStringPolicy = RemoteLocalizedFormatStringPolicies.current, +) -> String { + let localFormatString = String(localized: keyAndValue, bundle: bundle) + + guard let remoteContentBundle else { + return localFormatString + } + + let remoteFormatString = String(localized: keyAndValue, bundle: remoteContentBundle) + + // Getting the key back means the remote bundle has no entry for it + guard remoteFormatString != key else { + return localFormatString + } + + guard + policy.allowsRemoteFormatString( + remoteFormatString, + localFormatString: localFormatString, + key: key, + ) + else { + return localFormatString + } + + return remoteFormatString +} + + /// A macro that returns a formatted localized string using a combination of remote- and local localization data. /// /// This macro transforms: @@ -72,10 +130,13 @@ public macro remoteLocalizedString(_ key: String, bundle: Bundle = #bundle) -> S /// Into: /// /// String.localizedStringWithFormat( -/// #remoteLocalizedString("feline.count.format", bundle: .main), +/// remoteLocalizedFormatString("feline.count.format", key: "feline.count.format", bundle: .main), /// catCount, kittenCount /// ) /// +/// - Warning: A remote format string is only used if a ``RemoteLocalizedFormatStringPolicy`` allows it. By default, +/// none does. See ``RemoteLocalizedFormatStringPolicies``. +/// /// - Parameters: /// - format: A string literal containing the localization key for the format string. /// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle. diff --git a/Sources/DevFoundation/Utility Types/CurrentValueMulticaster.swift b/Sources/DevFoundation/Utility Types/CurrentValueMulticaster.swift index 088d6fd..95f2ddb 100644 --- a/Sources/DevFoundation/Utility Types/CurrentValueMulticaster.swift +++ b/Sources/DevFoundation/Utility Types/CurrentValueMulticaster.swift @@ -109,7 +109,7 @@ public final class CurrentValueMulticaster: Sendable where Element: Sen // `makeStream` is used rather than the closure-based `AsyncStream` initializer on purpose: that initializer’s // build closure would capture `self` strongly and be retained for the stream’s lifetime, so a consumer - // holding the stream would keep the multicaster alive — preventing deallocation and the stream from ever + // holding the stream would keep the multicaster alive, preventing deallocation and the stream from ever // finishing. Here the only retained closure is `onTermination`, which holds `self` weakly. let (stream, continuation) = AsyncStream.makeStream( bufferingPolicy: bufferingPolicy.asyncStreamBufferingPolicy diff --git a/Sources/RemoteLocalizationMacros/RemoteLocalizedStringWithFormatMacro.swift b/Sources/RemoteLocalizationMacros/RemoteLocalizedStringWithFormatMacro.swift index 8aa6cc1..70fb05d 100644 --- a/Sources/RemoteLocalizationMacros/RemoteLocalizedStringWithFormatMacro.swift +++ b/Sources/RemoteLocalizationMacros/RemoteLocalizedStringWithFormatMacro.swift @@ -29,42 +29,47 @@ public struct RemoteLocalizedStringWithFormatMacro: ExpressionMacro { // Build the arguments for String.localizedStringWithFormat call var argumentsArray: [LabeledExprSyntax] = [] - // First argument: the localized string using #localizedString macro + // First argument: the resolved format string, via remoteLocalizedFormatString let bundleArgument = node.arguments.first { $0.label?.text == "bundle" } - let localizedStringArguments: [LabeledExprSyntax] - - if let bundleArgument = bundleArgument { - // Use the explicitly provided bundle argument - localizedStringArguments = [ - LabeledExprSyntax( - expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)), - trailingComma: .commaToken(), - ), - LabeledExprSyntax( - label: .identifier("bundle"), - colon: .colonToken(), - expression: bundleArgument.expression, - ), - ] - } else { - // Default to just the key (which will use #bundle by default) - localizedStringArguments = [ - LabeledExprSyntax( - expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)) + let bundleExpression: ExprSyntax = + bundleArgument?.expression + ?? ExprSyntax( + MacroExpansionExprSyntax( + macroName: .identifier("bundle"), + leftParen: .leftParenToken(), + arguments: LabeledExprListSyntax([]), + rightParen: .rightParenToken(), ) - ] - } + ) + + let remoteLocalizedFormatStringArguments = LabeledExprListSyntax([ + LabeledExprSyntax( + expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)), + trailingComma: .commaToken(), + ), + LabeledExprSyntax( + label: .identifier("key"), + colon: .colonToken(), + expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)), + trailingComma: .commaToken(), + ), + LabeledExprSyntax( + label: .identifier("bundle"), + colon: .colonToken(), + expression: bundleExpression, + ), + ]) - let localizedStringCall = MacroExpansionExprSyntax( - macroName: .identifier("remoteLocalizedString"), + let remoteLocalizedFormatStringCall = FunctionCallExprSyntax( + calledExpression: DeclReferenceExprSyntax(baseName: .identifier("remoteLocalizedFormatString")), leftParen: .leftParenToken(), - arguments: LabeledExprListSyntax(localizedStringArguments), + arguments: remoteLocalizedFormatStringArguments, rightParen: .rightParenToken(), ) argumentsArray.append( LabeledExprSyntax( - expression: ExprSyntax(localizedStringCall), + expression: ExprSyntax(remoteLocalizedFormatStringCall), trailingComma: .commaToken(), ) ) diff --git a/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedFormatStringPolicyTests.swift b/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedFormatStringPolicyTests.swift new file mode 100644 index 0000000..30ac709 --- /dev/null +++ b/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedFormatStringPolicyTests.swift @@ -0,0 +1,47 @@ +// +// RemoteLocalizedFormatStringPolicyTests.swift +// DevFoundation +// +// Created by Prachi Gauriar on 8/4/26. +// + +import DevTesting +import Foundation +import Testing + +@testable import DevFoundation + +struct RemoteLocalizedFormatStringPolicyTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + @DefaultRemoteContentBundleActor + func currentDefaultsToDisallowedPolicy() { + #expect(RemoteLocalizedFormatStringPolicies.current is DisallowedRemoteLocalizedFormatStringPolicy) + } + + + @Test + @DefaultRemoteContentBundleActor + func currentRoundTrips() { + defer { RemoteLocalizedFormatStringPolicies.current = RemoteLocalizedFormatStringPolicies.disallowed } + + let mockPolicy = MockRemoteLocalizedFormatStringPolicy() + RemoteLocalizedFormatStringPolicies.current = mockPolicy + + #expect((RemoteLocalizedFormatStringPolicies.current as? MockRemoteLocalizedFormatStringPolicy) === mockPolicy) + } + + + @Test + mutating func disallowedPolicyReturnsFalseForRandomKeysAndValues() { + let isAllowed = RemoteLocalizedFormatStringPolicies.disallowed.allowsRemoteFormatString( + randomAlphanumericString(), + localFormatString: randomAlphanumericString(), + key: randomAlphanumericString(), + ) + + #expect(!isAllowed) + } +} diff --git a/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedFormatStringTests.swift b/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedFormatStringTests.swift new file mode 100644 index 0000000..97fcf60 --- /dev/null +++ b/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedFormatStringTests.swift @@ -0,0 +1,171 @@ +// +// RemoteLocalizedFormatStringTests.swift +// DevFoundation +// +// Created by Prachi Gauriar on 8/4/26. +// + +import DevFoundation +import DevTesting +import Foundation +import Testing + +struct RemoteLocalizedFormatStringTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + @DefaultRemoteContentBundleActor + mutating func underDefaultPolicyRemoteEntryIsIgnored() throws { + let key = randomAlphanumericString() + let localFormat = "%d items" + let remoteFormat = "%d different items" + + let localBundle = try createTestBundle(with: [key: localFormat]) + let remoteBundle = try createTestBundle(with: [key: remoteFormat]) + + let resolvedFormat = remoteLocalizedFormatString( + String.LocalizationValue(key), + key: key, + bundle: localBundle, + remoteContentBundle: remoteBundle, + ) + + #expect(resolvedFormat == localFormat) + + let count = randomInt(in: 0 ..< 1_000) + let result = String.localizedStringWithFormat(resolvedFormat, count) + #expect(result == String.localizedStringWithFormat(localFormat, count)) + } + + + @Test + mutating func policyReceivesResolvedFormatStringsAndKey() throws { + let key = randomAlphanumericString() + let localFormat = randomAlphanumericString() + let remoteFormat = randomAlphanumericString() + + let localBundle = try createTestBundle(with: [key: localFormat]) + let remoteBundle = try createTestBundle(with: [key: remoteFormat]) + + let mockPolicy = MockRemoteLocalizedFormatStringPolicy() + mockPolicy.allowsRemoteFormatStringStub = Stub(defaultReturnValue: false) + + _ = remoteLocalizedFormatString( + String.LocalizationValue(key), + key: key, + bundle: localBundle, + remoteContentBundle: remoteBundle, + policy: mockPolicy, + ) + + let callArguments = try #require(mockPolicy.allowsRemoteFormatStringStub.callArguments.first) + #expect(callArguments.remoteFormatString == remoteFormat) + #expect(callArguments.localFormatString == localFormat) + #expect(callArguments.key == key) + } + + + @Test + mutating func policyAllowingRemoteFormatStringUsesRemoteValue() throws { + let key = randomAlphanumericString() + let localFormat = randomAlphanumericString() + let remoteFormat = randomAlphanumericString() + + let localBundle = try createTestBundle(with: [key: localFormat]) + let remoteBundle = try createTestBundle(with: [key: remoteFormat]) + + let mockPolicy = MockRemoteLocalizedFormatStringPolicy() + mockPolicy.allowsRemoteFormatStringStub = Stub(defaultReturnValue: true) + + let result = remoteLocalizedFormatString( + String.LocalizationValue(key), + key: key, + bundle: localBundle, + remoteContentBundle: remoteBundle, + policy: mockPolicy, + ) + + #expect(result == remoteFormat) + } + + + @Test + mutating func noRemoteContentBundleUsesLocalFormatStringWithoutConsultingPolicy() throws { + let key = randomAlphanumericString() + let localFormat = randomAlphanumericString() + let localBundle = try createTestBundle(with: [key: localFormat]) + + let result = remoteLocalizedFormatString( + String.LocalizationValue(key), + key: key, + bundle: localBundle, + remoteContentBundle: nil, + policy: MockRemoteLocalizedFormatStringPolicy(), + ) + + #expect(result == localFormat) + } + + + @Test + mutating func remoteBundleWithNoEntryUsesLocalFormatStringWithoutConsultingPolicy() throws { + let remoteKey = randomAlphanumericString() + let localKey = randomAlphanumericString() + let remoteValue = randomAlphanumericString() + let localFormat = randomAlphanumericString() + + let localBundle = try createTestBundle(with: [localKey: localFormat]) + let remoteBundle = try createTestBundle(with: [remoteKey: remoteValue]) + + let result = remoteLocalizedFormatString( + String.LocalizationValue(localKey), + key: localKey, + bundle: localBundle, + remoteContentBundle: remoteBundle, + policy: MockRemoteLocalizedFormatStringPolicy(), + ) + + #expect(result == localFormat) + } + + + @Test + @DefaultRemoteContentBundleActor + mutating func macroFormResolvesEndToEnd() throws { + defer { Bundle.defaultRemoteContentBundle = nil } + + let localFormat = "%d of %d" + let remoteFormat = "%@ %@ %@" + + let testBundle = try createTestBundle(with: ["macroFormatTestKey": localFormat]) + Bundle.defaultRemoteContentBundle = try createTestBundle(with: ["macroFormatTestKey": remoteFormat]) + + let result = #remoteLocalizedString(format: "macroFormatTestKey", bundle: testBundle, 1, 5) + #expect(result == String.localizedStringWithFormat(localFormat, 1, 5)) + } + + + @Test + @DefaultRemoteContentBundleActor + mutating func vulnerabilityRegressionMismatchedRemoteSpecifiersDoNotCorruptOutput() throws { + let key = randomAlphanumericString() + let localFormat = "%d of %d" + let remoteFormat = "%@ %@ %@" + + let localBundle = try createTestBundle(with: [key: localFormat]) + let remoteBundle = try createTestBundle(with: [key: remoteFormat]) + + let resolvedFormat = remoteLocalizedFormatString( + String.LocalizationValue(key), + key: key, + bundle: localBundle, + remoteContentBundle: remoteBundle, + ) + + #expect(resolvedFormat == localFormat) + + let result = String.localizedStringWithFormat(resolvedFormat, 3, 5) + #expect(result == String.localizedStringWithFormat(localFormat, 3, 5)) + } +} diff --git a/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedStringTests.swift b/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedStringTests.swift index 735edc7..927e23a 100644 --- a/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedStringTests.swift +++ b/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedStringTests.swift @@ -66,18 +66,4 @@ struct LocalizationTests: RandomValueGenerating { #expect(result == localValue) } - - - private mutating func createTestBundle(with localizedStrings: [String: String]) throws -> Bundle { - let tempDirectory = FileManager.default.temporaryDirectory - let bundleURL = tempDirectory.appendingPathComponent("\(randomAlphanumericString(count: 32)).bundle") - let resourcesURL = bundleURL.appendingPathComponent("Contents/Resources") - - try FileManager.default.createDirectory(at: resourcesURL, withIntermediateDirectories: true) - - let localizedStringsData = try PropertyListEncoder().encode(localizedStrings) - try localizedStringsData.write(to: resourcesURL.appendingPathComponent("Localizable.strings")) - - return try #require(Bundle(url: bundleURL)) - } } diff --git a/Tests/DevFoundationTests/Testing Helpers/MockRemoteLocalizedFormatStringPolicy.swift b/Tests/DevFoundationTests/Testing Helpers/MockRemoteLocalizedFormatStringPolicy.swift new file mode 100644 index 0000000..e8fd3d0 --- /dev/null +++ b/Tests/DevFoundationTests/Testing Helpers/MockRemoteLocalizedFormatStringPolicy.swift @@ -0,0 +1,36 @@ +// +// MockRemoteLocalizedFormatStringPolicy.swift +// DevFoundation +// +// Created by Prachi Gauriar on 8/4/26. +// + +import DevFoundation +import DevTesting +import Foundation + +final class MockRemoteLocalizedFormatStringPolicy: RemoteLocalizedFormatStringPolicy { + struct AllowsRemoteFormatStringArguments { + let remoteFormatString: String + let localFormatString: String + let key: String + } + + + nonisolated(unsafe) var allowsRemoteFormatStringStub: Stub! + + + func allowsRemoteFormatString( + _ remoteFormatString: String, + localFormatString: String, + key: String, + ) -> Bool { + allowsRemoteFormatStringStub( + .init( + remoteFormatString: remoteFormatString, + localFormatString: localFormatString, + key: key, + ) + ) + } +} diff --git a/Tests/DevFoundationTests/Testing Helpers/RemoteContentTestBundle.swift b/Tests/DevFoundationTests/Testing Helpers/RemoteContentTestBundle.swift new file mode 100644 index 0000000..7a7356d --- /dev/null +++ b/Tests/DevFoundationTests/Testing Helpers/RemoteContentTestBundle.swift @@ -0,0 +1,26 @@ +// +// RemoteContentTestBundle.swift +// DevFoundation +// +// Created by Prachi Gauriar on 8/4/26. +// + +import DevTesting +import Foundation +import Testing + +extension RandomValueGenerating { + /// Creates a temporary bundle on disk containing the specified localized strings. + mutating func createTestBundle(with localizedStrings: [String: String]) throws -> Bundle { + let tempDirectory = FileManager.default.temporaryDirectory + let bundleURL = tempDirectory.appendingPathComponent("\(randomAlphanumericString(count: 32)).bundle") + let resourcesURL = bundleURL.appendingPathComponent("Contents/Resources") + + try FileManager.default.createDirectory(at: resourcesURL, withIntermediateDirectories: true) + + let localizedStringsData = try PropertyListEncoder().encode(localizedStrings) + try localizedStringsData.write(to: resourcesURL.appendingPathComponent("Localizable.strings")) + + return try #require(Bundle(url: bundleURL)) + } +} diff --git a/Tests/DevFoundationTests/Utility Types/CurrentValueMulticasterTests.swift b/Tests/DevFoundationTests/Utility Types/CurrentValueMulticasterTests.swift index b505818..6b1394d 100644 --- a/Tests/DevFoundationTests/Utility Types/CurrentValueMulticasterTests.swift +++ b/Tests/DevFoundationTests/Utility Types/CurrentValueMulticasterTests.swift @@ -156,7 +156,7 @@ struct CurrentValueMulticasterTests: RandomValueGenerating { #expect(unboundedReceived == [initialValue, newValue]) - // with the default newest-1 policy, the update is still observed — it is never lost + // with the default newest-1 policy, the update is still observed. It is never lost let newestMulticaster = CurrentValueMulticaster(initialValue) let newestValues = newestMulticaster.values() newestMulticaster.value = newValue