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) + } +}