diff --git a/Source/common/BUILD b/Source/common/BUILD index b10b9fc2a..b6666cfdb 100644 --- a/Source/common/BUILD +++ b/Source/common/BUILD @@ -514,6 +514,7 @@ objc_library( ":Pinning", ":SNTCELFallbackRule", ":SNTCommonEnums", + ":SNTConfigBundle", ":SNTExportConfiguration", ":SNTLiteDetector", ":SNTLogging", @@ -1094,6 +1095,7 @@ santa_unit_test( srcs = ["SNTConfiguratorTest.mm"], deps = [ ":SNTCommonEnums", + ":SNTConfigBundle", ":SNTConfigurator", "@OCMock", ], diff --git a/Source/common/SNTConfigBundle.h b/Source/common/SNTConfigBundle.h index c281cb1be..111608c94 100644 --- a/Source/common/SNTConfigBundle.h +++ b/Source/common/SNTConfigBundle.h @@ -55,5 +55,6 @@ - (void)celFallbackRules:(void (^)(NSArray*))block; - (void)fullSyncInterval:(void (^)(NSUInteger))block; - (void)pushNotificationsFullSyncInterval:(void (^)(NSUInteger))block; +- (void)clearSyncStateBeforeApply:(void (^)(BOOL))block; @end diff --git a/Source/common/SNTConfigBundle.mm b/Source/common/SNTConfigBundle.mm index 8e0b9a8c3..774b86a3f 100644 --- a/Source/common/SNTConfigBundle.mm +++ b/Source/common/SNTConfigBundle.mm @@ -52,6 +52,7 @@ @interface SNTConfigBundle () @property NSArray* celFallbackRules; @property NSNumber* fullSyncInterval; @property NSNumber* pushNotificationsFullSyncInterval; +@property NSNumber* clearSyncStateBeforeApply; @end @implementation SNTConfigBundle @@ -92,6 +93,7 @@ - (void)encodeWithCoder:(NSCoder*)coder { ENCODE(coder, celFallbackRules); ENCODE(coder, fullSyncInterval); ENCODE(coder, pushNotificationsFullSyncInterval); + ENCODE(coder, clearSyncStateBeforeApply); } - (instancetype)initWithCoder:(NSCoder*)decoder { @@ -128,6 +130,7 @@ - (instancetype)initWithCoder:(NSCoder*)decoder { DECODE_ARRAY(decoder, celFallbackRules, SNTCELFallbackRule); DECODE(decoder, fullSyncInterval, NSNumber); DECODE(decoder, pushNotificationsFullSyncInterval, NSNumber); + DECODE(decoder, clearSyncStateBeforeApply, NSNumber); } return self; } @@ -317,4 +320,10 @@ - (void)pushNotificationsFullSyncInterval:(void (^)(NSUInteger))block { } } +- (void)clearSyncStateBeforeApply:(void (^)(BOOL))block { + if (self.clearSyncStateBeforeApply) { + block([self.clearSyncStateBeforeApply boolValue]); + } +} + @end diff --git a/Source/common/SNTConfigBundleTest.mm b/Source/common/SNTConfigBundleTest.mm index a217bb9cd..35a7c9a7d 100644 --- a/Source/common/SNTConfigBundleTest.mm +++ b/Source/common/SNTConfigBundleTest.mm @@ -53,6 +53,7 @@ @interface SNTConfigBundle (Testing) @property NSArray* celFallbackRules; @property NSNumber* fullSyncInterval; @property NSNumber* pushNotificationsFullSyncInterval; +@property NSNumber* clearSyncStateBeforeApply; @end @interface SNTConfigBundleTest : XCTestCase @@ -62,7 +63,7 @@ @implementation SNTConfigBundleTest - (void)testGettersWithValues { __block XCTestExpectation* exp = [self expectationWithDescription:@"Result Blocks"]; - exp.expectedFulfillmentCount = 29; + exp.expectedFulfillmentCount = 30; NSDate* nowDate = [NSDate now]; SNTConfigBundle* bundle = [[SNTConfigBundle alloc] init]; @@ -97,6 +98,7 @@ - (void)testGettersWithValues { bundle.pushTokenChain = @[ @"issuerJWT", @"userJWT" ]; bundle.fullSyncInterval = @(600); bundle.pushNotificationsFullSyncInterval = @(21600); + bundle.clearSyncStateBeforeApply = @(YES); [bundle clientMode:^(SNTClientMode val) { XCTAssertEqual(val, SNTClientModeLockdown); @@ -248,6 +250,11 @@ - (void)testGettersWithValues { [exp fulfill]; }]; + [bundle clearSyncStateBeforeApply:^(BOOL val) { + XCTAssertNotEqual(val, NO); + [exp fulfill]; + }]; + // Low timeout because code above is synchronous [self waitForExpectationsWithTimeout:0.1 handler:NULL]; } @@ -370,6 +377,52 @@ - (void)testGettersWithoutValues { [bundle pushNotificationsFullSyncInterval:^(NSUInteger val) { XCTFail(@"This shouldn't be called"); }]; + + [bundle clearSyncStateBeforeApply:^(BOOL val) { + XCTFail(@"This shouldn't be called"); + }]; +} + +- (void)testClearSyncStateBeforeApplyEncodesAndDecodes { + // When set to YES, the round-trip preserves the value and the accessor block fires. + SNTConfigBundle* set = [[SNTConfigBundle alloc] init]; + set.clearSyncStateBeforeApply = @(YES); + + NSError* error = nil; + NSData* setData = [NSKeyedArchiver archivedDataWithRootObject:set + requiringSecureCoding:YES + error:&error]; + XCTAssertNil(error); + SNTConfigBundle* setDecoded = [NSKeyedUnarchiver unarchivedObjectOfClass:[SNTConfigBundle class] + fromData:setData + error:&error]; + XCTAssertNil(error); + + __block BOOL setFired = NO; + __block BOOL setValue = NO; + [setDecoded clearSyncStateBeforeApply:^(BOOL v) { + setFired = YES; + setValue = v; + }]; + XCTAssertTrue(setFired); + XCTAssertEqual(setValue, YES); + + // When left unset, the round-trip preserves the unset state and the block does not fire. + SNTConfigBundle* unset = [[SNTConfigBundle alloc] init]; + NSData* unsetData = [NSKeyedArchiver archivedDataWithRootObject:unset + requiringSecureCoding:YES + error:&error]; + XCTAssertNil(error); + SNTConfigBundle* unsetDecoded = [NSKeyedUnarchiver unarchivedObjectOfClass:[SNTConfigBundle class] + fromData:unsetData + error:&error]; + XCTAssertNil(error); + + __block BOOL unsetFired = NO; + [unsetDecoded clearSyncStateBeforeApply:^(BOOL v) { + unsetFired = YES; + }]; + XCTAssertFalse(unsetFired); } @end diff --git a/Source/common/SNTConfigurator.h b/Source/common/SNTConfigurator.h index 77f2c69b2..21edc22d4 100644 --- a/Source/common/SNTConfigurator.h +++ b/Source/common/SNTConfigurator.h @@ -16,6 +16,7 @@ #import #import "Source/common/SNTCommonEnums.h" +#import "Source/common/SNTConfigBundle.h" @class SNTCELFallbackRule; @class SNTExportConfiguration; @@ -1148,10 +1149,25 @@ extern NSString* _Nonnull const kEnableMenuItemUserOverride; #endif /// -/// Clear the sync server configuration from the effective configuration. +/// Clear the persisted sync-managed state. The in-memory dictionary is +/// reset and `sync-state.plist` is removed from disk. Called by +/// `SNTSyncdQueue` after `SyncBaseURL` has been removed for 10 minutes, +/// to forget settings from the previous server. /// - (void)clearSyncState; +/// +/// Atomically replace the persisted sync-managed state with the bundle's contents. +/// Walks the bundle once, builds a fresh in-memory dictionary (with inline +/// validation/translation matching the existing per-key setters), then performs +/// a single in-memory swap and a single disk write. Used by the daemon's +/// `updateSyncSettings:` clean-sync branch (when `bundle.clearSyncStateBeforeApply` +/// is YES). Slots intentionally not handled: `clearSyncStateBeforeApply` itself +/// (consumed by the daemon's branching), and `modeTransition` (owned by +/// `_temporaryMonitorMode` for combined persistence + side effects). +/// +- (void)atomicallyApplyBundle:(nonnull SNTConfigBundle*)bundle; + /// /// Validate the configuration profile. /// diff --git a/Source/common/SNTConfigurator.mm b/Source/common/SNTConfigurator.mm index 11fe8bb06..3185ae2c3 100644 --- a/Source/common/SNTConfigurator.mm +++ b/Source/common/SNTConfigurator.mm @@ -2060,10 +2060,133 @@ - (void)saveSyncStateToDisk { } - (void)clearSyncState { - self.syncState = [NSMutableDictionary dictionary]; - // TODO: Start a timer to flush the state to disk. On startup, Santa should - // check for the presence of the state file and, if no SyncBaseURL is - // configured, start the timer to clear sync state and flush to disk. + void (^block)(void) = ^{ + self.syncState = [NSMutableDictionary dictionary]; + [[NSFileManager defaultManager] removeItemAtPath:self.syncStateFilePath error:NULL]; + }; + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_sync(dispatch_get_main_queue(), block); + } +} + +- (void)atomicallyApplyBundle:(SNTConfigBundle*)bundle { + NSMutableDictionary* newSyncState = [NSMutableDictionary dictionary]; + + [bundle clientMode:^(SNTClientMode m) { + if (m == SNTClientModeMonitor || m == SNTClientModeLockdown || m == SNTClientModeStandalone) { + newSyncState[kClientModeKey] = @(m); + } + }]; + [bundle syncType:^(SNTSyncType v) { + newSyncState[kSyncTypeRequired] = @(v); + }]; + [bundle allowlistRegex:^(NSString* pattern) { + NSRegularExpression* re = [NSRegularExpression regularExpressionWithPattern:pattern + options:0 + error:NULL]; + if (re) { + newSyncState[kAllowedPathRegexKey] = re; + } + }]; + [bundle blocklistRegex:^(NSString* pattern) { + NSRegularExpression* re = [NSRegularExpression regularExpressionWithPattern:pattern + options:0 + error:NULL]; + if (re) { + newSyncState[kBlockedPathRegexKey] = re; + } + }]; + [bundle removableMediaAction:^(NSString* v) { + newSyncState[kRemovableMediaActionKey] = v; + }]; + [bundle removableMediaRemountFlags:^(NSArray* v) { + newSyncState[kRemovableMediaRemountFlagsKey] = v; + }]; + [bundle encryptedRemovableMediaAction:^(NSString* v) { + newSyncState[kEncryptedRemovableMediaActionKey] = v; + }]; + [bundle encryptedRemovableMediaRemountFlags:^(NSArray* v) { + newSyncState[kEncryptedRemovableMediaRemountFlagsKey] = v; + }]; + [bundle blockNetworkMount:^(BOOL v) { + newSyncState[kBlockNetworkMountKey] = @(v); + }]; + [bundle bannedNetworkMountBlockMessage:^(NSString* v) { + newSyncState[kBannedNetworkMountBlockMessage] = v; + }]; + [bundle allowedNetworkMountHosts:^(NSArray* v) { + newSyncState[kAllowedNetworkMountHosts] = v; + }]; + [bundle enableBundles:^(BOOL v) { + newSyncState[kEnableBundlesKey] = @(v); + }]; + [bundle enableTransitiveRules:^(BOOL v) { + newSyncState[kEnableTransitiveRulesKey] = @(v); + }]; + [bundle enableAllEventUpload:^(BOOL v) { + newSyncState[kEnableAllEventUploadKey] = @(v); + }]; + [bundle disableUnknownEventUpload:^(BOOL v) { + newSyncState[kDisableUnknownEventUploadKey] = @(v); + }]; + [bundle overrideFileAccessAction:^(NSString* v) { + NSString* lower = [v lowercaseString]; + if ([lower isEqualToString:@"auditonly"] || [lower isEqualToString:@"disable"] || + [lower isEqualToString:@"none"] || [lower isEqualToString:@""]) { + newSyncState[kOverrideFileAccessActionKey] = v; + } + }]; + [bundle exportConfiguration:^(SNTExportConfiguration* v) { + newSyncState[kExportConfigurationKey] = [v serialize]; + }]; + [bundle fullSyncLastSuccess:^(NSDate* v) { + newSyncState[kFullSyncLastSuccess] = v; + }]; + [bundle ruleSyncLastSuccess:^(NSDate* v) { + newSyncState[kRuleSyncLastSuccess] = v; + }]; + [bundle eventDetailURL:^(NSString* v) { + newSyncState[kEventDetailURLKey] = v; + }]; + [bundle eventDetailText:^(NSString* v) { + newSyncState[kEventDetailTextKey] = v; + }]; + [bundle fileAccessEventDetailURL:^(NSString* v) { + newSyncState[kFileAccessEventDetailURLKey] = v; + }]; + [bundle fileAccessEventDetailText:^(NSString* v) { + newSyncState[kFileAccessEventDetailTextKey] = v; + }]; + [bundle networkExtensionSettings:^(SNTSyncNetworkExtensionSettings* v) { + newSyncState[kNetworkExtensionSettingsKey] = [v serialize]; + }]; + [bundle pushTokenChain:^(NSArray* v) { + newSyncState[kPushTokenChainKey] = EnsureArrayOfStrings(v); + }]; + [bundle telemetryFilterExpressions:^(NSArray* v) { + newSyncState[kTelemetryFilterExpressionsKey] = EnsureArrayOfStrings(v); + }]; + [bundle celFallbackRules:^(NSArray* v) { + newSyncState[kCELFallbackRulesKey] = [SNTCELFallbackRule serializeArray:v]; + }]; + [bundle fullSyncInterval:^(NSUInteger v) { + newSyncState[kFullSyncInterval] = v ? @(v) : nil; + }]; + [bundle pushNotificationsFullSyncInterval:^(NSUInteger v) { + newSyncState[kFCMFullSyncInterval] = v ? @(v) : nil; + }]; + + void (^commit)(void) = ^{ + self.syncState = newSyncState; + [self saveSyncStateToDisk]; + }; + if ([NSThread isMainThread]) { + commit(); + } else { + dispatch_sync(dispatch_get_main_queue(), commit); + } } - (NSArray*)entitlementsPrefixFilter { diff --git a/Source/common/SNTConfiguratorTest.mm b/Source/common/SNTConfiguratorTest.mm index a95d63778..8ead25579 100644 --- a/Source/common/SNTConfiguratorTest.mm +++ b/Source/common/SNTConfiguratorTest.mm @@ -17,6 +17,7 @@ #import #import "Source/common/SNTCommonEnums.h" +#import "Source/common/SNTConfigBundle.h" #import "Source/common/SNTConfigurator.h" typedef BOOL (^StateFileAccessAuthorizer)(void); @@ -32,6 +33,38 @@ - (instancetype)initWithSyncStateFile:(NSString*)syncStateFilePath @property NSMutableDictionary* syncState; @end +// Expose SNTConfigBundle's writable properties for test setup. These properties +// are declared as a class extension in SNTConfigBundle.mm; redeclaring them as +// a category here gives the test typed setter access. +@interface SNTConfigBundle (ConfigBundleCreator) +@property NSNumber* clientMode; +@property NSNumber* syncType; +@property NSString* allowlistRegex; +@property NSString* blocklistRegex; +@property NSString* removableMediaAction; +@property NSArray* removableMediaRemountFlags; +@property NSString* encryptedRemovableMediaAction; +@property NSArray* encryptedRemovableMediaRemountFlags; +@property NSNumber* blockNetworkMount; +@property NSString* bannedNetworkMountBlockMessage; +@property NSArray* allowedNetworkMountHosts; +@property NSNumber* enableBundles; +@property NSNumber* enableTransitiveRules; +@property NSNumber* enableAllEventUpload; +@property NSNumber* disableUnknownEventUpload; +@property NSString* overrideFileAccessAction; +@property NSDate* fullSyncLastSuccess; +@property NSDate* ruleSyncLastSuccess; +@property NSString* eventDetailURL; +@property NSString* eventDetailText; +@property NSString* fileAccessEventDetailURL; +@property NSString* fileAccessEventDetailText; +@property NSArray* pushTokenChain; +@property NSArray* telemetryFilterExpressions; +@property NSNumber* fullSyncInterval; +@property NSNumber* pushNotificationsFullSyncInterval; +@end + @interface SNTConfiguratorTest : XCTestCase @property NSFileManager* fileMgr; @property NSString* testDir; @@ -285,4 +318,153 @@ - (void)testAllowDelegatedSignalsOverride { XCTAssertFalse(sut.allowDelegatedSignals); } +#pragma mark - clearSyncState tests + +- (SNTConfigurator*)configuratorWithSyncState:(NSDictionary*)initialState + plistPath:(NSString*)plistPath { + if (initialState) { + XCTAssertTrue([initialState writeToFile:plistPath atomically:YES]); + } + return [[SNTConfigurator alloc] initWithSyncStateFile:plistPath + stateFile:@"/does/not/need/to/exist" + oldStateFile:@"/does/not/need/to/exist" + syncStateAccessAuthorizer:^{ + return YES; + } + stateAccessAuthorizer:^BOOL { + return NO; + }]; +} + +- (void)testClearSyncStateRemovesDiskFile { + NSString* plistPath = [NSString stringWithFormat:@"%@/test-clear-state.plist", self.testDir]; + NSDictionary* contents = @{@"ClientMode" : @(SNTClientModeLockdown)}; + XCTAssertTrue([contents writeToFile:plistPath atomically:YES]); + XCTAssertTrue([self.fileMgr fileExistsAtPath:plistPath]); + + SNTConfigurator* cfg = [self configuratorWithSyncState:nil plistPath:plistPath]; + cfg.syncState = contents.mutableCopy; + + [cfg clearSyncState]; + + XCTAssertEqual(cfg.syncState.count, (NSUInteger)0); + XCTAssertFalse([self.fileMgr fileExistsAtPath:plistPath]); +} + +- (void)testClearSyncStateNoFileDoesNotError { + NSString* plistPath = [NSString stringWithFormat:@"%@/test-clear-state.plist", self.testDir]; + XCTAssertFalse([self.fileMgr fileExistsAtPath:plistPath]); + + SNTConfigurator* cfg = [self configuratorWithSyncState:nil plistPath:plistPath]; + + XCTAssertNoThrow([cfg clearSyncState]); + XCTAssertFalse([self.fileMgr fileExistsAtPath:plistPath]); +} + +- (void)testClearSyncStateIdempotent { + NSString* plistPath = [NSString stringWithFormat:@"%@/test-clear-state.plist", self.testDir]; + NSDictionary* contents = @{@"ClientMode" : @(SNTClientModeLockdown)}; + XCTAssertTrue([contents writeToFile:plistPath atomically:YES]); + + SNTConfigurator* cfg = [self configuratorWithSyncState:nil plistPath:plistPath]; + cfg.syncState = contents.mutableCopy; + + [cfg clearSyncState]; + XCTAssertNoThrow([cfg clearSyncState]); + XCTAssertFalse([self.fileMgr fileExistsAtPath:plistPath]); +} + +#pragma mark - atomicallyApplyBundle: tests + +- (void)testAtomicallyApplyBundleReplacesAllKeys { + NSString* plistPath = [NSString stringWithFormat:@"%@/test-atomic-replace.plist", self.testDir]; + + // Pre-populate sync state with stale values — the atomic apply replaces all of them. + SNTConfigurator* cfg = [self configuratorWithSyncState:@{ + @"ClientMode" : @(SNTClientModeLockdown), + @"AllowedPathRegex" : @"old-pattern", + @"OverrideFileAccessAction" : @"disable", + @"full_sync_interval" : @(99999), + } + plistPath:plistPath]; + + // Build a bundle exercising every slot atomicallyApplyBundle: handles. + SNTConfigBundle* bundle = [[SNTConfigBundle alloc] init]; + // Use the testing category to set bundle slots directly. + bundle.clientMode = @(SNTClientModeMonitor); + bundle.syncType = @(SNTSyncTypeNormal); + bundle.allowlistRegex = @"new-allow"; + bundle.blocklistRegex = @"new-block"; + bundle.removableMediaAction = @"Block"; + bundle.removableMediaRemountFlags = @[ @"foo" ]; + bundle.encryptedRemovableMediaAction = @"Remount"; + bundle.encryptedRemovableMediaRemountFlags = @[ @"rdonly" ]; + bundle.blockNetworkMount = @(YES); + bundle.bannedNetworkMountBlockMessage = @"blocked"; + bundle.allowedNetworkMountHosts = @[ @"example.com" ]; + bundle.enableBundles = @(YES); + bundle.enableTransitiveRules = @(YES); + bundle.enableAllEventUpload = @(NO); + bundle.disableUnknownEventUpload = @(YES); + bundle.overrideFileAccessAction = @"AuditOnly"; // case validated; original case stored + bundle.fullSyncLastSuccess = [NSDate dateWithTimeIntervalSince1970:1000]; + bundle.ruleSyncLastSuccess = [NSDate dateWithTimeIntervalSince1970:2000]; + bundle.eventDetailURL = @"https://x/details"; + bundle.eventDetailText = @"View"; + bundle.fileAccessEventDetailURL = @"https://x/faa"; + bundle.fileAccessEventDetailText = @"View FAA"; + bundle.pushTokenChain = @[ @"issuerJWT", @"jwt" ]; + bundle.telemetryFilterExpressions = @[ @"expr" ]; + bundle.fullSyncInterval = @(600); + bundle.pushNotificationsFullSyncInterval = @(21600); + + [cfg atomicallyApplyBundle:bundle]; + + // FullSyncInterval set by the bundle replaces the stale 99999. + XCTAssertEqualObjects(cfg.syncState[@"full_sync_interval"], @(600)); + // Pre-existing stale values are replaced (not merged). + XCTAssertTrue([cfg.syncState[@"AllowedPathRegex"] isKindOfClass:[NSRegularExpression class]]); + XCTAssertEqualObjects([cfg.syncState[@"AllowedPathRegex"] pattern], @"new-allow"); + XCTAssertTrue([cfg.syncState[@"BlockedPathRegex"] isKindOfClass:[NSRegularExpression class]]); + XCTAssertEqualObjects([cfg.syncState[@"BlockedPathRegex"] pattern], @"new-block"); + + // ClientMode validated and stored. + XCTAssertEqualObjects(cfg.syncState[@"ClientMode"], @(SNTClientModeMonitor)); + + // OverrideFileAccessAction stored in original case (matching + // setSyncServerOverrideFileAccessAction:). + XCTAssertEqualObjects(cfg.syncState[@"OverrideFileAccessAction"], @"AuditOnly"); + + // Push token chain and telemetry filter expressions ensure-string-filtered. + XCTAssertEqualObjects(cfg.syncState[@"PushTokenChain"], (@[ @"issuerJWT", @"jwt" ])); + XCTAssertEqualObjects(cfg.syncState[@"TelemetryFilterExpressions"], (@[ @"expr" ])); + + // Bundle slots that the method excludes are absent. + XCTAssertNil(cfg.syncState[@"ModeTransition"]); + + // Disk file matches in-memory state (modulo regex flatten). + NSDictionary* onDisk = [NSDictionary dictionaryWithContentsOfFile:plistPath]; + XCTAssertEqualObjects(onDisk[@"ClientMode"], @(SNTClientModeMonitor)); + XCTAssertEqualObjects(onDisk[@"AllowedPathRegex"], @"new-allow"); // pattern string on disk + XCTAssertEqualObjects(onDisk[@"OverrideFileAccessAction"], @"AuditOnly"); + + XCTAssertTrue([self.fileMgr removeItemAtPath:plistPath error:nil]); +} + +- (void)testAtomicallyApplyBundleDropsInvalidValues { + NSString* plistPath = [NSString stringWithFormat:@"%@/test-atomic-drops.plist", self.testDir]; + SNTConfigurator* cfg = [self configuratorWithSyncState:@{} plistPath:plistPath]; + + SNTConfigBundle* bundle = [[SNTConfigBundle alloc] init]; + bundle.clientMode = @(42); // invalid enum + bundle.overrideFileAccessAction = @"warn"; // not in allow-list + + [cfg atomicallyApplyBundle:bundle]; + + XCTAssertNil(cfg.syncState[@"ClientMode"]); + XCTAssertNil(cfg.syncState[@"OverrideFileAccessAction"]); + + XCTAssertTrue([self.fileMgr removeItemAtPath:plistPath error:nil]); +} + @end diff --git a/Source/common/SNTXPCControlInterface.h b/Source/common/SNTXPCControlInterface.h index 8bd0c41ad..16acba0d2 100644 --- a/Source/common/SNTXPCControlInterface.h +++ b/Source/common/SNTXPCControlInterface.h @@ -55,6 +55,19 @@ typedef NS_ENUM(NSInteger, SNTRuleAddSource) { /// /// Config ops /// +/// Apply `bundle` to the persisted sync state. By default, non-nil values +/// are merged per-key into the existing state (every other sync stage's +/// incremental write). +/// +/// When `bundle.clearSyncStateBeforeApply == YES` (set by +/// `PostflightConfigBundle` only when the in-flight sync was Clean or +/// CleanAll), the daemon takes an atomic-swap branch: it calls +/// `[SNTConfigurator atomicallyApplyBundle:]`, which builds a fresh +/// in-memory dictionary from the bundle and performs a single in-memory +/// swap + single disk write. The mode-transition handler and CEL fallback +/// rule cache flush still fire after the swap; persistence for every other +/// slot is owned by the atomic-apply call. +/// - (void)updateSyncSettings:(SNTConfigBundle*)result reply:(void (^)(void))reply; /// diff --git a/Source/santactl/Commands/SNTCommandSync.mm b/Source/santactl/Commands/SNTCommandSync.mm index 069555bcf..1c4feffb4 100644 --- a/Source/santactl/Commands/SNTCommandSync.mm +++ b/Source/santactl/Commands/SNTCommandSync.mm @@ -51,9 +51,10 @@ + (NSString*)longHelpText { @"this is the command used for syncing.\n\n" @"Options:\n" @" --clean: Perform a clean sync, erasing all existing non-transitive rules and\n" - @" requesting a clean sync from the server.\n" + @" requesting a clean sync from the server. Also clears sync-managed\n" + @" settings.\n" @" --clean-all: Perform a clean sync, erasing all existing rules and requesting a\n" - @" clean sync from the server.\n" + @" clean sync from the server. Also clears sync-managed settings.\n" @" --debug: Enable verbose output.\n"); } diff --git a/Source/santad/SNTDaemonControlController.mm b/Source/santad/SNTDaemonControlController.mm index 99f28aea5..9b6c8417a 100644 --- a/Source/santad/SNTDaemonControlController.mm +++ b/Source/santad/SNTDaemonControlController.mm @@ -473,6 +473,35 @@ - (void)disableUnknownEventUpload:(void (^)(BOOL))reply { - (void)updateSyncSettings:(SNTConfigBundle*)result reply:(void (^)(void))reply { SNTConfigurator* configurator = [SNTConfigurator configurator]; + __block BOOL atomicReplace = NO; + [result clearSyncStateBeforeApply:^(BOOL v) { + atomicReplace = v; + }]; + + if (atomicReplace) { + // Capture pre-swap CEL state so the post-swap comparison detects "rules + // disappeared from the server's config" as a change too — not just + // modifications. + NSData* oldCELData = [SNTCELFallbackRule serializeArray:[configurator celFallbackRules]]; + + [configurator atomicallyApplyBundle:result]; + + // Side-effects pass. Persistence is already done by atomicallyApplyBundle: + // for every slot except modeTransition. + [result modeTransition:^(SNTModeTransition* val) { + _temporaryMonitorMode->NewModeTransitionReceived(val); + }]; + + NSData* newCELData = [SNTCELFallbackRule serializeArray:[configurator celFallbackRules]]; + if (oldCELData != newCELData && ![oldCELData isEqualToData:newCELData] && + self.flushCacheBlock) { + self.flushCacheBlock(FlushCacheMode::kAllCaches, FlushCacheReason::kCELFallbackRulesChanged); + } + + reply(); + return; + } + [result clientMode:^(SNTClientMode m) { [configurator setSyncServerClientMode:m]; }]; diff --git a/Source/santasyncservice/SNTSyncConfigBundle.mm b/Source/santasyncservice/SNTSyncConfigBundle.mm index 9781b0e88..1e98fe9e1 100644 --- a/Source/santasyncservice/SNTSyncConfigBundle.mm +++ b/Source/santasyncservice/SNTSyncConfigBundle.mm @@ -52,6 +52,7 @@ @interface SNTConfigBundle (ConfigBundleCreator) @property NSArray* celFallbackRules; @property NSNumber* fullSyncInterval; @property NSNumber* pushNotificationsFullSyncInterval; +@property NSNumber* clearSyncStateBeforeApply; @end SNTConfigBundle* PreflightConfigBundle(SNTSyncState* syncState) { @@ -68,6 +69,10 @@ @interface SNTConfigBundle (ConfigBundleCreator) SNTConfigBundle* bundle = [[SNTConfigBundle alloc] init]; bundle.clientMode = syncState.clientMode ? @(syncState.clientMode) : nil; + // After a Clean or CleanAll sync, reset SyncTypeRequired to Normal so the + // directive doesn't repeat on the next sync. The daemon's atomic-swap + // branch is gated by `clearSyncStateBeforeApply` (set below), not by this + // syncType value. bundle.syncType = syncState.syncType != SNTSyncTypeNormal ? @(SNTSyncTypeNormal) : nil; bundle.allowlistRegex = syncState.allowlistRegex; bundle.blocklistRegex = syncState.blocklistRegex; @@ -95,8 +100,19 @@ @interface SNTConfigBundle (ConfigBundleCreator) bundle.fullSyncInterval = syncState.fullSyncInterval; bundle.pushNotificationsFullSyncInterval = syncState.pushNotificationsFullSyncInterval; + if (syncState.pushIssuerJWT.length && syncState.pushJWT.length) { + bundle.pushTokenChain = @[ syncState.pushIssuerJWT, syncState.pushJWT ]; + } + bundle.fullSyncLastSuccess = [NSDate now]; + // On Clean/CleanAll syncs, signal the daemon to atomically replace the + // persisted sync state instead of merging per-key, so settings the server + // has stopped sending no longer linger on disk. + if (syncState.syncType != SNTSyncTypeNormal) { + bundle.clearSyncStateBeforeApply = @(YES); + } + return bundle; } diff --git a/Source/santasyncservice/SNTSyncConfigBundleTest.mm b/Source/santasyncservice/SNTSyncConfigBundleTest.mm index caca79cfe..d7fbbfda3 100644 --- a/Source/santasyncservice/SNTSyncConfigBundleTest.mm +++ b/Source/santasyncservice/SNTSyncConfigBundleTest.mm @@ -258,4 +258,111 @@ - (void)testSyncTypeConfigBundle { XCTAssertNil(bundle.celFallbackRules); } +- (void)testPostflightConfigBundleAlwaysSetsFullSyncLastSuccess { + for (SNTSyncType type : + (SNTSyncType[]){SNTSyncTypeNormal, SNTSyncTypeClean, SNTSyncTypeCleanAll}) { + SNTSyncState* state = [[SNTSyncState alloc] init]; + state.syncType = type; + SNTConfigBundle* bundle = PostflightConfigBundle(state); + XCTAssertNotNil(bundle.fullSyncLastSuccess, + @"PostflightConfigBundle must set fullSyncLastSuccess " + @"for syncType=%lu", + (unsigned long)type); + } +} + +- (void)testPostflightConfigBundleResetsSyncTypeToNormalAfterCleanSync { + SNTSyncState* cleanState = [[SNTSyncState alloc] init]; + cleanState.syncType = SNTSyncTypeClean; + SNTConfigBundle* cleanBundle = PostflightConfigBundle(cleanState); + XCTAssertEqualObjects(cleanBundle.syncType, @(SNTSyncTypeNormal)); + + SNTSyncState* cleanAllState = [[SNTSyncState alloc] init]; + cleanAllState.syncType = SNTSyncTypeCleanAll; + SNTConfigBundle* cleanAllBundle = PostflightConfigBundle(cleanAllState); + XCTAssertEqualObjects(cleanAllBundle.syncType, @(SNTSyncTypeNormal)); + + SNTSyncState* normalState = [[SNTSyncState alloc] init]; + normalState.syncType = SNTSyncTypeNormal; + SNTConfigBundle* normalBundle = PostflightConfigBundle(normalState); + XCTAssertNil(normalBundle.syncType); +} + +- (void)testPostflightConfigBundleForwardsPushTokenChain { + SNTSyncState* syncState = [[SNTSyncState alloc] init]; + syncState.pushIssuerJWT = @"issuerToken"; + syncState.pushJWT = @"userToken"; + + SNTConfigBundle* bundle = PostflightConfigBundle(syncState); + XCTAssertEqualObjects(bundle.pushTokenChain, (@[ @"issuerToken", @"userToken" ])); +} + +- (void)testPostflightConfigBundleOmitsPushTokenChainWhenJWTsMissing { + SNTSyncState* syncState = [[SNTSyncState alloc] init]; + // Both empty: no chain. + SNTConfigBundle* bundle = PostflightConfigBundle(syncState); + XCTAssertNil(bundle.pushTokenChain); + + // One missing: still no chain (defensive — server is expected to always + // send both, but this ensures we don't ship a half-chain to the daemon). + syncState.pushIssuerJWT = @"issuerToken"; + bundle = PostflightConfigBundle(syncState); + XCTAssertNil(bundle.pushTokenChain); + + // Symmetric: only pushJWT set, pushIssuerJWT missing — still no chain. + syncState.pushIssuerJWT = nil; + syncState.pushJWT = @"userToken"; + bundle = PostflightConfigBundle(syncState); + XCTAssertNil(bundle.pushTokenChain); +} + +- (void)testPostflightConfigBundleSetsClearSyncStateBeforeApplyOnCleanSync { + for (SNTSyncType type : (SNTSyncType[]){SNTSyncTypeClean, SNTSyncTypeCleanAll}) { + SNTSyncState* state = [[SNTSyncState alloc] init]; + state.syncType = type; + SNTConfigBundle* bundle = PostflightConfigBundle(state); + + __block BOOL fired = NO; + __block BOOL value = NO; + [bundle clearSyncStateBeforeApply:^(BOOL v) { + fired = YES; + value = v; + }]; + + XCTAssertTrue(fired, + @"PostflightConfigBundle must set clearSyncStateBeforeApply for syncType=%lu", + (unsigned long)type); + XCTAssertTrue(value); + } +} + +- (void)testPostflightConfigBundleOmitsClearSyncStateBeforeApplyOnNormalSync { + SNTSyncState* state = [[SNTSyncState alloc] init]; + state.syncType = SNTSyncTypeNormal; + SNTConfigBundle* bundle = PostflightConfigBundle(state); + + __block BOOL fired = NO; + [bundle clearSyncStateBeforeApply:^(BOOL v) { + fired = YES; + }]; + XCTAssertFalse(fired); +} + +- (void)testNonPostflightFactoriesNeverSetClearSyncStateBeforeApply { + SNTSyncState* state = [[SNTSyncState alloc] init]; + NSArray* bundles = @[ + PreflightConfigBundle(state), + RuleSyncConfigBundle(), + SyncTypeConfigBundle(SNTSyncTypeClean), + ]; + + for (SNTConfigBundle* bundle in bundles) { + __block BOOL fired = NO; + [bundle clearSyncStateBeforeApply:^(BOOL v) { + fired = YES; + }]; + XCTAssertFalse(fired); + } +} + @end diff --git a/Source/santasyncservice/SNTSyncManager.h b/Source/santasyncservice/SNTSyncManager.h index 4267df855..d259981c6 100644 --- a/Source/santasyncservice/SNTSyncManager.h +++ b/Source/santasyncservice/SNTSyncManager.h @@ -61,7 +61,12 @@ /// reply block will be called again with a SNTSyncStatusType when the sync has completed or /// failed. /// -/// Pass true to isClean to perform a clean sync, defaults to false. +/// Pass `syncType` to control rule cleanup: `SNTSyncTypeNormal` for a regular +/// sync, `SNTSyncTypeClean` to remove non-transitive rules, or +/// `SNTSyncTypeCleanAll` to remove all rules. On Clean and CleanAll, the +/// postflight bundle sets `clearSyncStateBeforeApply` so the daemon +/// atomically replaces the persisted sync state instead of merging +/// per-key — settings the server stops sending no longer linger on disk. /// - (void)syncType:(SNTSyncType)syncType withReply:(void (^)(SNTSyncStatusType))reply; diff --git a/Source/santasyncservice/SNTSyncTest.mm b/Source/santasyncservice/SNTSyncTest.mm index bf9f21365..185630ecc 100644 --- a/Source/santasyncservice/SNTSyncTest.mm +++ b/Source/santasyncservice/SNTSyncTest.mm @@ -19,6 +19,7 @@ #import "Source/common/MOLXPCConnection.h" #import "Source/common/SNTCommonEnums.h" +#import "Source/common/SNTConfigBundle.h" #import "Source/common/SNTConfigurator.h" #import "Source/common/SNTModeTransition.h" #import "Source/common/SNTRule.h" @@ -1423,4 +1424,71 @@ - (void)testEventUploadRuleId { XCTAssertTrue([sut sync]); } +- (void)testCleanSyncPostflightBundleHasClearSyncStateBeforeApply { + // Drive a clean-sync flow through the two stages that ship + // updateSyncSettings: bundles bracketing the in-flight syncType (preflight + // and postflight), capture every bundle, and assert that exactly one of + // them (the postflight bundle) sets clearSyncStateBeforeApply == YES while + // none of the others do. This pins the contract that Task 4's clean-sync + // branch depends on: only the postflight bundle triggers the daemon's + // atomic swap; preflight bundles never carry the flag. + NSMutableArray* capturedBundles = [NSMutableArray array]; + OCMStub([self.daemonConnRop updateSyncSettings:[OCMArg any] reply:[OCMArg any]]) + .andDo(^(NSInvocation* inv) { + SNTConfigBundle* __unsafe_unretained bundle = nil; + [inv getArgument:&bundle atIndex:2]; + @synchronized(capturedBundles) { + [capturedBundles addObject:bundle]; + } + void (^__unsafe_unretained reply)(void) = nil; + [inv getArgument:&reply atIndex:3]; + if (reply) reply(); + }); + + // Default daemon stubs report Normal as the requested syncType; the server's + // preflight response below will override that to Clean. + [self setupDefaultDaemonConnResponses]; + + // Stage 1: preflight. Stub a server response that elects a Clean sync so + // syncState.syncType becomes Clean entering postflight. + NSData* preflightBody = + [@"{\"sync_type\": \"CLEAN\", \"client_mode\": \"MONITOR\", \"batch_size\": 100}" + dataUsingEncoding:NSUTF8StringEncoding]; + [self stubRequestBody:preflightBody + response:nil + error:nil + validateBlock:^BOOL(NSURLRequest* req) { + return [req.URL.absoluteString containsString:@"/preflight/"]; + }]; + XCTAssertTrue([[[SNTSyncPreflight alloc] initWithState:self.syncState] sync]); + XCTAssertEqual(self.syncState.syncType, SNTSyncTypeClean, + @"Preflight must elect Clean for the postflight bundle to set the flag"); + + // Stage 2: postflight. Stub a generic response and run. + [self stubRequestBody:nil + response:nil + error:nil + validateBlock:^BOOL(NSURLRequest* req) { + return [req.URL.absoluteString containsString:@"/postflight/"]; + }]; + XCTAssertTrue([[[SNTSyncPostflight alloc] initWithState:self.syncState] sync]); + + NSUInteger withFlag = 0; + for (SNTConfigBundle* b in capturedBundles) { + __block BOOL fired = NO; + [b clearSyncStateBeforeApply:^(BOOL v) { + if (v) fired = YES; + }]; + if (fired) withFlag++; + } + XCTAssertGreaterThanOrEqual(capturedBundles.count, (NSUInteger)2, + @"Expected the clean-sync flow to ship at least two bundles " + @"(preflight + postflight); got %lu", + (unsigned long)capturedBundles.count); + XCTAssertEqual(withFlag, (NSUInteger)1, + @"Expected exactly one bundle with clearSyncStateBeforeApply=YES " + @"during a --clean sync (the postflight bundle); got %lu", + (unsigned long)withFlag); +} + @end