diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c3873a1..04ce4c0a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# CHANGELOG
+## v2.5.1
+
+### Date: 17-Aug-2026
+
+### Bug Fixes
+
+- Fixed a crash in `ContentstackLogger` caused by passing the log message to `NSLog` as the format string. Affects the default logger configuration (`logType = .nsLog`, `logLevel = .error`).
+- `CachePolicy.cacheThenNetwork` no longer crashes the `async`/`await` APIs; it is served as `CachePolicy.cacheElseNetwork`. The completion-handler APIs are unchanged.
+- `CachePolicy.networkElseCache` set per request now falls back to the cache on transport errors.
+- A response with neither data nor an error no longer leaves an `async` call suspended indefinitely.
+
## v2.5.0
### Date: 27-Jul-2026
diff --git a/ContentstackSwift iOS.xctestplan b/ContentstackSwift iOS.xctestplan
new file mode 100644
index 00000000..af01dea3
--- /dev/null
+++ b/ContentstackSwift iOS.xctestplan
@@ -0,0 +1,44 @@
+{
+ "configurations" : [
+ {
+ "id" : "5614AFE7-8607-4A8A-A359-3CC22B4E26AF",
+ "name" : "Test Scheme Action",
+ "options" : {
+ "targetForVariableExpansion" : {
+ "containerPath" : "container:ContentstackSwift.xcodeproj",
+ "identifier" : "0F4A75CA241BAC4300E3A024",
+ "name" : "ContentstackSwift iOS"
+ }
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "codeCoverage" : false,
+ "performanceAntipatternCheckerEnabled" : true
+ },
+ "testTargets" : [
+ {
+ "target" : {
+ "containerPath" : "container:ContentstackSwift.xcodeproj",
+ "identifier" : "0F4A75F2241BAE6C00E3A024",
+ "name" : "ContentstackSwift macOS Tests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:ContentstackSwift.xcodeproj",
+ "identifier" : "0F4A75D2241BAC4300E3A024",
+ "name" : "ContentstackSwift iOS Tests"
+ }
+ },
+ {
+ "enabled" : false,
+ "target" : {
+ "containerPath" : "container:ContentstackSwift.xcodeproj",
+ "identifier" : "0F4A760E241BAFE000E3A024",
+ "name" : "ContentstackSwift tvOS Tests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/ContentstackSwift.xcodeproj/xcshareddata/xcschemes/Contentstack iOS Tests.xcscheme b/ContentstackSwift.xcodeproj/xcshareddata/xcschemes/Contentstack iOS Tests.xcscheme
deleted file mode 100644
index 8b63b939..00000000
--- a/ContentstackSwift.xcodeproj/xcshareddata/xcschemes/Contentstack iOS Tests.xcscheme
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ContentstackSwift.xcodeproj/xcshareddata/xcschemes/Contentstack iOS.xcscheme b/ContentstackSwift.xcodeproj/xcshareddata/xcschemes/Contentstack iOS.xcscheme
deleted file mode 100644
index 483a12eb..00000000
--- a/ContentstackSwift.xcodeproj/xcshareddata/xcschemes/Contentstack iOS.xcscheme
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack iOS.xcscheme b/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack iOS.xcscheme
deleted file mode 100644
index 40c4c679..00000000
--- a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack iOS.xcscheme
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack macOS Tests.xcscheme b/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack macOS Tests.xcscheme
deleted file mode 100644
index 21edad22..00000000
--- a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack macOS Tests.xcscheme
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack macOS.xcscheme b/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack macOS.xcscheme
deleted file mode 100644
index caf3521a..00000000
--- a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack macOS.xcscheme
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack tvOS.xcscheme b/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack tvOS.xcscheme
deleted file mode 100644
index 8c5a44a2..00000000
--- a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack tvOS.xcscheme
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack watchOS.xcscheme b/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack watchOS.xcscheme
deleted file mode 100644
index 10eae328..00000000
--- a/ContentstackSwift.xcworkspace/xcshareddata/xcschemes/Contentstack watchOS.xcscheme
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/README.md b/README.md
index ca6cbdc3..d351a095 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,65 @@ To fetch a specific entry from a content type, use the following query:
}
}
```
+#### Using async/await
+
+Every query and fetch is also available as an `async throws` method (iOS 13, macOS 10.15, tvOS 13,
+watchOS 6 and later):
+
+ ```
+ let stack = Contentstack.stack(apiKey: apiKey,
+ deliveryToken: deliveryToken,
+ environment: environment)
+
+ do {
+ let response: ContentstackResponse = try await stack
+ .contentType(uid: contentTypeUID).entry().query().find()
+ // response.items contains the entries
+ } catch {
+ // Error Message
+ }
+ ```
+
+#### Caching
+
+Set `cachePolicy` on the `Stack` to change where content is read from, or set it on an individual
+query, entry or asset to override the `Stack` default:
+
+ ```
+ stack.cachePolicy = .networkElseCache
+ ```
+
+| Policy | Behaviour |
+| --- | --- |
+| `.networkOnly` | Network call only, and the response is cached. This is the default. |
+| `.cacheOnly` | Cache only. |
+| `.cacheElseNetwork` | Cache, falling back to a network call when the cache misses. |
+| `.networkElseCache` | Network call, falling back to the cache when the call fails. |
+| `.cacheThenNetwork` | Cache first, then a network call. **The completion handler is invoked twice.** |
+
+`.cacheThenNetwork` is supported by the completion-handler APIs only. A single `await` returns one
+value, so the `async` APIs cannot deliver both results — they serve that policy as
+`.cacheElseNetwork` and log the substitution. Use the completion-handler APIs when both the cached
+and the network result are needed.
+
+Note that `cachePolicy` set on the `Stack` propagates to queries created from it, but single
+resource `fetch()` calls on `Entry`, `Asset`, `ContentType`, `GlobalField` and `Taxonomy` default
+to `.networkOnly` independently; set the policy on those objects directly.
+
+#### Logging
+
+`ContentstackLogger.logLevel` defaults to `.error` and `ContentstackLogger.logType` to `.nsLog` on
+Apple platforms. Supply your own logger with `.custom`:
+
+ ```
+ ContentstackLogger.logType = .custom(MyLogger())
+ ```
+
+A `CustomLogger` receives an already-formatted message. Pass it to logging APIs as an *argument*,
+never as a format string — `NSLog("%@", message)`, not `NSLog(message)` — because messages contain
+percent-encoded URLs and server-supplied error text that would otherwise be parsed as format
+conversion specifiers.
+
### Advanced Queries
You can query for content types, entries, assets and more using our iOS API Reference.
diff --git a/Sources/CSDefinitions.swift b/Sources/CSDefinitions.swift
index e38ea86b..046186a3 100644
--- a/Sources/CSDefinitions.swift
+++ b/Sources/CSDefinitions.swift
@@ -57,6 +57,10 @@ public enum CachePolicy {
///The SDK gets data using a network call. However, if the call fails, it retrieves data from cache.
case networkElseCache
///The SDK gets data from cache, and then makes a network call. (A success callback will be invoked twice.)
+ ///
+ ///Supported by the completion-handler APIs only. A single `await` returns one value, so the
+ ///`async` APIs cannot deliver both results; they serve this policy as ``cacheElseNetwork``
+ ///and log that substitution. Use the completion-handler APIs when both results are required.
case cacheThenNetwork
}
diff --git a/Sources/ContentstackLogger.swift b/Sources/ContentstackLogger.swift
index f17454cc..4da5523d 100644
--- a/Sources/ContentstackLogger.swift
+++ b/Sources/ContentstackLogger.swift
@@ -65,7 +65,11 @@ public enum ContentstackLogger {
case .print:
Swift.print(formattedMessage)
case .nsLog:
- NSLog(formattedMessage)
+ // `formattedMessage` must be passed as an argument, never as the format string.
+ // Logged messages carry percent-encoded URLs and server-supplied error text, so a
+ // message used as a format string is parsed for conversion specifiers (`%22s` from
+ // `{"sku":..}`, for example) and reads arguments that were never supplied.
+ NSLog("%@", formattedMessage)
case .custom(let customLogger):
customLogger.log(message: formattedMessage)
}
diff --git a/Sources/ContentstackMessages.swift b/Sources/ContentstackMessages.swift
index e2679988..a9a8eb0f 100644
--- a/Sources/ContentstackMessages.swift
+++ b/Sources/ContentstackMessages.swift
@@ -99,8 +99,16 @@ internal enum ContentstackMessages {
Please contact the maintainer on Github with a copy of the query
"""
+ // MARK: - Cache Policy Messages
+
+ static let cacheThenNetworkUnsupportedInAsync = """
+ CachePolicy.cacheThenNetwork delivers two results and cannot be represented by a single \
+ async/await return value. This request is being served as CachePolicy.cacheElseNetwork. \
+ Use the completion-handler APIs if both the cached and the network result are required.
+ """
+
// MARK: - Internal/Debug Messages
-
+
static let unsupportedEndpointType = "Unsupported endpoint type encountered during response decoding"
}
diff --git a/Sources/ImageOperations.swift b/Sources/ImageOperations.swift
index a30315ff..9e015282 100644
--- a/Sources/ImageOperations.swift
+++ b/Sources/ImageOperations.swift
@@ -152,10 +152,10 @@ public enum Crop {
var values = [String]()
var queryItems = [URLQueryItem]()
switch self {
- case .default(let size):
+ case .default(let width, let height):
values = [
- String(size.width),
- String(size.height)
+ String(width),
+ String(height)
]
case .aspectRatio(let sizes, let ratio, let mode):
sizes.urlQueryItem(queryItems: &queryItems)
@@ -166,24 +166,24 @@ public enum Crop {
if let value = mode.value {
values.append(value)
}
- case .region(let region):
+ case .region(let width, let height, let xRegion, let yRegion, let mode):
values = [
- String(region.width),
- String(region.height),
- "x\(String(region.xRegion))",
- "y\(String(region.yRegion))"
+ String(width),
+ String(height),
+ "x\(String(xRegion))",
+ "y\(String(yRegion))"
]
- if let value = region.mode.value {
+ if let value = mode.value {
values.append(value)
}
- case .offset(let offset):
+ case .offset(let width, let height, let xOffset, let yOffset, let mode):
values = [
- String(offset.width),
- String(offset.height),
- "offset-x\(String(offset.xOffset))",
- "offset-y\(String(offset.yOffset))"
+ String(width),
+ String(height),
+ "offset-x\(String(xOffset))",
+ "offset-y\(String(yOffset))"
]
- if let value = offset.mode.value {
+ if let value = mode.value {
values.append(value)
}
}
@@ -210,10 +210,10 @@ public enum Canvas {
var values = [String]()
var queryItems = [URLQueryItem]()
switch self {
- case .default(let size):
+ case .default(let width, let height):
values = [
- String(size.width),
- String(size.height)
+ String(width),
+ String(height)
]
case .aspectRatio(let sizes, let ratio):
sizes.urlQueryItem(queryItems: &queryItems)
@@ -221,19 +221,19 @@ public enum Canvas {
throw ImageTransformError(message: ContentstackMessages.canvasAspectRatioRequired)
}
values = [ratio]
- case .region(let region):
+ case .region(let width, let height, let xRegion, let yRegion):
values = [
- String(region.width),
- String(region.height),
- "x\(String(region.xRegion))",
- "y\(String(region.yRegion))"
+ String(width),
+ String(height),
+ "x\(String(xRegion))",
+ "y\(String(yRegion))"
]
- case .offset(let offset):
+ case .offset(let width, let height, let xOffset, let yOffset):
values = [
- String(offset.width),
- String(offset.height),
- "offset-x\(String(offset.xOffset))",
- "offset-y\(String(offset.yOffset))"
+ String(width),
+ String(height),
+ "offset-x\(String(xOffset))",
+ "offset-y\(String(yOffset))"
]
}
if values.count > 0 {
@@ -394,3 +394,4 @@ public enum Color {
}
}
}
+
diff --git a/Sources/Stack.swift b/Sources/Stack.swift
index 09257615..18693db2 100644
--- a/Sources/Stack.swift
+++ b/Sources/Stack.swift
@@ -14,6 +14,28 @@ public enum Host {
public typealias ResultsHandler = (_ result: Result, ResponseType) -> Void
+/// Ensures a `CheckedContinuation` is resumed at most once.
+///
+/// A checked continuation traps the process on a second resume, so a completion handler that can
+/// fire more than once must be gated. `CachePolicy.cacheThenNetwork` is the known case and is
+/// coerced away before it reaches an `async` call, which makes this defence-in-depth: any future
+/// multi-callback path degrades to "first result wins" rather than crashing. The two callbacks
+/// arrive on different queues, so the flag is lock-protected.
+internal final class ResumeOnceGuard: @unchecked Sendable {
+ private let lock = NSLock()
+ private var hasResumed = false
+
+ /// Claims the sole right to resume.
+ /// - Returns: `true` for the first caller only, `false` for every subsequent one.
+ func claim() -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ if hasResumed { return false }
+ hasResumed = true
+ return true
+ }
+}
+
/// Stack is instance for performing Contentstack Delivery API request.
public class Stack: CachePolicyAccessible {
internal var urlSession: URLSession
@@ -245,7 +267,7 @@ public class Stack: CachePolicyAccessible {
}
if let error = error {
- if self.cachePolicy == .networkElseCache,
+ if cachePolicy == .networkElseCache,
self.canFullfillRequestWithCache(request) {
self.fullfillRequestWithCache(request, then: completion)
return
@@ -259,6 +281,10 @@ public class Stack: CachePolicyAccessible {
return
}
+ // Neither data nor error: not a documented URLSession outcome, but every path here
+ // must be terminal. Falling through would leave an awaiting continuation unresumed
+ // and suspend the calling task indefinitely.
+ completion(Result.failure(SDKError.invalidHTTPResponse(response: response)), .network)
})
performDataTask(dataTask!, request: request, cachePolicy: cachePolicy, then: completion)
}
@@ -274,8 +300,11 @@ public class Stack: CachePolicyAccessible {
/// - Throws: Network or cache errors
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
private func fetchUrl(_ url: URL, headers: [String: String], cachePolicy: CachePolicy) async throws -> (Data, ResponseType) {
+ let effectiveCachePolicy = Stack.asyncCachePolicy(for: cachePolicy)
+ let resumeGuard = ResumeOnceGuard()
return try await withCheckedThrowingContinuation { continuation in
- fetchUrl(url, headers: headers, cachePolicy: cachePolicy) { result, responseType in
+ fetchUrl(url, headers: headers, cachePolicy: effectiveCachePolicy) { result, responseType in
+ guard resumeGuard.claim() else { return }
switch result {
case .success(let data):
continuation.resume(returning: (data, responseType))
@@ -285,6 +314,18 @@ public class Stack: CachePolicyAccessible {
}
}
}
+
+ /// Resolves the cache policy an `async` request can actually honour.
+ ///
+ /// A single `await` produces exactly one value, so `CachePolicy.cacheThenNetwork` — defined to
+ /// deliver both a cached and a network result — cannot be represented. It is served as
+ /// `CachePolicy.cacheElseNetwork`, the only single-emission reading of it, and the
+ /// substitution is logged. The completion-handler APIs still deliver both results.
+ internal static func asyncCachePolicy(for cachePolicy: CachePolicy) -> CachePolicy {
+ guard cachePolicy == .cacheThenNetwork else { return cachePolicy }
+ ContentstackLogger.log(.error, message: ContentstackMessages.cacheThenNetworkUnsupportedInAsync)
+ return .cacheElseNetwork
+ }
internal func fetch(endpoint: Endpoint,
cachePolicy: CachePolicy,
diff --git a/Tests/AsyncAwaitAPITest.swift b/Tests/AsyncAwaitAPITest.swift
index 50fe4177..431084f9 100644
--- a/Tests/AsyncAwaitAPITest.swift
+++ b/Tests/AsyncAwaitAPITest.swift
@@ -362,7 +362,102 @@ class AsyncAwaitSyntaxTests: XCTestCase {
expectation.fulfill()
}
}
-
+
await fulfillment(of: [expectation], timeout: 30.0)
}
}
+
+// MARK: - Cache Policy Under Async (DX-10148)
+
+/// `CachePolicy.cacheThenNetwork` invokes its completion handler twice by design. Fed into
+/// `withCheckedThrowingContinuation` that traps the process, because a checked continuation
+/// permits exactly one resume. These cover both halves of the fix: the policy is coerced to a
+/// single-emission equivalent, and the continuation is guarded regardless.
+class AsyncCachePolicyTests: XCTestCase {
+
+ private var originalLogType: ContentstackLogger.LogType!
+ private var originalLogLevel: ContentstackLogger.LogLevel!
+
+ override func setUp() {
+ super.setUp()
+ originalLogType = ContentstackLogger.logType
+ originalLogLevel = ContentstackLogger.logLevel
+ }
+
+ override func tearDown() {
+ ContentstackLogger.logType = originalLogType
+ ContentstackLogger.logLevel = originalLogLevel
+ super.tearDown()
+ }
+
+ // MARK: Policy resolution
+
+ func testCacheThenNetworkIsServedAsCacheElseNetwork() {
+ ContentstackLogger.logLevel = .none // suppress the substitution notice
+ XCTAssertEqual(Stack.asyncCachePolicy(for: .cacheThenNetwork), .cacheElseNetwork)
+ }
+
+ func testEveryOtherPolicyIsPassedThroughUnchanged() {
+ ContentstackLogger.logLevel = .none
+ for policy in [CachePolicy.networkOnly, .cacheOnly, .cacheElseNetwork, .networkElseCache] {
+ XCTAssertEqual(Stack.asyncCachePolicy(for: policy), policy,
+ "\(policy) must not be rewritten")
+ }
+ }
+
+ /// The substitution changes behaviour, so it has to be discoverable rather than silent.
+ func testCacheThenNetworkSubstitutionIsLogged() {
+ let spy = CustomeLogMessage()
+ ContentstackLogger.logType = .custom(spy)
+ ContentstackLogger.logLevel = .error
+
+ _ = Stack.asyncCachePolicy(for: .cacheThenNetwork)
+
+ let logged = spy.customeMessage
+ XCTAssertNotNil(logged, "Substituting the cache policy must be logged")
+ XCTAssertTrue(logged?.contains("cacheThenNetwork") ?? false,
+ "The notice must name the requested policy. Got: \(logged ?? "nil")")
+ XCTAssertTrue(logged?.contains("cacheElseNetwork") ?? false,
+ "The notice must name the substituted policy. Got: \(logged ?? "nil")")
+ }
+
+ func testPassthroughPolicyIsNotLogged() {
+ let spy = CustomeLogMessage()
+ ContentstackLogger.logType = .custom(spy)
+ ContentstackLogger.logLevel = .error
+
+ _ = Stack.asyncCachePolicy(for: .networkOnly)
+
+ XCTAssertNil(spy.customeMessage, "A policy needing no substitution must log nothing")
+ }
+
+ // MARK: Continuation guard
+
+ func testResumeGuardGrantsExactlyOneClaim() {
+ let resumeGuard = ResumeOnceGuard()
+ XCTAssertTrue(resumeGuard.claim(), "the first claim must succeed")
+ XCTAssertFalse(resumeGuard.claim(), "the second claim must be refused")
+ XCTAssertFalse(resumeGuard.claim(), "every later claim must be refused")
+ }
+
+ /// The cache and network callbacks arrive on different queues, so the guard must hold under
+ /// contention — a bare `Bool` would let two callers through and trap the continuation.
+ func testResumeGuardGrantsOneClaimUnderConcurrency() {
+ for _ in 0..<200 {
+ let resumeGuard = ResumeOnceGuard()
+ let lock = NSLock()
+ var grantedCount = 0
+
+ DispatchQueue.concurrentPerform(iterations: 16) { _ in
+ if resumeGuard.claim() {
+ lock.lock()
+ grantedCount += 1
+ lock.unlock()
+ }
+ }
+
+ XCTAssertEqual(grantedCount, 1,
+ "exactly one concurrent caller may claim the continuation")
+ }
+ }
+}
diff --git a/Tests/ContentStackLogTest.swift b/Tests/ContentStackLogTest.swift
index fc8007be..611a0a94 100644
--- a/Tests/ContentStackLogTest.swift
+++ b/Tests/ContentStackLogTest.swift
@@ -72,3 +72,139 @@ class ContentStackLogTest: XCTestCase {
}
}
+
+/// Regression coverage for DX-10148.
+///
+/// The `.nsLog` log type was previously untested and every existing test message was a plain
+/// literal. That combination hid a crash: the message was handed to `NSLog` as the *format
+/// string*, so percent-encoded URLs and server-supplied error text were parsed for conversion
+/// specifiers and read arguments that were never supplied.
+class ContentstackLoggerFormatStringTest: XCTestCase {
+
+ private var originalLogType: ContentstackLogger.LogType!
+ private var originalLogLevel: ContentstackLogger.LogLevel!
+
+ override func setUp() {
+ super.setUp()
+ // These are process-global, and the rest of the suite mutates them without restoring.
+ originalLogType = ContentstackLogger.logType
+ originalLogLevel = ContentstackLogger.logLevel
+ }
+
+ override func tearDown() {
+ ContentstackLogger.logType = originalLogType
+ ContentstackLogger.logLevel = originalLogLevel
+ super.tearDown()
+ }
+
+ // MARK: - Helpers
+
+ /// A URL as `Stack.url(endpoint:parameters:)` percent-encodes it. `.urlQueryAllowed` leaves
+ /// `:` alone but encodes `{`, `}` and `"`, so `{"sku":..}` becomes `%7B%22sku%22:..` — and
+ /// `%22s` is a valid `char *` conversion.
+ private func encodedDeliveryURL(queryJSON: String) -> String {
+ let encoded = queryJSON.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
+ ?? queryJSON
+ return "https://cdn.contentstack.io/v3/content_types/product/entries"
+ + "?query=\(encoded)&environment=production"
+ }
+
+ /// Captures `stderr` while `body` runs. `NSLog` writes there as well as to the unified log,
+ /// which lets us assert on what was actually emitted rather than only that we survived.
+ private func captureStandardError(during body: () -> Void) -> String {
+ let pipe = Pipe()
+ fflush(stderr)
+ let savedStderr = dup(STDERR_FILENO)
+ dup2(pipe.fileHandleForWriting.fileDescriptor, STDERR_FILENO)
+
+ body()
+
+ fflush(stderr)
+ dup2(savedStderr, STDERR_FILENO)
+ close(savedStderr)
+ try? pipe.fileHandleForWriting.close()
+
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ return String(data: data, encoding: .utf8) ?? ""
+ }
+
+ // MARK: - Tests
+
+ /// Primary regression: a percent-encoded URL must reach the log verbatim. This one fails
+ /// rather than crashes on the unfixed code — a single specifier reads a zeroed argument slot,
+ /// so `%22s` rendered as `(null)` and swallowed the `s` of `sku`.
+ func testNSLogDoesNotInterpretPercentEncodedURLAsFormatString() throws {
+ ContentstackLogger.logType = .nsLog
+ ContentstackLogger.logLevel = .error
+
+ let message = """
+ Errored: 'GET' \(encodedDeliveryURL(queryJSON: "{\"sku\":\"1234567\"}"))
+ Message: The request timed out.
+ """
+
+ let output = captureStandardError { ContentstackLogger.log(.error, message: message) }
+
+ try XCTSkipIf(output.isEmpty, "NSLog did not write to stderr in this environment")
+
+ XCTAssertTrue(output.contains("%7B%22sku%22"),
+ "The encoded query must be logged verbatim. Got: \(output)")
+ XCTAssertFalse(output.contains("(null)"),
+ "A conversion specifier was consumed, so the message was still treated as "
+ + "a format string. Got: \(output)")
+ XCTAssertTrue(output.contains("[Contentstack] Error: "),
+ "Existing prefix formatting must be preserved. Got: \(output)")
+ }
+
+ /// The DX-10148 crash itself. Nine or more conversions exhaust the zeroed argument-register
+ /// area, so the ninth reads live stack memory and a pointer conversion there dereferences it.
+ /// A realistic nine-field catalog filter is enough. On the unfixed code this killed the test
+ /// process with SIGBUS/SIGSEGV rather than failing.
+ func testNSLogSurvivesQueryThatExhaustsArgumentRegisters() {
+ ContentstackLogger.logType = .nsLog
+ ContentstackLogger.logLevel = .error
+
+ let queryJSON = "{\"name\":\"a\",\"price\":1,\"category\":\"b\",\"description\":\"c\","
+ + "\"features\":\"d\",\"size\":\"e\",\"stock\":1,\"uid\":\"f\",\"sku\":\"g\"}"
+ let message = """
+ Errored: 'GET' \(encodedDeliveryURL(queryJSON: queryJSON))
+ Message: The request timed out.
+ """
+
+ ContentstackLogger.log(.error, message: message)
+ }
+
+ /// Server-supplied error text reaches this same logger through `APIError.handleError`, so a
+ /// response body is an untrusted format string too.
+ func testNSLogSurvivesHostileServerSuppliedMessage() {
+ ContentstackLogger.logType = .nsLog
+ ContentstackLogger.logLevel = .error
+
+ let hostile = String(repeating: "%@ %s %n %p ", count: 8)
+ ContentstackLogger.log(.error,
+ message: "Errored: 'GET' (403) https://example.com\n"
+ + "Message: \(hostile)")
+ }
+
+ /// `.print` is the documented workaround for already-released SDK versions, so it must leave
+ /// the message untouched as well.
+ func testPrintLogTypeDoesNotInterpretFormatSpecifiers() {
+ ContentstackLogger.logType = .print
+ ContentstackLogger.logLevel = .error
+
+ ContentstackLogger.log(.error, message: "Errored: %@ %s %7B%22sku%22 %n")
+ }
+
+ /// A custom logger must still receive the message verbatim — the existing prefix assertions
+ /// depend on that, and the fix must not change what `CustomLogger` is handed.
+ func testCustomLoggerReceivesMessageVerbatim() {
+ let spy = CustomeLogMessage()
+ ContentstackLogger.logType = .custom(spy)
+ ContentstackLogger.logLevel = .error
+
+ let message = "Errored: 'GET' "
+ + encodedDeliveryURL(queryJSON: "{\"sku\":\"1\"}") + " %@ %s"
+ ContentstackLogger.log(.error, message: message)
+
+ XCTAssertEqual(spy.customeMessage, "[Contentstack] Error: " + message)
+ }
+}