diff --git a/docs/design_docs/tweak_compatibility.md b/docs/design_docs/tweak_compatibility.md index 1e9c931b..965e5f3b 100644 --- a/docs/design_docs/tweak_compatibility.md +++ b/docs/design_docs/tweak_compatibility.md @@ -69,8 +69,10 @@ Central replication can assess an effective value which differs from a saved preference, such as internal Metadata encryption whose prerequisites are inactive. Recovery accepts the current saved or effective snapshot without treating that representation difference as a settings change. Applying assessed changes retains -saved preferences for settings whose effective values already agree. The full -settings signature still detects a preference changed while awaiting a decision. +saved preferences only when they leave the adopted effective values unchanged. +If adoption activates encryption prerequisites, the adopted encryption value +takes precedence. The full settings signature still detects a preference changed +while awaiting a decision. The active publication reservation is not held while waiting for the dialogue. Remote writes use the failed attempt's publication guard. Fetch and Rebuild use diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts index 0294b7d1..4053b6c9 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts @@ -215,8 +215,17 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { ): Promise<[TweakValues | boolean, boolean]> { if (assessment.alignment === "matched") return [false, false]; if (this.requiresIdConfigurationReview(assessment)) return [false, false]; - // Retain saved preferences when only their effective representation differs from the assessment. + // Retain saved preferences only when they leave the adopted effective values unchanged. + const assessedSettings = settingsAfterAdoption(assessment, "adoptPreferred"); const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred", this.settings); + if ( + this.settings.remoteType === REMOTE_COUCHDB && + usesEncryptedInternalMetadata({ ...this.settings, ...acceptedSettings }) !== + usesEncryptedInternalMetadata({ ...this.settings, ...assessedSettings }) + ) { + // Adopting encryption prerequisites can activate a previously inactive preference. + acceptedSettings.encryptInternalMetadata = assessedSettings.encryptInternalMetadata; + } const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment); if (autoAcceptSide === "REMOTE") return [acceptedSettings, false]; if (autoAcceptSide === "CURRENT") return [true, false]; diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts index b8fb8623..f52f5c85 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts @@ -14,6 +14,7 @@ import { CENTRAL_COMPATIBILITY_REJECTION_REASONS, REMOTE_RESOURCE_KINDS, USER_INITIATED_REPLICATION_AUTHORITY, + usesEncryptedInternalMetadata, type ReplicationAttemptFailure, } from "@vrtmrz/livesync-commonlib/replication"; @@ -74,6 +75,46 @@ function createModule(settingsOverride: Partial = {}) { } describe("ModuleResolvingMismatchedTweaks", () => { + it.each([ + { label: "E2EE", encrypt: false, usePathObfuscation: true }, + { label: "Property Encryption", encrypt: true, usePathObfuscation: false }, + ])( + "uses the remote Metadata encryption preference when adoption enables $label", + async ({ encrypt, usePathObfuscation }) => { + const { module, core, askSelectStringDialogue } = createModule({ + autoAcceptCompatibleTweak: false, + encrypt, + usePathObfuscation, + E2EEAlgorithm: "v2", + encryptInternalMetadata: true, + idDerivationVersion: 0, + }); + const current = { + ...extractObject(TweakValuesTemplate, core.settings), + encryptInternalMetadata: usesEncryptedInternalMetadata(core.settings), + }; + const preferred = { ...current, encrypt: true, usePathObfuscation: true, encryptInternalMetadata: false }; + const assessment = assessTweakCompatibility(current, preferred); + core._services.tweakValue = { + checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module), + }; + core.rebuilder = { $fetchLocal: vi.fn(async () => undefined) }; + askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device, and fetch again"); + const updateRemote = vi.fn(async () => true); + + await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote, assessment)).resolves.toBe( + "CHECKAGAIN" + ); + + expect(core.rebuilder.$fetchLocal).toHaveBeenCalledOnce(); + expect(core.settings).toMatchObject({ encrypt: true, usePathObfuscation: true }); + expect(usesEncryptedInternalMetadata(core.settings)).toBe(false); + expect(updateRemote).toHaveBeenCalledWith( + expect.objectContaining({ encrypt: true, usePathObfuscation: true, encryptInternalMetadata: false }) + ); + } + ); + it.each([ { label: "automatic alignment with E2EE disabled", autoAcceptCompatibleTweak: true, encrypt: false }, { label: "manual review with E2EE disabled", autoAcceptCompatibleTweak: false, encrypt: false },