Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Sources/DevFoundation/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any RemoteLocalizedFormatStringPolicy> = .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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ public final class CurrentValueMulticaster<Element>: 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<Element>.makeStream(
bufferingPolicy: bufferingPolicy.asyncStreamBufferingPolicy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading