diff --git a/Package.swift b/Package.swift index 9dab0f5e..a0643126 100644 --- a/Package.swift +++ b/Package.swift @@ -42,6 +42,7 @@ let sources: [String] = ["Qonversion/IDFA", "Qonversion/Qonversion/Services/QNIdentityService", "Qonversion/Qonversion/Services/QNStoreKitService", "Qonversion/Qonversion/Services/QONFallbackService", + "Qonversion/Qonversion/Services/QONRemoteConfigFallbackStore", "Qonversion/Qonversion/Services/QNUserInfoService", "Qonversion/Qonversion/Services/QONRemoteConfigService", "Qonversion/Qonversion/Services/QONExceptionManager", diff --git a/Qonversion.xcodeproj/project.pbxproj b/Qonversion.xcodeproj/project.pbxproj index 1d205342..3cb5c4d9 100644 --- a/Qonversion.xcodeproj/project.pbxproj +++ b/Qonversion.xcodeproj/project.pbxproj @@ -7,6 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + B2F000012F60000100000001 /* QONRemoteConfigFallbackStore.h in Headers */ = {isa = PBXBuildFile; fileRef = B2F000112F60000100000011 /* QONRemoteConfigFallbackStore.h */; }; + B2F000022F60000100000002 /* QONRemoteConfigFallbackStore.m in Sources */ = {isa = PBXBuildFile; fileRef = B2F000122F60000100000012 /* QONRemoteConfigFallbackStore.m */; }; + B2F000032F60000100000003 /* QONRemoteConfigFallbackStoreTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B2F000132F60000100000013 /* QONRemoteConfigFallbackStoreTests.m */; }; + B2F000042F60000100000004 /* qonversion_remote_config_defaults.json in Resources */ = {isa = PBXBuildFile; fileRef = B2F000142F60000100000014 /* qonversion_remote_config_defaults.json */; }; + B2F000052F60000100000005 /* qonversion_remote_config_defaults.json in Resources */ = {isa = PBXBuildFile; fileRef = B2F000142F60000100000014 /* qonversion_remote_config_defaults.json */; }; + B2F000062F60000100000006 /* qonversion_remote_config_defaults_all_types.json in Resources */ = {isa = PBXBuildFile; fileRef = B2F000152F60000100000015 /* qonversion_remote_config_defaults_all_types.json */; }; + B2F000072F60000100000007 /* qonversion_remote_config_defaults_number_boundaries.json in Resources */ = {isa = PBXBuildFile; fileRef = B2F000162F60000100000016 /* qonversion_remote_config_defaults_number_boundaries.json */; }; 0A17440D00C09DABC1E2B1D3 /* QONEntitlementsUpdateListenerAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = 0B69D8BEFE710A9EF0697B28 /* QONEntitlementsUpdateListenerAdapter.m */; }; 15EE5F2E2F6CBF096CB24CBA /* NoCodesLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F40A7683B495F16AD590A5EC /* NoCodesLoadingView.swift */; }; 176D395BD9D9A09A56F417D7 /* QONDeferredPurchasesListener.h in Headers */ = {isa = PBXBuildFile; fileRef = 61B460B7A476E7C352C3B615 /* QONDeferredPurchasesListener.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -350,6 +357,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + B2F000112F60000100000011 /* QONRemoteConfigFallbackStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QONRemoteConfigFallbackStore.h; sourceTree = ""; }; + B2F000122F60000100000012 /* QONRemoteConfigFallbackStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QONRemoteConfigFallbackStore.m; sourceTree = ""; }; + B2F000132F60000100000013 /* QONRemoteConfigFallbackStoreTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QONRemoteConfigFallbackStoreTests.m; sourceTree = ""; }; + B2F000142F60000100000014 /* qonversion_remote_config_defaults.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = qonversion_remote_config_defaults.json; sourceTree = ""; }; + B2F000152F60000100000015 /* qonversion_remote_config_defaults_all_types.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = qonversion_remote_config_defaults_all_types.json; sourceTree = ""; }; + B2F000162F60000100000016 /* qonversion_remote_config_defaults_number_boundaries.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = qonversion_remote_config_defaults_number_boundaries.json; sourceTree = ""; }; 02B82604A89181D262616958 /* NoCodesContextBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NoCodesContextBuilder.swift; sourceTree = ""; }; 0B69D8BEFE710A9EF0697B28 /* QONEntitlementsUpdateListenerAdapter.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = QONEntitlementsUpdateListenerAdapter.m; sourceTree = ""; }; 1037791CABEE4F1611A8561E /* Pods-Watch Sample Watch App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Watch Sample Watch App.release.xcconfig"; path = "Target Support Files/Pods-Watch Sample Watch App/Pods-Watch Sample Watch App.release.xcconfig"; sourceTree = ""; }; @@ -739,6 +752,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + B2F000202F60000100000020 /* QONRemoteConfigFallbackStore */ = { + isa = PBXGroup; + children = ( + B2F000112F60000100000011 /* QONRemoteConfigFallbackStore.h */, + B2F000122F60000100000012 /* QONRemoteConfigFallbackStore.m */, + ); + path = QONRemoteConfigFallbackStore; + sourceTree = ""; + }; 223E7B74704F72F8689D201D /* QONRedemptionManager */ = { isa = PBXGroup; children = ( @@ -929,6 +951,9 @@ 459DAC0E243E4E530011ECF3 /* Stubs */ = { isa = PBXGroup; children = ( + B2F000142F60000100000014 /* qonversion_remote_config_defaults.json */, + B2F000152F60000100000015 /* qonversion_remote_config_defaults_all_types.json */, + B2F000162F60000100000016 /* qonversion_remote_config_defaults_number_boundaries.json */, 70893C992B3EC136002C6B82 /* expected_entitlements.json */, 45FFA2EC24BEF379007EFB8F /* full_init.json */, 45FFA2DF24BE1548007EFB8F /* broken_data.json */, @@ -1718,6 +1743,7 @@ 8957326A26DD03A3009507A6 /* Services */ = { isa = PBXGroup; children = ( + B2F000202F60000100000020 /* QONRemoteConfigFallbackStore */, 7042E7812C1C5DB700C5AECF /* QONFallbackService */, 70D05A9029C9FF2C00EA5DDF /* QONRemoteConfigService */, 8957326B26DD03A3009507A6 /* QNStoreKitService */, @@ -1790,6 +1816,7 @@ 89A192A32604973200C3FCD9 /* Services */ = { isa = PBXGroup; children = ( + B2F000132F60000100000013 /* QONRemoteConfigFallbackStoreTests.m */, 89A192A42604974800C3FCD9 /* QNUserInfoServiceTests.m */, 89673C2826F35D29008D209A /* QNIdentityServiceTests.m */, ); @@ -1839,6 +1866,7 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( + B2F000012F60000100000001 /* QONRemoteConfigFallbackStore.h in Headers */, 895732F026DD03A3009507A6 /* QNErrorsMapper.h in Headers */, 895732E026DD03A3009507A6 /* QONOfferings+Protected.h in Headers */, 895732FD26DD03A3009507A6 /* QNStoreKitService.h in Headers */, @@ -2098,6 +2126,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B2F000052F60000100000005 /* qonversion_remote_config_defaults.json in Resources */, 454EF64824E5CC5A0070581E /* Assets.xcassets in Resources */, 706AC1FA2E68CE0D00ADA6CB /* nocodes_fallbacks.json in Resources */, 70CB7CE42C2DB37A00241FF1 /* qonversion_ios_fallbacks.json in Resources */, @@ -2124,6 +2153,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B2F000042F60000100000004 /* qonversion_remote_config_defaults.json in Resources */, + B2F000062F60000100000006 /* qonversion_remote_config_defaults_all_types.json in Resources */, + B2F000072F60000100000007 /* qonversion_remote_config_defaults_number_boundaries.json in Resources */, 70893C9A2B3EC136002C6B82 /* expected_entitlements.json in Resources */, 45FFA2ED24BEF379007EFB8F /* full_init.json in Resources */, 456ECD49249CA28300D2BC40 /* init_failed_state.json in Resources */, @@ -2296,6 +2328,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B2F000022F60000100000002 /* QONRemoteConfigFallbackStore.m in Sources */, 895732C326DD03A3009507A6 /* QNInMemoryStorage.m in Sources */, 8957330326DD03A3009507A6 /* QNIdentityService.m in Sources */, 895732C826DD03A3009507A6 /* QNInternalConstants.m in Sources */, @@ -2429,6 +2462,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B2F000032F60000100000003 /* QONRemoteConfigFallbackStoreTests.m in Sources */, 89673C2C26F37DC2008D209A /* XCTestCase+Helpers.m in Sources */, 45FFA2DE24BE1015007EFB8F /* QNTestConstants.m in Sources */, 459DAC0D243E4D610011ECF3 /* XCTestCase+TestJSON.m in Sources */, diff --git a/QonversionTests/Resources/Stubs/qonversion_remote_config_defaults.json b/QonversionTests/Resources/Stubs/qonversion_remote_config_defaults.json new file mode 100644 index 00000000..a002ed6d --- /dev/null +++ b/QonversionTests/Resources/Stubs/qonversion_remote_config_defaults.json @@ -0,0 +1 @@ +{"schemaVersion":1,"projectId":42,"environmentUid":"env-production","releaseUid":"release-portable","releaseNumber":7,"manifestContentHash":"0291766e896e3f36aca5385082d74e8bd70fabf12ee776b7dfa2cd4d961c9dea","defaultsDigest":"9e4dfcb4069901c3af4341383c814b24cdc39b20491f7a4593e0036d01f39540","defaults":[{"key":"alpha","variationUid":"variation-alpha","valueBase64":"IHsgIm1lc3NhZ2UiOiAi0J/RgNC40LLQtdGCIPCfkYsiIH0gCg=="},{"key":"beta","variationUid":"variation-beta","valueBase64":"bnVsbA=="}]} \ No newline at end of file diff --git a/QonversionTests/Resources/Stubs/qonversion_remote_config_defaults_all_types.json b/QonversionTests/Resources/Stubs/qonversion_remote_config_defaults_all_types.json new file mode 100644 index 00000000..bf4c2d75 --- /dev/null +++ b/QonversionTests/Resources/Stubs/qonversion_remote_config_defaults_all_types.json @@ -0,0 +1 @@ +{"schemaVersion":1,"projectId":42,"environmentUid":"env-production","releaseUid":"release-all-json-types","releaseNumber":8,"manifestContentHash":"aa6a89035cd667e719c826029b46d063107991009382955b2332974fea7e417d","defaultsDigest":"05237b0c07eac66a5ae1f7268c9afef3f0170b8649d22559388a4071ee81e95f","defaults":[{"key":"array","variationUid":"variation-array","valueBase64":"WzEsInR3byIsZmFsc2Vd"},{"key":"bool","variationUid":"variation-bool","valueBase64":"dHJ1ZQ=="},{"key":"null","variationUid":"variation-null","valueBase64":"bnVsbA=="},{"key":"number","variationUid":"variation-number","valueBase64":"NDIuNQ=="},{"key":"object","variationUid":"variation-object","valueBase64":"eyJlbmFibGVkIjp0cnVlLCJuZXN0ZWQiOiJ2YWx1ZSJ9"},{"key":"string","variationUid":"variation-string","valueBase64":"ImhlbGxvIg=="},{"key":"unicode","variationUid":"variation-unicode","valueBase64":"ItCf0YDQuNCy0LXRgiDwn5GLIg=="}]} \ No newline at end of file diff --git a/QonversionTests/Resources/Stubs/qonversion_remote_config_defaults_number_boundaries.json b/QonversionTests/Resources/Stubs/qonversion_remote_config_defaults_number_boundaries.json new file mode 100644 index 00000000..0a8860bb --- /dev/null +++ b/QonversionTests/Resources/Stubs/qonversion_remote_config_defaults_number_boundaries.json @@ -0,0 +1 @@ +{"schemaVersion":1,"projectId":42,"environmentUid":"env-production","releaseUid":"release-number-boundaries","releaseNumber":9,"manifestContentHash":"3896effad7acc9270542cb9c9ce76975c65888a19f6efc6eafba299e8e75be4b","defaultsDigest":"0f8138237ed0fc124a161e6dcc2f5fe6e3fa78691b0dea2dbf7f42bc66736d8d","defaults":[{"key":"float_max","variationUid":"variation-float_max","valueBase64":"MWUzMDg="},{"key":"int_max","variationUid":"variation-int_max","valueBase64":"OTAwNzE5OTI1NDc0MDk5MQ=="},{"key":"int_min","variationUid":"variation-int_min","valueBase64":"LTkwMDcxOTkyNTQ3NDA5OTE="}]} \ No newline at end of file diff --git a/QonversionTests/Services/QONRemoteConfigFallbackStoreTests.m b/QonversionTests/Services/QONRemoteConfigFallbackStoreTests.m new file mode 100644 index 00000000..ee288415 --- /dev/null +++ b/QonversionTests/Services/QONRemoteConfigFallbackStoreTests.m @@ -0,0 +1,554 @@ +// +// QONRemoteConfigFallbackStoreTests.m +// QonversionTests +// +// Copyright © 2026 Qonversion Inc. All rights reserved. +// + +#import +#import +#import + +#import "QONRemoteConfigFallbackStore.h" +#import "Qonversion.h" + +static NSString *const kRemoteConfigDefaultsFileName = @"qonversion_remote_config_defaults"; +static NSString *const kRemoteConfigDefaultsFileExtension = @"json"; +static NSString *const kEscapedSurrogateManifestHash = + @"e2d2e6cd92b6aca3bae40d4196bcedb6beade7b3dbf672bc13193b3dd9e7ad02"; + +static NSData *QONTestASCIIData(NSString *value) { + return [value dataUsingEncoding:NSASCIIStringEncoding]; +} + +static void QONTestAppendDigestPart(CC_SHA256_CTX *context, NSData *part) { + uint64_t length = CFSwapInt64HostToBig((uint64_t)part.length); + CC_SHA256_Update(context, &length, (CC_LONG)sizeof(length)); + CC_SHA256_Update(context, part.bytes, (CC_LONG)part.length); +} + +static NSString *QONTestSingleDefaultDigest(int64_t projectID, + int64_t releaseNumber, + NSData *rawValue) { + CC_SHA256_CTX context; + CC_SHA256_Init(&context); + NSArray *parts = @[ + QONTestASCIIData(@"qonversion.remote-config-fallback-defaults.v1"), + QONTestASCIIData(@"1"), + QONTestASCIIData([NSString stringWithFormat:@"%lld", (long long)projectID]), + [@"env-production" dataUsingEncoding:NSUTF8StringEncoding], + [@"release-escaped-surrogate" dataUsingEncoding:NSUTF8StringEncoding], + QONTestASCIIData([NSString stringWithFormat:@"%lld", (long long)releaseNumber]), + QONTestASCIIData(kEscapedSurrogateManifestHash), + QONTestASCIIData(@"1"), + [@"escaped_pair" dataUsingEncoding:NSUTF8StringEncoding], + [@"variation-escaped-pair" dataUsingEncoding:NSUTF8StringEncoding], + rawValue, + ]; + for (NSData *part in parts) { + QONTestAppendDigestPart(&context, part); + } + uint8_t digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256_Final(digest, &context); + NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (NSUInteger index = 0; index < CC_SHA256_DIGEST_LENGTH; index++) { + [hex appendFormat:@"%02x", digest[index]]; + } + return hex; +} + +static NSData *QONTestSingleDefaultArtifact(int64_t projectID, + int64_t releaseNumber, + NSString *rawJSON) { + NSData *rawValue = [rawJSON dataUsingEncoding:NSUTF8StringEncoding]; + NSString *encoded = [rawValue base64EncodedStringWithOptions:0]; + NSString *digest = QONTestSingleDefaultDigest(projectID, releaseNumber, rawValue); + NSString *artifact = [NSString stringWithFormat: + @"{\"schemaVersion\":1,\"projectId\":%lld,\"environmentUid\":\"env-production\"," + @"\"releaseUid\":\"release-escaped-surrogate\",\"releaseNumber\":%lld," + @"\"manifestContentHash\":\"%@\",\"defaultsDigest\":\"%@\"," + @"\"defaults\":[{\"key\":\"escaped_pair\",\"variationUid\":\"variation-escaped-pair\"," + @"\"valueBase64\":\"%@\"}]}", + (long long)projectID, (long long)releaseNumber, + kEscapedSurrogateManifestHash, digest, encoded]; + return [artifact dataUsingEncoding:NSUTF8StringEncoding]; +} + +@interface QONRemoteConfigFallbackStoreTests : XCTestCase +@end + +@implementation QONRemoteConfigFallbackStoreTests + +- (NSDictionary *)goldenManifest { + NSBundle *bundle = [NSBundle bundleForClass:self.class]; + NSURL *url = [bundle URLForResource:kRemoteConfigDefaultsFileName + withExtension:kRemoteConfigDefaultsFileExtension]; + XCTAssertNotNil(url); + + NSData *data = [NSData dataWithContentsOfURL:url]; + XCTAssertNotNil(data); + + NSError *error = nil; + id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + XCTAssertNil(error); + XCTAssertTrue([object isKindOfClass:NSDictionary.class]); + return object; +} + +- (NSDictionary *)allTypesManifest { + NSBundle *bundle = [NSBundle bundleForClass:self.class]; + NSURL *url = [bundle URLForResource:@"qonversion_remote_config_defaults_all_types" + withExtension:kRemoteConfigDefaultsFileExtension]; + XCTAssertNotNil(url); + NSData *data = [NSData dataWithContentsOfURL:url]; + XCTAssertNotNil(data); + id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + XCTAssertTrue([object isKindOfClass:NSDictionary.class]); + return object; +} + +- (QONRemoteConfigFallbackStore *)storeWithFixtureNamed:(NSString *)name { + NSBundle *bundle = [NSBundle bundleForClass:self.class]; + NSURL *url = [bundle URLForResource:name withExtension:kRemoteConfigDefaultsFileExtension]; + XCTAssertNotNil(url); + NSData *data = [NSData dataWithContentsOfURL:url]; + XCTAssertNotNil(data); + return [[QONRemoteConfigFallbackStore alloc] + initWithBundle:[self bundleWithFileName:@"qonversion_remote_config_defaults.json" data:data]]; +} + +- (NSData *)fixtureDataNamed:(NSString *)name { + NSURL *url = [[NSBundle bundleForClass:self.class] URLForResource:name + withExtension:kRemoteConfigDefaultsFileExtension]; + XCTAssertNotNil(url); + NSData *data = [NSData dataWithContentsOfURL:url]; + XCTAssertNotNil(data); + return data; +} + +- (QONRemoteConfigFallbackStore *)storeWithEncodedArtifact:(NSData *)data { + return [[QONRemoteConfigFallbackStore alloc] + initWithBundle:[self bundleWithFileName:@"qonversion_remote_config_defaults.json" data:data]]; +} + +- (NSBundle *)bundleWithFileName:(NSString *)fileName data:(NSData *)data { + NSString *bundlePath = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.bundle", NSUUID.UUID.UUIDString]]; + NSError *error = nil; + XCTAssertTrue([[NSFileManager defaultManager] createDirectoryAtPath:bundlePath + withIntermediateDirectories:YES + attributes:nil + error:&error]); + XCTAssertNil(error); + + NSDictionary *info = @{ + @"CFBundleIdentifier": [NSString stringWithFormat:@"io.qonversion.tests.%@", NSUUID.UUID.UUIDString], + @"CFBundleName": @"RemoteConfigFallbackFixture", + @"CFBundlePackageType": @"BNDL", + }; + XCTAssertTrue([info writeToFile:[bundlePath stringByAppendingPathComponent:@"Info.plist"] atomically:YES]); + + if (data) { + XCTAssertTrue([data writeToFile:[bundlePath stringByAppendingPathComponent:fileName] + options:NSDataWritingAtomic + error:&error]); + XCTAssertNil(error); + } + + NSBundle *bundle = [NSBundle bundleWithPath:bundlePath]; + XCTAssertNotNil(bundle); + return bundle; +} + +- (NSBundle *)bundleWithManifest:(NSDictionary *)manifest { + NSError *error = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:manifest options:0 error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(data); + return [self bundleWithFileName:@"qonversion_remote_config_defaults.json" data:data]; +} + +- (QONRemoteConfigFallbackStore *)storeWithManifest:(NSDictionary *)manifest { + return [[QONRemoteConfigFallbackStore alloc] initWithBundle:[self bundleWithManifest:manifest]]; +} + +- (NSMutableDictionary *)mutableGoldenManifest { + NSMutableDictionary *manifest = [[self goldenManifest] mutableCopy]; + NSMutableArray *defaults = [NSMutableArray new]; + for (NSDictionary *entry in manifest[@"defaults"]) { + [defaults addObject:[entry mutableCopy]]; + } + manifest[@"defaults"] = defaults; + return manifest; +} + +- (NSMutableDictionary *)mutableAllTypesManifest { + NSMutableDictionary *manifest = [[self allTypesManifest] mutableCopy]; + NSMutableArray *defaults = [NSMutableArray new]; + for (NSDictionary *entry in manifest[@"defaults"]) { + [defaults addObject:[entry mutableCopy]]; + } + manifest[@"defaults"] = defaults; + return manifest; +} + +- (void)testReadsServerGoldenArtifactByteForByteContract { + QONRemoteConfigFallbackStore *store = [self storeWithFixtureNamed:kRemoteConfigDefaultsFileName]; + XCTAssertEqualObjects([store valueForContextKey:@"alpha"], (@{@"message": @"Привет 👋"})); + XCTAssertEqualObjects([store valueForContextKey:@"beta"], NSNull.null); +} + +- (void)testReadsEverySupportedJSONValueFromGoldenManifest { + QONRemoteConfigFallbackStore *store = [self storeWithFixtureNamed:@"qonversion_remote_config_defaults_all_types"]; + + XCTAssertEqualObjects([store valueForContextKey:@"array"], (@[@1, @"two", @NO])); + XCTAssertEqualObjects([store valueForContextKey:@"bool"], @YES); + XCTAssertEqualObjects([store valueForContextKey:@"null"], NSNull.null); + XCTAssertEqualObjects([store valueForContextKey:@"number"], @42.5); + XCTAssertEqualObjects([store valueForContextKey:@"object"], (@{@"enabled": @YES, @"nested": @"value"})); + XCTAssertEqualObjects([store valueForContextKey:@"string"], @"hello"); + XCTAssertEqualObjects([store valueForContextKey:@"unicode"], @"Привет 👋"); +} + +- (void)testAcceptsPortableJSONNumberBoundariesFromProducerGolden { + QONRemoteConfigFallbackStore *store = [self storeWithFixtureNamed:@"qonversion_remote_config_defaults_number_boundaries"]; + XCTAssertEqualWithAccuracy([[store valueForContextKey:@"float_max"] doubleValue], 1e308, 0.0); + XCTAssertEqualObjects([store valueForContextKey:@"int_max"], @9007199254740991LL); + XCTAssertEqualObjects([store valueForContextKey:@"int_min"], @(-9007199254740991LL)); +} + +- (void)testReturnsNilForMissingContextKey { + QONRemoteConfigFallbackStore *store = [self storeWithFixtureNamed:@"qonversion_remote_config_defaults_all_types"]; + XCTAssertNil([store valueForContextKey:@"missing"]); + XCTAssertNil([store valueForContextKey:nil]); +} + +- (void)testReturnedContainersCannotMutateCachedValues { + QONRemoteConfigFallbackStore *store = [self storeWithFixtureNamed:@"qonversion_remote_config_defaults_all_types"]; + + NSDictionary *first = [store valueForContextKey:@"object"]; + XCTAssertFalse([first isKindOfClass:NSMutableDictionary.class]); + NSMutableDictionary *callerCopy = [first mutableCopy]; + callerCopy[@"nested"] = @"changed"; + + XCTAssertEqualObjects([store valueForContextKey:@"object"], (@{@"enabled": @YES, @"nested": @"value"})); +} + +- (void)testValidatesExactRawJSONBytesThroughGoldenDigest { + NSMutableDictionary *manifest = [self mutableAllTypesManifest]; + NSMutableDictionary *unicode = manifest[@"defaults"][6]; + NSData *escapedJSON = [@"\"\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442 \\ud83d\\udc4b\"" + dataUsingEncoding:NSUTF8StringEncoding]; + unicode[@"valueBase64"] = [escapedJSON base64EncodedStringWithOptions:0]; + + // The decoded value is semantically identical, but the producer digest is + // over the exact raw JSON bytes and therefore must reject this artifact. + QONRemoteConfigFallbackStore *store = [self storeWithManifest:manifest]; + XCTAssertNil([store valueForContextKey:@"unicode"]); +} + +- (void)testRejectsDigestMismatch { + NSMutableDictionary *manifest = [self mutableGoldenManifest]; + manifest[@"defaultsDigest"] = @"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"bool"]); +} + +- (void)testRejectsUnknownSchema { + NSMutableDictionary *manifest = [self mutableGoldenManifest]; + manifest[@"schemaVersion"] = @2; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"bool"]); +} + +- (void)testRejectsMissingRequiredHeader { + NSMutableDictionary *manifest = [self mutableGoldenManifest]; + [manifest removeObjectForKey:@"releaseUid"]; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"bool"]); +} + +- (void)testRejectsNonPositiveOrNonIntegralScopeNumbers { + NSMutableDictionary *zeroProject = [self mutableGoldenManifest]; + zeroProject[@"projectId"] = @0; + XCTAssertNil([[self storeWithManifest:zeroProject] valueForContextKey:@"alpha"]); + + NSMutableDictionary *negativeRelease = [self mutableGoldenManifest]; + negativeRelease[@"releaseNumber"] = @(-1); + XCTAssertNil([[self storeWithManifest:negativeRelease] valueForContextKey:@"alpha"]); + + NSString *golden = [[NSString alloc] initWithData:[self fixtureDataNamed:kRemoteConfigDefaultsFileName] + encoding:NSUTF8StringEncoding]; + NSString *floatingProject = [golden stringByReplacingOccurrencesOfString:@"\"projectId\":42" + withString:@"\"projectId\":42.0"]; + XCTAssertNil([[self storeWithEncodedArtifact:[floatingProject dataUsingEncoding:NSUTF8StringEncoding]] + valueForContextKey:@"alpha"]); +} + +- (void)testRejectsUppercaseOrMalformedHex { + NSMutableDictionary *uppercaseHash = [self mutableGoldenManifest]; + uppercaseHash[@"manifestContentHash"] = [uppercaseHash[@"manifestContentHash"] uppercaseString]; + XCTAssertNil([[self storeWithManifest:uppercaseHash] valueForContextKey:@"bool"]); + + NSMutableDictionary *shortDigest = [self mutableGoldenManifest]; + shortDigest[@"defaultsDigest"] = @"00"; + XCTAssertNil([[self storeWithManifest:shortDigest] valueForContextKey:@"bool"]); +} + +- (void)testRejectsNonCanonicalBase64 { + NSMutableDictionary *manifest = [self mutableGoldenManifest]; + NSMutableDictionary *entry = manifest[@"defaults"][1]; + entry[@"valueBase64"] = @"dHJ1ZQ"; // canonical form is dHJ1ZQ== + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"bool"]); +} + +- (void)testRejectsInvalidRawJSON { + NSMutableDictionary *manifest = [self mutableGoldenManifest]; + NSMutableDictionary *entry = manifest[@"defaults"][1]; + entry[@"valueBase64"] = [[@"not-json" dataUsingEncoding:NSUTF8StringEncoding] + base64EncodedStringWithOptions:0]; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"bool"]); +} + +- (void)testRejectsDuplicateMembersInsideRawJSONValue { + NSMutableDictionary *manifest = [self mutableAllTypesManifest]; + NSMutableDictionary *entry = manifest[@"defaults"][4]; + entry[@"valueBase64"] = [[@"{\"duplicate\":1,\"duplicate\":2}" + dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0]; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"object"]); +} + +- (void)testRejectsRawJSONDeeperThanSixtyFourContainers { + NSMutableString *nested = [NSMutableString new]; + for (NSUInteger index = 0; index < 65; index++) [nested appendString:@"["]; + [nested appendString:@"null"]; + for (NSUInteger index = 0; index < 65; index++) [nested appendString:@"]"]; + + NSMutableDictionary *manifest = [self mutableAllTypesManifest]; + NSMutableDictionary *entry = manifest[@"defaults"][0]; + entry[@"valueBase64"] = [[nested dataUsingEncoding:NSUTF8StringEncoding] + base64EncodedStringWithOptions:0]; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"array"]); +} + +- (void)testRejectsInvalidUTF8RawJSON { + const uint8_t bytes[] = {0x22, 0xff, 0x22}; + NSMutableDictionary *manifest = [self mutableAllTypesManifest]; + NSMutableDictionary *entry = manifest[@"defaults"][5]; + entry[@"valueBase64"] = [[NSData dataWithBytes:bytes length:sizeof(bytes)] + base64EncodedStringWithOptions:0]; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"string"]); +} + +- (void)testRejectsNonFiniteBinary64JSONNumber { + NSMutableDictionary *manifest = [self mutableAllTypesManifest]; + NSMutableDictionary *entry = manifest[@"defaults"][3]; + entry[@"valueBase64"] = [[@"1e309" dataUsingEncoding:NSUTF8StringEncoding] + base64EncodedStringWithOptions:0]; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"number"]); +} + +- (void)testEscapedSurrogatePairsMatchPortableProducerProfile { + QONRemoteConfigFallbackStore *valid = [self storeWithEncodedArtifact: + QONTestSingleDefaultArtifact(42, 10, @"\"\\ud83d\\udc4b\"")]; + XCTAssertEqualObjects([valid valueForContextKey:@"escaped_pair"], @"👋"); + + for (NSString *raw in @[@"\"\\ud83d\"", @"\"\\ud83d\\u0041\"", @"\"\\udc4b\""]) { + QONRemoteConfigFallbackStore *invalid = [self storeWithEncodedArtifact: + QONTestSingleDefaultArtifact(42, 10, raw)]; + XCTAssertNil([invalid valueForContextKey:@"escaped_pair"], @"%@", raw); + } +} + +- (void)testHeaderIntegersMustRemainExactlyPortableAcrossSDKs { + XCTAssertNil([[self storeWithEncodedArtifact: + QONTestSingleDefaultArtifact(9007199254740992LL, 10, @"true")] + valueForContextKey:@"escaped_pair"]); + XCTAssertNil([[self storeWithEncodedArtifact: + QONTestSingleDefaultArtifact(42, 9007199254740992LL, @"true")] + valueForContextKey:@"escaped_pair"]); +} + +- (void)testRejectsPlainIntegersOutsidePortableExactRange { + for (NSString *raw in @[@"9007199254740992", @"-9007199254740992"]) { + NSMutableDictionary *manifest = [self mutableAllTypesManifest]; + NSMutableDictionary *entry = manifest[@"defaults"][3]; + entry[@"valueBase64"] = [[raw dataUsingEncoding:NSUTF8StringEncoding] + base64EncodedStringWithOptions:0]; + QONRemoteConfigFallbackStore *store = [self storeWithManifest:manifest]; + XCTAssertNil([store valueForContextKey:@"number"]); + XCTAssertNil([store valueForContextKey:@"bool"]); + } +} + +- (void)testOneCorruptDefaultPreventsEveryArtifactValueFromBeingExposed { + NSMutableDictionary *manifest = [self mutableAllTypesManifest]; + NSArray *defaults = manifest[@"defaults"]; + NSMutableDictionary *lastEntry = defaults.lastObject; + lastEntry[@"valueBase64"] = [[@"not-json" dataUsingEncoding:NSUTF8StringEncoding] + base64EncodedStringWithOptions:0]; + + QONRemoteConfigFallbackStore *store = [self storeWithManifest:manifest]; + XCTAssertNil([store valueForContextKey:@"array"]); + XCTAssertNil([store valueForContextKey:@"bool"]); +} + +- (void)testRejectsUnsortedKeys { + NSMutableDictionary *manifest = [self mutableGoldenManifest]; + NSMutableArray *defaults = manifest[@"defaults"]; + [defaults exchangeObjectAtIndex:0 withObjectAtIndex:1]; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"bool"]); +} + +- (void)testRejectsDuplicateKeys { + NSMutableDictionary *manifest = [self mutableGoldenManifest]; + NSMutableArray *defaults = manifest[@"defaults"]; + [defaults insertObject:[defaults.firstObject mutableCopy] atIndex:1]; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"array"]); +} + +- (void)testRejectsEmptyOrOversizedKeyAndUIDs { + NSMutableDictionary *emptyKey = [self mutableAllTypesManifest]; + emptyKey[@"defaults"][0][@"key"] = @""; + XCTAssertNil([[self storeWithManifest:emptyKey] valueForContextKey:@""]); + + NSMutableDictionary *oversizedKey = [self mutableAllTypesManifest]; + oversizedKey[@"defaults"][0][@"key"] = [@"é" stringByPaddingToLength:258 + withString:@"é" + startingAtIndex:0]; + XCTAssertNil([[self storeWithManifest:oversizedKey] valueForContextKey:@"array"]); + + NSMutableDictionary *oversizedUID = [self mutableAllTypesManifest]; + oversizedUID[@"defaults"][0][@"variationUid"] = [@"x" stringByPaddingToLength:37 + withString:@"x" + startingAtIndex:0]; + XCTAssertNil([[self storeWithManifest:oversizedUID] valueForContextKey:@"array"]); + + NSMutableDictionary *oversizedEnvironment = [self mutableAllTypesManifest]; + oversizedEnvironment[@"environmentUid"] = [@"👋" stringByPaddingToLength:74 + withString:@"👋" + startingAtIndex:0]; + XCTAssertNil([[self storeWithManifest:oversizedEnvironment] valueForContextKey:@"array"]); +} + +- (void)testRejectsMoreThanOneThousandDefaults { + NSMutableDictionary *manifest = [self mutableGoldenManifest]; + NSMutableArray *defaults = [NSMutableArray new]; + for (NSUInteger index = 0; index < 1001; index++) { + [defaults addObject:@{ + @"key": [NSString stringWithFormat:@"key-%04lu", (unsigned long)index], + @"variationUid": @"variation", + @"valueBase64": @"bnVsbA==", + }]; + } + manifest[@"defaults"] = defaults; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"key-0000"]); +} + +- (void)testRejectsDecodedValueLargerThanSixtyFourKiB { + NSMutableDictionary *manifest = [self mutableGoldenManifest]; + NSMutableDictionary *entry = manifest[@"defaults"][1]; + NSMutableData *oversizedJSON = [NSMutableData dataWithLength:64 * 1024 + 1]; + memset(oversizedJSON.mutableBytes, ' ', oversizedJSON.length); + ((uint8_t *)oversizedJSON.mutableBytes)[0] = '0'; + entry[@"valueBase64"] = [oversizedJSON base64EncodedStringWithOptions:0]; + XCTAssertNil([[self storeWithManifest:manifest] valueForContextKey:@"bool"]); +} + +- (void)testRejectsFileLargerThanEightMiBBeforeParsing { + NSMutableData *data = [NSMutableData dataWithLength:8 * 1024 * 1024 + 1]; + memset(data.mutableBytes, ' ', data.length); + QONRemoteConfigFallbackStore *store = [[QONRemoteConfigFallbackStore alloc] + initWithBundle:[self bundleWithFileName:@"qonversion_remote_config_defaults.json" data:data]]; + XCTAssertNil([store valueForContextKey:@"bool"]); +} + +- (void)testRejectsNonCanonicalWhitespaceUnknownAndDuplicateRootFields { + NSData *goldenData = [self fixtureDataNamed:kRemoteConfigDefaultsFileName]; + + NSMutableData *withNewline = [goldenData mutableCopy]; + const uint8_t newline = '\n'; + [withNewline appendBytes:&newline length:1]; + XCTAssertNil([[self storeWithEncodedArtifact:withNewline] valueForContextKey:@"alpha"]); + + NSString *golden = [[NSString alloc] initWithData:goldenData encoding:NSUTF8StringEncoding]; + NSString *withUnknown = [golden stringByReplacingOccurrencesOfString:@",\"defaults\":[" + withString:@",\"unknown\":true,\"defaults\":["]; + XCTAssertNil([[self storeWithEncodedArtifact:[withUnknown dataUsingEncoding:NSUTF8StringEncoding]] + valueForContextKey:@"alpha"]); + + NSString *withDuplicate = [golden stringByReplacingOccurrencesOfString:@"{\"schemaVersion\":1," + withString:@"{\"schemaVersion\":1,\"schemaVersion\":1,"]; + XCTAssertNil([[self storeWithEncodedArtifact:[withDuplicate dataUsingEncoding:NSUTF8StringEncoding]] + valueForContextKey:@"alpha"]); + + NSString *withUnknownDefaultField = [golden + stringByReplacingOccurrencesOfString:@"{\"key\":\"alpha\"" + withString:@"{\"unknown\":true,\"key\":\"alpha\""]; + XCTAssertNil([[self storeWithEncodedArtifact:[withUnknownDefaultField dataUsingEncoding:NSUTF8StringEncoding]] + valueForContextKey:@"alpha"]); +} + +- (void)testDoesNotAcceptLegacyFallbackFile { + NSError *error = nil; + NSData *legacyData = [NSJSONSerialization dataWithJSONObject:@{ + @"remote_config_list": @[@{ + @"payload": @{@"bool": @YES}, + @"source": @{@"context_key": @"bool"}, + }], + } options:0 error:&error]; + XCTAssertNil(error); + + QONRemoteConfigFallbackStore *store = [[QONRemoteConfigFallbackStore alloc] + initWithBundle:[self bundleWithFileName:@"qonversion_ios_fallbacks.json" data:legacyData]]; + XCTAssertNil([store valueForContextKey:@"bool"]); +} + +- (void)testCachesTheValidatedImmutableArtifactForTheLifetimeOfTheStore { + NSData *goldenData = [self fixtureDataNamed:kRemoteConfigDefaultsFileName]; + NSBundle *bundle = [self bundleWithFileName:@"qonversion_remote_config_defaults.json" data:goldenData]; + QONRemoteConfigFallbackStore *store = [[QONRemoteConfigFallbackStore alloc] initWithBundle:bundle]; + XCTAssertEqualObjects([store valueForContextKey:@"alpha"], (@{@"message": @"Привет 👋"})); + + NSURL *artifact = [bundle.bundleURL URLByAppendingPathComponent:@"qonversion_remote_config_defaults.json"]; + XCTAssertTrue([[@"{}" dataUsingEncoding:NSUTF8StringEncoding] writeToURL:artifact atomically:YES]); + XCTAssertEqualObjects([store valueForContextKey:@"alpha"], (@{@"message": @"Привет 👋"})); + + QONRemoteConfigFallbackStore *freshStore = [[QONRemoteConfigFallbackStore alloc] + initWithBundle:[NSBundle bundleWithPath:bundle.bundlePath]]; + XCTAssertNil([freshStore valueForContextKey:@"alpha"]); +} + +- (void)testConcurrentReadsShareAValidatedImmutableCache { + QONRemoteConfigFallbackStore *store = [self storeWithFixtureNamed:@"qonversion_remote_config_defaults_all_types"]; + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + NSObject *failuresLock = [NSObject new]; + __block NSUInteger failures = 0; + + for (NSUInteger index = 0; index < 500; index++) { + dispatch_group_async(group, queue, ^{ + id value = [store valueForContextKey:@"unicode"]; + if (![value isEqual:@"Привет 👋"]) { + @synchronized (failuresLock) { + failures += 1; + } + } + }); + } + + XCTAssertEqual(dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)), 0); + XCTAssertEqual(failures, 0); +} + +- (void)testPublicClassGetterReadsTheHostBundleWithoutSDKInitialization { + // The class entry point is intentionally usable before initWithConfig:. The + // Sample test host embeds the golden artifact as a main-bundle resource. + XCTAssertEqualObjects([Qonversion fallbackRemoteConfigValueForContextKey:@"alpha"], + (@{@"message": @"Привет 👋"})); +} + +- (void)testInstanceForwarderMatchesThePreInitClassGetterWithoutUsingInstanceState { + Qonversion *uninitializedInstance = class_createInstance(Qonversion.class, 0); + XCTAssertEqualObjects([uninitializedInstance fallbackRemoteConfigValueForContextKey:@"alpha"], + [Qonversion fallbackRemoteConfigValueForContextKey:@"alpha"]); +} + +@end diff --git a/Sources/Qonversion/Public/Qonversion.h b/Sources/Qonversion/Public/Qonversion.h index 10962b52..be0ede8d 100644 --- a/Sources/Qonversion/Public/Qonversion.h +++ b/Sources/Qonversion/Public/Qonversion.h @@ -309,6 +309,30 @@ NS_SWIFT_NAME(remoteConfigList(contextKeys:includeEmptyContextKey:completion:)); */ - (void)remoteConfigList:(QONRemoteConfigListCompletionHandler)completion; +/** + Reads a generated Remote Config default directly from + `qonversion_remote_config_defaults.json` in the application bundle. + + This class method is synchronous and can be called before initWithConfig:. It + never initializes the SDK and never consults the network, identity, cache, or + Documents directory. Returns nil when the key is absent or the complete + artifact is missing or invalid. A JSON null is returned as NSNull. + + @param contextKey context key whose bundled default should be returned. + */ ++ (nullable id)fallbackRemoteConfigValueForContextKey:(NSString *)contextKey +NS_SWIFT_NAME(fallbackRemoteConfigValue(contextKey:)); + +/** + Instance convenience for the bundled Remote Config default getter. This has + the same bundle-only behavior as the class method and does not use instance + state. + + @param contextKey context key whose bundled default should be returned. + */ +- (nullable id)fallbackRemoteConfigValueForContextKey:(NSString *)contextKey +NS_SWIFT_NAME(fallbackRemoteConfigValue(contextKey:)); + /** Invalidates the in-memory cache of remote configs so the next remoteConfig or remoteConfigList call fetches a fresh targeting evaluation from the server diff --git a/Sources/Qonversion/Public/Qonversion.m b/Sources/Qonversion/Public/Qonversion.m index b4c22167..98d60f9d 100644 --- a/Sources/Qonversion/Public/Qonversion.m +++ b/Sources/Qonversion/Public/Qonversion.m @@ -15,6 +15,7 @@ #import "QONExceptionManager.h" #import "QONUserProperty.h" #import "QONFallbackService.h" +#import "QONRemoteConfigFallbackStore.h" #import "QONRedemptionManager.h" static id shared = nil; @@ -249,6 +250,19 @@ - (void)remoteConfigList:(QONRemoteConfigListCompletionHandler)completion { [self.remoteConfigManager obtainRemoteConfigList:completion]; } ++ (id)fallbackRemoteConfigValueForContextKey:(NSString *)contextKey { + static QONRemoteConfigFallbackStore *fallbackStore = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fallbackStore = [[QONRemoteConfigFallbackStore alloc] initWithBundle:NSBundle.mainBundle]; + }); + return [fallbackStore valueForContextKey:contextKey]; +} + +- (id)fallbackRemoteConfigValueForContextKey:(NSString *)contextKey { + return [Qonversion fallbackRemoteConfigValueForContextKey:contextKey]; +} + - (void)invalidateRemoteConfigsCache { [self.remoteConfigManager invalidateRemoteConfigsCache]; } diff --git a/Sources/Qonversion/Qonversion/Services/QONRemoteConfigFallbackStore/QONRemoteConfigFallbackStore.h b/Sources/Qonversion/Qonversion/Services/QONRemoteConfigFallbackStore/QONRemoteConfigFallbackStore.h new file mode 100644 index 00000000..29b1cedd --- /dev/null +++ b/Sources/Qonversion/Qonversion/Services/QONRemoteConfigFallbackStore/QONRemoteConfigFallbackStore.h @@ -0,0 +1,30 @@ +// +// QONRemoteConfigFallbackStore.h +// Qonversion +// +// Copyright © 2026 Qonversion Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + A process-local, immutable view of the generated Remote Config defaults in a + specific bundle. This store never consults Documents, persistence, identity, + or the network. + */ +@interface QONRemoteConfigFallbackStore : NSObject + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithBundle:(NSBundle *)bundle NS_DESIGNATED_INITIALIZER; + +/** + Returns the JSON value for a context key, or nil when the key is absent or the + complete artifact fails validation. A JSON null is returned as NSNull. + */ +- (nullable id)valueForContextKey:(nullable NSString *)contextKey; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Qonversion/Qonversion/Services/QONRemoteConfigFallbackStore/QONRemoteConfigFallbackStore.m b/Sources/Qonversion/Qonversion/Services/QONRemoteConfigFallbackStore/QONRemoteConfigFallbackStore.m new file mode 100644 index 00000000..a4c9a19a --- /dev/null +++ b/Sources/Qonversion/Qonversion/Services/QONRemoteConfigFallbackStore/QONRemoteConfigFallbackStore.m @@ -0,0 +1,698 @@ +// +// QONRemoteConfigFallbackStore.m +// Qonversion +// +// Copyright © 2026 Qonversion Inc. All rights reserved. +// + +#import "QONRemoteConfigFallbackStore.h" + +#import + +static NSString *const kQONRemoteConfigDefaultsResourceName = @"qonversion_remote_config_defaults"; +static NSString *const kQONRemoteConfigDefaultsResourceExtension = @"json"; + +static NSUInteger const kQONRemoteConfigDefaultsMaxArtifactBytes = 8 * 1024 * 1024; +static NSUInteger const kQONRemoteConfigDefaultsMaxCount = 1000; +static NSUInteger const kQONRemoteConfigDefaultsMaxValueBytes = 64 * 1024; +static NSUInteger const kQONRemoteConfigDefaultsMaxKeyBytes = 256; +static NSUInteger const kQONRemoteConfigDefaultsMaxUIDCodePoints = 36; +static NSUInteger const kQONRemoteConfigDefaultsMaxJSONDepth = 64; +static int64_t const kQONRemoteConfigDefaultsMaxSafeInteger = 9007199254740991LL; +static NSInteger const kQONRemoteConfigDefaultsSchemaVersion = 1; + +// This exact domain is shared by every SDK and the producer. Changing it is a +// wire-format change and requires a new artifact schema. +static const char kQONRemoteConfigDefaultsDigestDomain[] = + "qonversion.remote-config-fallback-defaults.v1"; + +typedef struct { + const uint8_t *bytes; + NSUInteger length; + NSUInteger offset; +} QONRemoteConfigJSONScanner; + +static BOOL QONRemoteConfigIsJSONWhitespace(uint8_t byte) { + return byte == ' ' || byte == '\t' || byte == '\n' || byte == '\r'; +} + +static void QONRemoteConfigSkipJSONWhitespace(QONRemoteConfigJSONScanner *scanner) { + while (scanner->offset < scanner->length && + QONRemoteConfigIsJSONWhitespace(scanner->bytes[scanner->offset])) { + scanner->offset += 1; + } +} + +static BOOL QONRemoteConfigDecodeJSONCodeUnit(const uint8_t *bytes, uint16_t *result) { + uint16_t value = 0; + for (NSUInteger index = 0; index < 4; index++) { + uint8_t byte = bytes[index]; + value <<= 4; + if (byte >= '0' && byte <= '9') { + value |= (uint16_t)(byte - '0'); + } else if (byte >= 'a' && byte <= 'f') { + value |= (uint16_t)(byte - 'a' + 10); + } else if (byte >= 'A' && byte <= 'F') { + value |= (uint16_t)(byte - 'A' + 10); + } else { + return NO; + } + } + if (result) { + *result = value; + } + return YES; +} + +static BOOL QONRemoteConfigScanJSONString(QONRemoteConfigJSONScanner *scanner, + NSRange *range) { + if (scanner->offset >= scanner->length || scanner->bytes[scanner->offset] != '"') { + return NO; + } + + NSUInteger start = scanner->offset; + scanner->offset += 1; + while (scanner->offset < scanner->length) { + uint8_t byte = scanner->bytes[scanner->offset++]; + if (byte == '"') { + if (range) { + *range = NSMakeRange(start, scanner->offset - start); + } + return YES; + } + if (byte < 0x20) { + return NO; + } + if (byte != '\\') { + continue; + } + if (scanner->offset >= scanner->length) { + return NO; + } + uint8_t escape = scanner->bytes[scanner->offset++]; + if (escape == 'u') { + if (scanner->length - scanner->offset < 4) { + return NO; + } + uint16_t codeUnit = 0; + if (!QONRemoteConfigDecodeJSONCodeUnit(scanner->bytes + scanner->offset, &codeUnit)) { + return NO; + } + scanner->offset += 4; + if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) { + if (scanner->length - scanner->offset < 6 || + scanner->bytes[scanner->offset] != '\\' || + scanner->bytes[scanner->offset + 1] != 'u') { + return NO; + } + uint16_t lowSurrogate = 0; + if (!QONRemoteConfigDecodeJSONCodeUnit(scanner->bytes + scanner->offset + 2, + &lowSurrogate) || + lowSurrogate < 0xDC00 || lowSurrogate > 0xDFFF) { + return NO; + } + scanner->offset += 6; + } else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) { + return NO; + } + } else if (!(escape == '"' || escape == '\\' || escape == '/' || + escape == 'b' || escape == 'f' || escape == 'n' || + escape == 'r' || escape == 't')) { + return NO; + } + } + return NO; +} + +static BOOL QONRemoteConfigScanJSONValue(QONRemoteConfigJSONScanner *scanner, + NSUInteger depth); + +static NSString *QONRemoteConfigDecodedJSONString(QONRemoteConfigJSONScanner *scanner, + NSRange range) { + NSData *data = [NSData dataWithBytes:scanner->bytes + range.location length:range.length]; + id value = [NSJSONSerialization JSONObjectWithData:data + options:NSJSONReadingFragmentsAllowed + error:nil]; + return [value isKindOfClass:NSString.class] ? value : nil; +} + +static BOOL QONRemoteConfigScanJSONObject(QONRemoteConfigJSONScanner *scanner, + NSUInteger depth) { + if (depth >= kQONRemoteConfigDefaultsMaxJSONDepth) { + return NO; + } + scanner->offset += 1; + QONRemoteConfigSkipJSONWhitespace(scanner); + if (scanner->offset < scanner->length && scanner->bytes[scanner->offset] == '}') { + scanner->offset += 1; + return YES; + } + + NSMutableSet *keys = [NSMutableSet new]; + while (scanner->offset < scanner->length) { + NSRange keyRange = NSMakeRange(NSNotFound, 0); + if (!QONRemoteConfigScanJSONString(scanner, &keyRange)) { + return NO; + } + NSString *key = QONRemoteConfigDecodedJSONString(scanner, keyRange); + if (!key || [keys containsObject:key]) { + return NO; + } + [keys addObject:key]; + + QONRemoteConfigSkipJSONWhitespace(scanner); + if (scanner->offset >= scanner->length || scanner->bytes[scanner->offset++] != ':') { + return NO; + } + if (!QONRemoteConfigScanJSONValue(scanner, depth + 1)) { + return NO; + } + QONRemoteConfigSkipJSONWhitespace(scanner); + if (scanner->offset >= scanner->length) { + return NO; + } + uint8_t delimiter = scanner->bytes[scanner->offset++]; + if (delimiter == '}') { + return YES; + } + if (delimiter != ',') { + return NO; + } + QONRemoteConfigSkipJSONWhitespace(scanner); + } + return NO; +} + +static BOOL QONRemoteConfigScanJSONArray(QONRemoteConfigJSONScanner *scanner, + NSUInteger depth) { + if (depth >= kQONRemoteConfigDefaultsMaxJSONDepth) { + return NO; + } + scanner->offset += 1; + QONRemoteConfigSkipJSONWhitespace(scanner); + if (scanner->offset < scanner->length && scanner->bytes[scanner->offset] == ']') { + scanner->offset += 1; + return YES; + } + + while (scanner->offset < scanner->length) { + if (!QONRemoteConfigScanJSONValue(scanner, depth + 1)) { + return NO; + } + QONRemoteConfigSkipJSONWhitespace(scanner); + if (scanner->offset >= scanner->length) { + return NO; + } + uint8_t delimiter = scanner->bytes[scanner->offset++]; + if (delimiter == ']') { + return YES; + } + if (delimiter != ',') { + return NO; + } + QONRemoteConfigSkipJSONWhitespace(scanner); + } + return NO; +} + +static BOOL QONRemoteConfigScanJSONNumber(QONRemoteConfigJSONScanner *scanner) { + NSUInteger offset = scanner->offset; + if (offset < scanner->length && scanner->bytes[offset] == '-') { + offset += 1; + } + NSUInteger integerStart = offset; + if (offset >= scanner->length) { + return NO; + } + if (scanner->bytes[offset] == '0') { + offset += 1; + } else if (scanner->bytes[offset] >= '1' && scanner->bytes[offset] <= '9') { + do { + offset += 1; + } while (offset < scanner->length && + scanner->bytes[offset] >= '0' && scanner->bytes[offset] <= '9'); + } else { + return NO; + } + BOOL hasFractionOrExponent = NO; + if (offset < scanner->length && scanner->bytes[offset] == '.') { + hasFractionOrExponent = YES; + offset += 1; + NSUInteger fractionStart = offset; + while (offset < scanner->length && + scanner->bytes[offset] >= '0' && scanner->bytes[offset] <= '9') { + offset += 1; + } + if (offset == fractionStart) { + return NO; + } + } + if (offset < scanner->length && + (scanner->bytes[offset] == 'e' || scanner->bytes[offset] == 'E')) { + hasFractionOrExponent = YES; + offset += 1; + if (offset < scanner->length && + (scanner->bytes[offset] == '+' || scanner->bytes[offset] == '-')) { + offset += 1; + } + NSUInteger exponentStart = offset; + while (offset < scanner->length && + scanner->bytes[offset] >= '0' && scanner->bytes[offset] <= '9') { + offset += 1; + } + if (offset == exponentStart) { + return NO; + } + } + if (!hasFractionOrExponent) { + static const char kMaxSafeInteger[] = "9007199254740991"; + NSUInteger digitCount = offset - integerStart; + NSUInteger maxDigitCount = strlen(kMaxSafeInteger); + if (digitCount > maxDigitCount || + (digitCount == maxDigitCount && + memcmp(scanner->bytes + integerStart, kMaxSafeInteger, maxDigitCount) > 0)) { + return NO; + } + } + scanner->offset = offset; + return YES; +} + +static BOOL QONRemoteConfigScanJSONLiteral(QONRemoteConfigJSONScanner *scanner, + const char *literal) { + NSUInteger length = strlen(literal); + if (scanner->length - scanner->offset < length || + memcmp(scanner->bytes + scanner->offset, literal, length) != 0) { + return NO; + } + scanner->offset += length; + return YES; +} + +static BOOL QONRemoteConfigScanJSONValue(QONRemoteConfigJSONScanner *scanner, + NSUInteger depth) { + QONRemoteConfigSkipJSONWhitespace(scanner); + if (scanner->offset >= scanner->length) { + return NO; + } + switch (scanner->bytes[scanner->offset]) { + case '{': + return QONRemoteConfigScanJSONObject(scanner, depth); + case '[': + return QONRemoteConfigScanJSONArray(scanner, depth); + case '"': + return QONRemoteConfigScanJSONString(scanner, NULL); + case 't': + return QONRemoteConfigScanJSONLiteral(scanner, "true"); + case 'f': + return QONRemoteConfigScanJSONLiteral(scanner, "false"); + case 'n': + return QONRemoteConfigScanJSONLiteral(scanner, "null"); + default: + return QONRemoteConfigScanJSONNumber(scanner); + } +} + +static BOOL QONRemoteConfigIsUnambiguousJSONData(NSData *data, NSUInteger maxBytes) { + if (data.length == 0 || data.length > maxBytes || + ![[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]) { + return NO; + } + + QONRemoteConfigJSONScanner scanner = { + .bytes = data.bytes, + .length = data.length, + .offset = 0, + }; + if (!QONRemoteConfigScanJSONValue(&scanner, 0)) { + return NO; + } + QONRemoteConfigSkipJSONWhitespace(&scanner); + return scanner.offset == scanner.length; +} + +static id QONRemoteConfigValidatedJSONObject(NSData *data) { + if (!QONRemoteConfigIsUnambiguousJSONData(data, kQONRemoteConfigDefaultsMaxValueBytes)) { + return nil; + } + + return [NSJSONSerialization JSONObjectWithData:data + options:NSJSONReadingFragmentsAllowed + error:nil]; +} + +static BOOL QONRemoteConfigIsIntegralNumber(id value) { + return [value isKindOfClass:NSNumber.class] && + CFGetTypeID((__bridge CFTypeRef)value) != CFBooleanGetTypeID() && + !CFNumberIsFloatType((__bridge CFNumberRef)value); +} + +static BOOL QONRemoteConfigIsPositiveInt64(id value, int64_t *result) { + if (!QONRemoteConfigIsIntegralNumber(value)) { + return NO; + } + NSNumber *number = value; + int64_t candidate = number.longLongValue; + if (candidate <= 0 || candidate > kQONRemoteConfigDefaultsMaxSafeInteger || + ![number isEqualToNumber:@(candidate)]) { + return NO; + } + if (result) { + *result = candidate; + } + return YES; +} + +static NSUInteger QONRemoteConfigUTF8CodePointCount(NSData *data) { + NSUInteger count = 0; + const uint8_t *bytes = data.bytes; + for (NSUInteger index = 0; index < data.length; index++) { + if ((bytes[index] & 0xC0) != 0x80) { + count += 1; + } + } + return count; +} + +static NSData *QONRemoteConfigValidatedUIDData(id value) { + if (![value isKindOfClass:NSString.class]) { + return nil; + } + NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding]; + NSUInteger count = QONRemoteConfigUTF8CodePointCount(data); + return data.length > 0 && count <= kQONRemoteConfigDefaultsMaxUIDCodePoints ? data : nil; +} + +static NSData *QONRemoteConfigValidatedKeyData(id value) { + if (![value isKindOfClass:NSString.class]) { + return nil; + } + NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding]; + return data.length > 0 && data.length <= kQONRemoteConfigDefaultsMaxKeyBytes ? data : nil; +} + +static NSComparisonResult QONRemoteConfigCompareBytes(NSData *left, NSData *right) { + NSUInteger commonLength = MIN(left.length, right.length); + int comparison = memcmp(left.bytes, right.bytes, commonLength); + if (comparison < 0) return NSOrderedAscending; + if (comparison > 0) return NSOrderedDescending; + if (left.length < right.length) return NSOrderedAscending; + if (left.length > right.length) return NSOrderedDescending; + return NSOrderedSame; +} + +static NSData *QONRemoteConfigDecodeLowercaseSHA256Hex(id value) { + if (![value isKindOfClass:NSString.class]) { + return nil; + } + NSString *string = value; + if (string.length != CC_SHA256_DIGEST_LENGTH * 2 || + ![string isEqualToString:string.lowercaseString]) { + return nil; + } + NSMutableData *data = [NSMutableData dataWithLength:CC_SHA256_DIGEST_LENGTH]; + uint8_t *bytes = data.mutableBytes; + for (NSUInteger index = 0; index < CC_SHA256_DIGEST_LENGTH; index++) { + unichar high = [string characterAtIndex:index * 2]; + unichar low = [string characterAtIndex:index * 2 + 1]; + int highValue = high >= '0' && high <= '9' ? high - '0' : + (high >= 'a' && high <= 'f' ? high - 'a' + 10 : -1); + int lowValue = low >= '0' && low <= '9' ? low - '0' : + (low >= 'a' && low <= 'f' ? low - 'a' + 10 : -1); + if (highValue < 0 || lowValue < 0) { + return nil; + } + bytes[index] = (uint8_t)((highValue << 4) | lowValue); + } + return data; +} + +static void QONRemoteConfigAppendASCII(NSMutableData *data, NSString *string) { + NSData *bytes = [string dataUsingEncoding:NSASCIIStringEncoding]; + [data appendData:bytes]; +} + +static void QONRemoteConfigAppendJSONString(NSMutableData *data, NSString *string) { + QONRemoteConfigAppendASCII(data, @"\""); + NSData *utf8 = [string dataUsingEncoding:NSUTF8StringEncoding]; + const uint8_t *bytes = utf8.bytes; + NSUInteger scalarStart = 0; + for (NSUInteger index = 0; index < utf8.length;) { + uint8_t byte = bytes[index]; + NSUInteger scalarLength = byte < 0x80 ? 1 : + ((byte & 0xE0) == 0xC0 ? 2 : ((byte & 0xF0) == 0xE0 ? 3 : 4)); + NSString *escape = nil; + if (scalarLength == 1) { + switch (byte) { + case '"': escape = @"\\\""; break; + case '\\': escape = @"\\\\"; break; + case '\b': escape = @"\\b"; break; + case '\f': escape = @"\\f"; break; + case '\n': escape = @"\\n"; break; + case '\r': escape = @"\\r"; break; + case '\t': escape = @"\\t"; break; + case '<': escape = @"\\u003c"; break; + case '>': escape = @"\\u003e"; break; + case '&': escape = @"\\u0026"; break; + default: + if (byte < 0x20) { + escape = [NSString stringWithFormat:@"\\u%04x", byte]; + } + break; + } + } else if (scalarLength == 3 && index + 2 < utf8.length && + ((bytes[index] == 0xE2 && bytes[index + 1] == 0x80 && bytes[index + 2] == 0xA8) || + (bytes[index] == 0xE2 && bytes[index + 1] == 0x80 && bytes[index + 2] == 0xA9))) { + escape = bytes[index + 2] == 0xA8 ? @"\\u2028" : @"\\u2029"; + } + if (escape) { + if (index > scalarStart) { + [data appendBytes:bytes + scalarStart length:index - scalarStart]; + } + QONRemoteConfigAppendASCII(data, escape); + scalarStart = index + scalarLength; + } + index += scalarLength; + } + if (scalarStart < utf8.length) { + [data appendBytes:bytes + scalarStart length:utf8.length - scalarStart]; + } + QONRemoteConfigAppendASCII(data, @"\""); +} + +static NSData *QONRemoteConfigCanonicalArtifactData(NSDictionary *root, + NSArray *defaults, + int64_t projectID, + int64_t releaseNumber) { + NSMutableData *data = [NSMutableData new]; + QONRemoteConfigAppendASCII(data, @"{\"schemaVersion\":1,\"projectId\":"); + QONRemoteConfigAppendASCII(data, [NSString stringWithFormat:@"%lld", (long long)projectID]); + QONRemoteConfigAppendASCII(data, @",\"environmentUid\":"); + QONRemoteConfigAppendJSONString(data, root[@"environmentUid"]); + QONRemoteConfigAppendASCII(data, @",\"releaseUid\":"); + QONRemoteConfigAppendJSONString(data, root[@"releaseUid"]); + QONRemoteConfigAppendASCII(data, @",\"releaseNumber\":"); + QONRemoteConfigAppendASCII(data, [NSString stringWithFormat:@"%lld", (long long)releaseNumber]); + QONRemoteConfigAppendASCII(data, @",\"manifestContentHash\":"); + QONRemoteConfigAppendJSONString(data, root[@"manifestContentHash"]); + QONRemoteConfigAppendASCII(data, @",\"defaultsDigest\":"); + QONRemoteConfigAppendJSONString(data, root[@"defaultsDigest"]); + QONRemoteConfigAppendASCII(data, @",\"defaults\":["); + for (NSUInteger index = 0; index < defaults.count; index++) { + if (index > 0) { + QONRemoteConfigAppendASCII(data, @","); + } + NSDictionary *entry = defaults[index]; + QONRemoteConfigAppendASCII(data, @"{\"key\":"); + QONRemoteConfigAppendJSONString(data, entry[@"key"]); + QONRemoteConfigAppendASCII(data, @",\"variationUid\":"); + QONRemoteConfigAppendJSONString(data, entry[@"variationUid"]); + QONRemoteConfigAppendASCII(data, @",\"valueBase64\":"); + QONRemoteConfigAppendJSONString(data, entry[@"valueBase64"]); + QONRemoteConfigAppendASCII(data, @"}"); + } + QONRemoteConfigAppendASCII(data, @"]}"); + return data; +} + +static void QONRemoteConfigDigestPart(CC_SHA256_CTX *context, NSData *part) { + uint64_t length = CFSwapInt64HostToBig((uint64_t)part.length); + CC_SHA256_Update(context, &length, (CC_LONG)sizeof(length)); + if (part.length > 0) { + CC_SHA256_Update(context, part.bytes, (CC_LONG)part.length); + } +} + +static NSData *QONRemoteConfigASCIIData(NSString *string) { + return [string dataUsingEncoding:NSASCIIStringEncoding]; +} + +static NSString *QONRemoteConfigDefaultsDigest(int64_t projectID, + NSData *environmentUID, + NSData *releaseUID, + int64_t releaseNumber, + NSData *manifestContentHash, + NSArray *defaults, + NSArray *keys, + NSArray *variationUIDs, + NSArray *rawValues) { + CC_SHA256_CTX context; + CC_SHA256_Init(&context); + QONRemoteConfigDigestPart(&context, + [NSData dataWithBytes:kQONRemoteConfigDefaultsDigestDomain + length:strlen(kQONRemoteConfigDefaultsDigestDomain)]); + QONRemoteConfigDigestPart(&context, QONRemoteConfigASCIIData(@"1")); + QONRemoteConfigDigestPart(&context, + QONRemoteConfigASCIIData([NSString stringWithFormat:@"%lld", (long long)projectID])); + QONRemoteConfigDigestPart(&context, environmentUID); + QONRemoteConfigDigestPart(&context, releaseUID); + QONRemoteConfigDigestPart(&context, + QONRemoteConfigASCIIData([NSString stringWithFormat:@"%lld", (long long)releaseNumber])); + QONRemoteConfigDigestPart(&context, manifestContentHash); + QONRemoteConfigDigestPart(&context, + QONRemoteConfigASCIIData([NSString stringWithFormat:@"%lu", (unsigned long)defaults.count])); + for (NSUInteger index = 0; index < defaults.count; index++) { + QONRemoteConfigDigestPart(&context, keys[index]); + QONRemoteConfigDigestPart(&context, variationUIDs[index]); + QONRemoteConfigDigestPart(&context, rawValues[index]); + } + uint8_t digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256_Final(digest, &context); + NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (NSUInteger index = 0; index < CC_SHA256_DIGEST_LENGTH; index++) { + [hex appendFormat:@"%02x", digest[index]]; + } + return hex; +} + +@interface QONRemoteConfigFallbackStore () + +@property (nonatomic, strong) NSBundle *bundle; +@property (nonatomic, copy, nullable) NSDictionary *values; +@property (nonatomic, assign) BOOL didLoad; + +@end + +@implementation QONRemoteConfigFallbackStore + +- (instancetype)initWithBundle:(NSBundle *)bundle { + self = [super init]; + if (self) { + _bundle = bundle; + } + return self; +} + +- (id)valueForContextKey:(NSString *)contextKey { + if (!contextKey) { + return nil; + } + @synchronized (self) { + if (!self.didLoad) { + self.values = [self loadValidatedValues]; + self.didLoad = YES; + } + return self.values[contextKey]; + } +} + +- (NSDictionary *)loadValidatedValues { + NSURL *url = [self.bundle URLForResource:kQONRemoteConfigDefaultsResourceName + withExtension:kQONRemoteConfigDefaultsResourceExtension]; + if (!url) { + return nil; + } + + NSNumber *fileSize = nil; + if (![url getResourceValue:&fileSize forKey:NSURLFileSizeKey error:nil] || + fileSize.unsignedLongLongValue == 0 || + fileSize.unsignedLongLongValue > kQONRemoteConfigDefaultsMaxArtifactBytes) { + return nil; + } + NSData *encoded = [NSData dataWithContentsOfURL:url options:NSDataReadingMappedIfSafe error:nil]; + if (encoded.length == 0 || encoded.length > kQONRemoteConfigDefaultsMaxArtifactBytes || + !QONRemoteConfigIsUnambiguousJSONData(encoded, kQONRemoteConfigDefaultsMaxArtifactBytes)) { + return nil; + } + + id rootObject = [NSJSONSerialization JSONObjectWithData:encoded options:0 error:nil]; + if (![rootObject isKindOfClass:NSDictionary.class]) { + return nil; + } + NSDictionary *root = rootObject; + + if (!QONRemoteConfigIsIntegralNumber(root[@"schemaVersion"]) || + [root[@"schemaVersion"] longLongValue] != kQONRemoteConfigDefaultsSchemaVersion) { + return nil; + } + int64_t projectID = 0; + int64_t releaseNumber = 0; + if (!QONRemoteConfigIsPositiveInt64(root[@"projectId"], &projectID) || + !QONRemoteConfigIsPositiveInt64(root[@"releaseNumber"], &releaseNumber)) { + return nil; + } + NSData *environmentUID = QONRemoteConfigValidatedUIDData(root[@"environmentUid"]); + NSData *releaseUID = QONRemoteConfigValidatedUIDData(root[@"releaseUid"]); + NSData *decodedManifestContentHash = QONRemoteConfigDecodeLowercaseSHA256Hex(root[@"manifestContentHash"]); + NSData *manifestContentHash = [root[@"manifestContentHash"] dataUsingEncoding:NSASCIIStringEncoding]; + if (!environmentUID || !releaseUID || !decodedManifestContentHash || + !QONRemoteConfigDecodeLowercaseSHA256Hex(root[@"defaultsDigest"])) { + return nil; + } + + id defaultsObject = root[@"defaults"]; + if (![defaultsObject isKindOfClass:NSArray.class] || + [defaultsObject count] > kQONRemoteConfigDefaultsMaxCount) { + return nil; + } + NSArray *defaults = defaultsObject; + NSMutableArray *keys = [NSMutableArray arrayWithCapacity:defaults.count]; + NSMutableArray *variationUIDs = [NSMutableArray arrayWithCapacity:defaults.count]; + NSMutableArray *rawValues = [NSMutableArray arrayWithCapacity:defaults.count]; + NSMutableDictionary *values = [NSMutableDictionary dictionaryWithCapacity:defaults.count]; + NSData *previousKey = nil; + + for (id entryObject in defaults) { + if (![entryObject isKindOfClass:NSDictionary.class]) { + return nil; + } + NSDictionary *entry = entryObject; + NSData *key = QONRemoteConfigValidatedKeyData(entry[@"key"]); + NSData *variationUID = QONRemoteConfigValidatedUIDData(entry[@"variationUid"]); + id base64Object = entry[@"valueBase64"]; + if (!key || !variationUID || ![base64Object isKindOfClass:NSString.class] || + (previousKey && QONRemoteConfigCompareBytes(previousKey, key) != NSOrderedAscending)) { + return nil; + } + NSString *base64 = base64Object; + NSData *rawValue = [[NSData alloc] initWithBase64EncodedString:base64 options:0]; + if (!rawValue || rawValue.length == 0 || rawValue.length > kQONRemoteConfigDefaultsMaxValueBytes || + ![[rawValue base64EncodedStringWithOptions:0] isEqualToString:base64]) { + return nil; + } + id value = QONRemoteConfigValidatedJSONObject(rawValue); + if (!value) { + return nil; + } + + [keys addObject:key]; + [variationUIDs addObject:variationUID]; + [rawValues addObject:rawValue]; + values[entry[@"key"]] = value; + previousKey = key; + } + + NSString *digest = QONRemoteConfigDefaultsDigest(projectID, environmentUID, releaseUID, + releaseNumber, manifestContentHash, defaults, keys, variationUIDs, rawValues); + if (![digest isEqualToString:root[@"defaultsDigest"]]) { + return nil; + } + + NSData *canonical = QONRemoteConfigCanonicalArtifactData(root, defaults, projectID, releaseNumber); + if (![canonical isEqualToData:encoded]) { + return nil; + } + return [values copy]; +} + +@end