mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-21 16:52:32 +00:00
Fix tweak recovery for effective encryption settings
This commit is contained in:
@@ -65,6 +65,13 @@ for a decision and before applying it. A changed target or changed settings
|
||||
discard that decision. The signature used for this check can contain sensitive
|
||||
configuration and must not be logged, persisted, or included in diagnostics.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
their existing owners and propagate failure without claiming a successful
|
||||
|
||||
@@ -16,11 +16,12 @@ import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { $msg, translateIfAvailable } from "@/common/translation";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { REMOTE_COUCHDB, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
usesEncryptedInternalMetadata,
|
||||
type ReplicationAttemptFailure,
|
||||
type ReplicatorInstance,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
@@ -62,10 +63,14 @@ function definedTweaks(values: TweakValues): TweakValues {
|
||||
);
|
||||
}
|
||||
|
||||
function settingsAfterAdoption(assessment: TweakAssessment, direction: "adoptPreferred" | "adoptCurrent"): TweakValues {
|
||||
function settingsAfterAdoption(
|
||||
assessment: TweakAssessment,
|
||||
direction: "adoptPreferred" | "adoptCurrent",
|
||||
currentSettings: TweakValues = assessment.currentValues
|
||||
): TweakValues {
|
||||
const comparedKeys = new Set<string>(assessment.entries.map((entry) => entry.key));
|
||||
const source = direction === "adoptPreferred" ? assessment.preferredValues : assessment.currentValues;
|
||||
const target = direction === "adoptPreferred" ? assessment.currentValues : assessment.preferredValues;
|
||||
const target = direction === "adoptPreferred" ? currentSettings : assessment.preferredValues;
|
||||
const recommendations = Object.fromEntries(
|
||||
Object.entries(source).filter(([key, value]) => !comparedKeys.has(key) && value !== undefined)
|
||||
);
|
||||
@@ -100,7 +105,9 @@ function resolutionSettingsSignature(settings: ObsidianLiveSyncSettings): string
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
private requiresIdConfigurationReview(assessment: TweakAssessment): boolean {
|
||||
if (!assessment.entries.some(({ key, relation }) => key === "idDerivationVersion" && relation === "different")) {
|
||||
if (
|
||||
!assessment.entries.some(({ key, relation }) => key === "idDerivationVersion" && relation === "different")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
Logger(
|
||||
@@ -208,7 +215,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
): Promise<[TweakValues | boolean, boolean]> {
|
||||
if (assessment.alignment === "matched") return [false, false];
|
||||
if (this.requiresIdConfigurationReview(assessment)) return [false, false];
|
||||
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred");
|
||||
// Retain saved preferences when only their effective representation differs from the assessment.
|
||||
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred", this.settings);
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
|
||||
if (autoAcceptSide === "REMOTE") return [acceptedSettings, false];
|
||||
if (autoAcceptSide === "CURRENT") return [true, false];
|
||||
@@ -279,8 +287,15 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
const signature = resolutionSettingsSignature(this.settings);
|
||||
const publication = await this.services.replicator.acquireActiveReplicatorContext();
|
||||
if (resolutionSettingsSignature(this.settings) !== signature) return "IGNORE";
|
||||
const currentTweaks = JSON.stringify(extractObject(TweakValuesTemplate, this.settings));
|
||||
if (JSON.stringify(extractObject(TweakValuesTemplate, assessment.currentValues)) !== currentTweaks) {
|
||||
const currentTweaks = extractObject(TweakValuesTemplate, this.settings);
|
||||
const effectiveTweaks = {
|
||||
...currentTweaks,
|
||||
encryptInternalMetadata:
|
||||
this.settings.remoteType === REMOTE_COUCHDB && usesEncryptedInternalMetadata(this.settings),
|
||||
};
|
||||
const assessedTweaks = JSON.stringify(extractObject(TweakValuesTemplate, assessment.currentValues));
|
||||
// Replication supplies effective values; legacy callers can supply saved preferences.
|
||||
if (assessedTweaks !== JSON.stringify(currentTweaks) && assessedTweaks !== JSON.stringify(effectiveTweaks)) {
|
||||
return "IGNORE";
|
||||
}
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(
|
||||
|
||||
@@ -74,6 +74,130 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it.each([
|
||||
{ label: "automatic alignment with E2EE disabled", autoAcceptCompatibleTweak: true, encrypt: false },
|
||||
{ label: "manual review with E2EE disabled", autoAcceptCompatibleTweak: false, encrypt: false },
|
||||
{
|
||||
label: "automatic alignment with Property Encryption disabled",
|
||||
autoAcceptCompatibleTweak: true,
|
||||
encrypt: true,
|
||||
},
|
||||
])(
|
||||
"resolves an effective chunk mismatch while retaining an inactive encryption preference: $label",
|
||||
async ({ autoAcceptCompatibleTweak, encrypt }) => {
|
||||
const { module, core, askSelectStringDialogue, reinitialise } = createModule({
|
||||
autoAcceptCompatibleTweak,
|
||||
encrypt,
|
||||
usePathObfuscation: false,
|
||||
encryptInternalMetadata: true,
|
||||
customChunkSize: 60,
|
||||
tweakModified: 100,
|
||||
});
|
||||
// CouchDB assesses the effective encryption value, while the saved preference remains enabled.
|
||||
const current = { ...extractObject(TweakValuesTemplate, core.settings), encryptInternalMetadata: false };
|
||||
const preferred = { ...current, customChunkSize: 0, tweakModified: 200 };
|
||||
const assessment = assessTweakCompatibility(current, preferred);
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
|
||||
};
|
||||
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device");
|
||||
const updateRemote = vi.fn(async () => true);
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote, assessment)).resolves.toBe(
|
||||
"CHECKAGAIN"
|
||||
);
|
||||
|
||||
expect(core.settings).toMatchObject({ customChunkSize: 0, encryptInternalMetadata: true });
|
||||
expect(core._services.setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(reinitialise).toHaveBeenCalledOnce();
|
||||
expect(updateRemote).toHaveBeenCalledOnce();
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledTimes(autoAcceptCompatibleTweak ? 0 : 1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["settings", "publication"] as const)(
|
||||
"discards an effective assessment when %s change while awaiting a decision",
|
||||
async (changed) => {
|
||||
const { module, core, askSelectStringDialogue, reinitialise } = createModule({
|
||||
autoAcceptCompatibleTweak: false,
|
||||
encrypt: false,
|
||||
encryptInternalMetadata: true,
|
||||
customChunkSize: 60,
|
||||
});
|
||||
const current = { ...extractObject(TweakValuesTemplate, core.settings), encryptInternalMetadata: false };
|
||||
const preferred = { ...current, customChunkSize: 0 };
|
||||
const assessment = assessTweakCompatibility(current, preferred);
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
|
||||
};
|
||||
askSelectStringDialogue.mockImplementation(async () => {
|
||||
if (changed === "settings") {
|
||||
// The effective value stays false, but the user's saved preference has changed.
|
||||
core.settings.encryptInternalMetadata = false;
|
||||
} else {
|
||||
core._services.replicator.acquireActiveReplicatorContext.mockResolvedValue({});
|
||||
}
|
||||
return "Apply settings to this device";
|
||||
});
|
||||
const updateRemote = vi.fn(async () => true);
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote, assessment)).resolves.toBe(
|
||||
"IGNORE"
|
||||
);
|
||||
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledOnce();
|
||||
expect(core.settings.customChunkSize).toBe(60);
|
||||
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
|
||||
expect(reinitialise).not.toHaveBeenCalled();
|
||||
expect(updateRemote).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it("discards an effective assessment of an earlier chunk setting before opening a dialogue", async () => {
|
||||
const { module, core, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: false,
|
||||
encrypt: false,
|
||||
encryptInternalMetadata: true,
|
||||
customChunkSize: 60,
|
||||
});
|
||||
const current = { ...extractObject(TweakValuesTemplate, core.settings), encryptInternalMetadata: false };
|
||||
const preferred = { ...current, customChunkSize: 0 };
|
||||
const assessment = assessTweakCompatibility(current, preferred);
|
||||
core.settings.customChunkSize = 72;
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
|
||||
};
|
||||
const updateRemote = vi.fn(async () => true);
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote, assessment)).resolves.toBe("IGNORE");
|
||||
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(core.settings.customChunkSize).toBe(72);
|
||||
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
|
||||
expect(updateRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies an explicit decision when effective internal Metadata encryption differs", async () => {
|
||||
const { module, core, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
encrypt: true,
|
||||
usePathObfuscation: true,
|
||||
E2EEAlgorithm: "v2",
|
||||
encryptInternalMetadata: true,
|
||||
});
|
||||
const preferred = { ...extractObject(TweakValuesTemplate, core.settings), encryptInternalMetadata: false };
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
|
||||
};
|
||||
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device");
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks(preferred, async () => true)).resolves.toBe("CHECKAGAIN");
|
||||
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledOnce();
|
||||
expect(core.settings.encryptInternalMetadata).toBe(false);
|
||||
expect(core._services.setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([0, 1] as const)(
|
||||
"keeps ID configuration %s when automatically aligning Chunk settings",
|
||||
async (idDerivationVersion) => {
|
||||
|
||||
@@ -187,7 +187,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
npm run test:e2e:obsidian:cli-to-obsidian-sync
|
||||
```
|
||||
|
||||
`test:e2e:obsidian:tweak-compatibility` exercises the mismatch dialogue against temporary CouchDB databases. Build the plug-in first. The scenario removes the legacy filename-case value from the remote preferred settings, applies compatible differences through the ordinary settings action, and verifies note synchronisation and a subsequent restart. Separate true/false and true/missing filename-case mismatches check that Fetch remains required. The true/missing case also fetches the files and verifies a subsequent synchronisation attempt. A final case changes the remote while the dialogue is open, selects the stale Fetch action, and checks that the current settings, existing local file, and documents in both remotes remain unchanged.
|
||||
`test:e2e:obsidian:tweak-compatibility` exercises the mismatch dialogue against temporary CouchDB databases. Build the plug-in first. The scenario removes the legacy filename-case value from the remote preferred settings, applies compatible differences through the ordinary settings action, and verifies note synchronisation and a subsequent restart. An enabled but inactive internal Metadata encryption preference must neither suppress the `Sync now` dialogue nor be lost when applying the remote chunk settings and restarting. Separate true/false and true/missing filename-case mismatches check that Fetch remains required. The true/missing case also fetches the files and verifies a subsequent synchronisation attempt. A final case changes the remote while the dialogue is open, selects the stale Fetch action, and checks that the current settings, existing local file, and documents in both remotes remain unchanged.
|
||||
|
||||
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* in the chunk size and V2 customisation setting. Applying the ordinary
|
||||
* remote settings action must permit a fresh synchronisation without a Fetch. A
|
||||
* Separate true/false and true/missing case controls keep Fetch required.
|
||||
* An enabled but inactive internal Metadata encryption preference must survive
|
||||
* ordinary setting adoption and restart, and must not suppress the Sync now dialogue.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -129,6 +131,21 @@ async function replicateOnce(cliBinary: string, env: NodeJS.ProcessEnv): Promise
|
||||
);
|
||||
}
|
||||
|
||||
async function runSyncNow(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<boolean>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const command=app.commands.commands['obsidian-livesync:livesync-replicate'];",
|
||||
"if(typeof command?.callback!=='function') throw new Error('Sync now command is unavailable');",
|
||||
"await command.callback();",
|
||||
"return JSON.stringify(true);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function selectCompatibilityAction(
|
||||
port: number,
|
||||
action: string,
|
||||
@@ -220,17 +237,23 @@ async function prepareConfiguredSession(
|
||||
async function readTweakState(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<{ customChunkSize: unknown; usePluginSyncV2: unknown; handleFilenameCaseSensitive: unknown }> {
|
||||
): Promise<{
|
||||
customChunkSize: unknown;
|
||||
usePluginSyncV2: unknown;
|
||||
handleFilenameCaseSensitive: unknown;
|
||||
encryptInternalMetadata: unknown;
|
||||
}> {
|
||||
return await evalObsidianJson<{
|
||||
customChunkSize: unknown;
|
||||
usePluginSyncV2: unknown;
|
||||
handleFilenameCaseSensitive: unknown;
|
||||
encryptInternalMetadata: unknown;
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
"const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({customChunkSize:settings.customChunkSize,usePluginSyncV2:settings.usePluginSyncV2,handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive});",
|
||||
"return JSON.stringify({customChunkSize:settings.customChunkSize,usePluginSyncV2:settings.usePluginSyncV2,handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,encryptInternalMetadata:settings.encryptInternalMetadata});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
@@ -360,12 +383,20 @@ async function main(): Promise<void> {
|
||||
await removeLegacyCasePreference(couchDb, dbName);
|
||||
|
||||
targetSession = await prepareConfiguredSession(binary, cli.binary, targetVault, settings, {
|
||||
encrypt: false,
|
||||
encryptInternalMetadata: true,
|
||||
customChunkSize: 60,
|
||||
usePluginSyncV2: true,
|
||||
handleFilenameCaseSensitive: false,
|
||||
autoAcceptCompatibleTweak: false,
|
||||
});
|
||||
await prepareRemote(cli.binary, targetSession.cliEnv);
|
||||
const commandPromise = runSyncNow(cli.binary, targetSession.cliEnv);
|
||||
await selectCompatibilityAction(targetSession.remoteDebuggingPort, dismissAction);
|
||||
await commandPromise;
|
||||
const dismissedState = await readTweakState(cli.binary, targetSession.cliEnv);
|
||||
assertEqual(dismissedState.customChunkSize, 60, "Dismissing Sync now changed the chunk size.");
|
||||
assertEqual(dismissedState.encryptInternalMetadata, true, "Dismissing Sync now changed the saved preference.");
|
||||
const targetReplicationPromise = replicateOnce(cli.binary, targetSession.cliEnv);
|
||||
await selectCompatibilityAction(targetSession.remoteDebuggingPort, applySettingsAction);
|
||||
const targetReplication = await targetReplicationPromise;
|
||||
@@ -383,6 +414,11 @@ async function main(): Promise<void> {
|
||||
await waitForLiveSyncCoreReady(cli.binary, targetSession.cliEnv);
|
||||
await assertE2eCompatibilityMarker(cli.binary, targetSession.cliEnv);
|
||||
const restartedState = await readTweakState(cli.binary, targetSession.cliEnv);
|
||||
assertEqual(
|
||||
restartedState.encryptInternalMetadata,
|
||||
true,
|
||||
"The inactive internal Metadata encryption preference was not retained after adoption and restart."
|
||||
);
|
||||
assertEqual(
|
||||
restartedState.customChunkSize,
|
||||
0,
|
||||
|
||||
@@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can resolve configuration mismatches when **Encrypt internal file Properties** is enabled but its encryption prerequisites are inactive. (#1180)
|
||||
- Compatible chunk settings can be aligned automatically, or reviewed through `Sync now`. Applying those settings preserves our inactive encryption preference.
|
||||
|
||||
## 1.0.37
|
||||
|
||||
16th October, 2026
|
||||
|
||||
Reference in New Issue
Block a user