[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,764 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { EncryptionService } from '../EncryptionService';
import { createAdapter, type CloudAdapter } from '../adapters';
import type GitHubAdapter from '../adapters/GitHubAdapter';
import type GoogleDriveAdapter from '../adapters/GoogleDriveAdapter';
import type OneDriveAdapter from '../adapters/OneDriveAdapter';
import { createSyncedFileSignature as createSyncedFileSignatureCore } from '../syncSignature.js';
import { decideRemoteChanged } from '../syncAnchorDecision.js';
import type {
CloudProvider,
OAuthTokens,
ProviderAccount,
ProviderConnection,
S3Config,
SyncedFile,
SyncPayload,
WebDAVConfig,
} from '../../../domain/sync';
import { normalizeDurablePluginSyncCredentialRef } from '../../../domain/sync';
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
import type {
ProviderSyncAnchor,
StartProviderAuthResult,
} from '../CloudSyncManager';
import {
registerPluginProviderIdImpl,
unregisterPluginProviderIdImpl,
} from './stateAndSecurityMethods';
const SYNC_REMOTE_ANCHOR_STORAGE_KEY = 'netcatty_sync_remote_anchor_v1';
export function clearProviderMergeStateImpl(this: any, provider: CloudProvider): void {
this.removeFromStorage(this.syncBaseKey(provider));
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
this.clearSyncAnchor(provider);
}
export async function startProviderAuthImpl(this: any,
provider: CloudProvider,
redirectUri?: string
): Promise<StartProviderAuthResult> {
if (provider === 'webdav' || provider === 's3') {
throw new Error('Provider requires manual configuration');
}
const authAttemptId = ++this.providerAuthAttemptSeq[provider];
this.providerAuthRestoreState[provider] = {
attemptId: authAttemptId,
connection: { ...this.state.providers[provider] },
adapter: this.adapters.get(provider) ?? null,
};
const adapter = await createAdapter(provider);
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
this.adapters.set(provider, adapter);
this.updateProviderStatus(provider, 'connecting');
try {
if (provider === 'github') {
// GitHub uses Device Flow
const ghAdapter = adapter as GitHubAdapter;
const deviceFlow = await ghAdapter.startAuth();
return {
type: 'device_code',
data: { ...deviceFlow, authAttemptId },
};
} else {
// Google and OneDrive use PKCE with redirect
if (!redirectUri) {
throw new Error(
`startProviderAuth('${provider}') requires a redirectUri — ` +
'call prepareOAuthCallback on the bridge first and pass its redirectUri through.'
);
}
if (provider === 'google') {
const gdAdapter = adapter as GoogleDriveAdapter;
const url = await gdAdapter.startAuth(redirectUri);
return { type: 'url', data: { url, redirectUri, authAttemptId } };
} else {
const odAdapter = adapter as OneDriveAdapter;
const url = await odAdapter.startAuth(redirectUri);
return { type: 'url', data: { url, redirectUri, authAttemptId } };
}
}
} catch (error) {
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[CloudSync] ${provider} connect failed`, {
error: errorMessage,
});
this.updateProviderStatus(provider, 'error', errorMessage);
throw error;
}
}
export async function completeGitHubAuthImpl(this: any,
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void,
signal?: AbortSignal,
authAttemptId?: number
): Promise<void> {
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
const adapter = this.adapters.get('github');
if (!adapter) {
throw new Error('GitHub adapter not initialized');
}
const ghAdapter = adapter as GitHubAdapter;
try {
// Snapshot the prior account BEFORE we overwrite providers[provider].
// Used as a fallback for the same-account comparison when the persisted
// accountId key is absent (e.g., first re-auth after upgrading to this
// version, where the key didn't exist yet).
const previousAccount = this.state.providers.github?.account;
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending, signal);
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
const resourceId = await ghAdapter.initializeSync(signal);
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
++this.providerDecryptSeq.github;
this.state.providers.github = {
...this.state.providers.github,
status: 'connected',
tokens,
account: ghAdapter.accountInfo || undefined,
};
if (resourceId) {
this.state.providers.github.resourceId = resourceId;
}
await this.saveProviderConnection('github', this.state.providers.github, authAttemptId);
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. See notes in completePKCEAuth.
const newId = ghAdapter.accountInfo?.id ?? null;
const previousId = this.loadProviderAccountId('github') ?? previousAccount?.id ?? null;
const sameAccount = newId !== null && previousId !== null && newId === previousId;
if (!sameAccount) {
clearProviderMergeStateImpl.call(this, 'github');
}
if (newId) {
this.saveProviderAccountId('github', newId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider: 'github',
account: ghAdapter.accountInfo!,
});
this.providerAuthRestoreState.github = null;
} catch (error) {
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw error;
}
if (error instanceof Error && error.message.includes('auth superseded')) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
this.resetProviderStatus('github', authAttemptId);
throw error;
}
this.resetProviderStatus('github', authAttemptId);
this.setProviderError('github', String(error));
throw error;
}
}
export async function completePKCEAuthImpl(this: any,
provider: 'google' | 'onedrive',
code: string,
redirectUri: string,
authAttemptId?: number
): Promise<void> {
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
const adapter = this.adapters.get(provider);
if (!adapter) {
throw new Error(`${provider} adapter not initialized`);
}
try {
// Snapshot the prior account BEFORE we overwrite providers[provider].
// Used as a fallback for the same-account comparison when the persisted
// accountId key is absent (e.g., first re-auth after upgrading to this
// version, where the key didn't exist yet).
const previousAccount = this.state.providers[provider]?.account;
let tokens: OAuthTokens;
let account;
if (provider === 'google') {
const gdAdapter = adapter as GoogleDriveAdapter;
tokens = await gdAdapter.completeAuth(code, redirectUri);
account = gdAdapter.accountInfo;
} else {
const odAdapter = adapter as OneDriveAdapter;
tokens = await odAdapter.completeAuth(code, redirectUri);
account = odAdapter.accountInfo;
}
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
const resourceId = await adapter.initializeSync();
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
status: 'connected',
tokens,
account: account || undefined,
};
if (resourceId) {
this.state.providers[provider].resourceId = resourceId;
}
await this.saveProviderConnection(provider, this.state.providers[provider], authAttemptId);
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. Same-account re-auth preserves the base
// so the next sync computes correct local-deletions instead of treating
// it as "first sync" and resurrecting zombie entries via null-base union.
const newId = account?.id ?? null;
const previousId = this.loadProviderAccountId(provider) ?? previousAccount?.id ?? null;
const sameAccount = newId !== null && previousId !== null && newId === previousId;
if (!sameAccount) {
clearProviderMergeStateImpl.call(this, provider);
}
if (newId) {
this.saveProviderAccountId(provider, newId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider,
account: account!,
});
this.providerAuthRestoreState[provider] = null;
} catch (error) {
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw error;
}
if (error instanceof Error && error.message.includes('auth superseded')) {
throw error;
}
this.resetProviderStatus(provider, authAttemptId);
this.setProviderError(provider, String(error));
throw error;
}
}
export async function connectConfigProviderImpl(this: any,
provider: 'webdav' | 's3',
config: WebDAVConfig | S3Config
): Promise<void> {
const adapter = await createAdapter(provider, undefined, undefined, config);
this.adapters.set(provider, adapter);
this.updateProviderStatus(provider, 'connecting');
try {
const resourceId = await adapter.initializeSync();
const account = adapter.accountInfo || this.buildAccountFromConfig(provider, config);
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
provider,
status: 'connected',
config,
account,
resourceId: resourceId || undefined,
};
await this.saveProviderConnection(provider, this.state.providers[provider]);
// Clear all trusted merge state when changing endpoint or bucket.
clearProviderMergeStateImpl.call(this, provider);
this.emit({
type: 'AUTH_COMPLETED',
provider,
account,
});
} catch (error) {
this.updateProviderStatus(provider, 'error', String(error));
throw error;
}
}
/**
* Connect a namespaced plugin sync Provider. Configuration is opaque plugin-
* owned JSON; Netcatty still owns encryption and only forwards encrypted objects.
*/
export async function connectPluginProviderImpl(
this: any,
providerId: string,
configuration: unknown = {},
credential?: unknown,
): Promise<void> {
if (!isPluginCloudProviderId(providerId)) {
throw new Error(`Invalid plugin sync provider ID: ${providerId}`);
}
// Initialize sequence counters before the first status write so cross-window
// handlers never see NaN and discard every later update.
if (this.providerWriteSeq[providerId] == null || Number.isNaN(this.providerWriteSeq[providerId])) {
this.providerWriteSeq[providerId] = 0;
}
if (this.providerDecryptSeq[providerId] == null || Number.isNaN(this.providerDecryptSeq[providerId])) {
this.providerDecryptSeq[providerId] = 0;
}
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
if (this.providerAuthAttemptSeq[providerId] == null || Number.isNaN(this.providerAuthAttemptSeq[providerId])) {
this.providerAuthAttemptSeq[providerId] = 0;
}
this.updateProviderStatus(providerId, 'connecting');
try {
const createPluginStorage = async (id: string) => {
if (typeof this.createPluginStorage === 'function') {
return this.createPluginStorage(id, {
provider: id,
status: 'connecting',
config: configuration as ProviderConnection['config'],
});
}
const { createPluginSyncIpcHost, isPluginSyncIpcAvailable } = await import(
'../adapters/pluginSyncIpcHost'
);
const { createPluginSyncObjectStorage } = await import('../adapters/pluginSyncObjectStorage');
if (!isPluginSyncIpcAvailable()) {
throw new Error(`Plugin sync provider ${id} is unavailable (plugin host not enabled)`);
}
return createPluginSyncObjectStorage({
providerId: id,
host: createPluginSyncIpcHost(),
configuration,
credential: credential as import('../adapters/pluginSyncObjectStorage').PluginSyncCredentialRef | undefined,
});
};
const adapter = await createAdapter(
providerId,
undefined,
undefined,
undefined,
{ createPluginStorage },
);
// Only cache after initializeSync succeeds so a rejected reconnect cannot
// leave a half-authenticated adapter pinned for later auto-sync.
let resourceId: string | null;
try {
resourceId = await adapter.initializeSync();
} catch (error) {
try { adapter.signOut(); } catch { /* ignore */ }
this.adapters.delete(providerId);
throw error;
}
this.adapters.set(providerId, adapter);
const account = adapter.accountInfo ?? { id: providerId };
if (this.providerDecryptSeq[providerId] == null) this.providerDecryptSeq[providerId] = 0;
if (this.providerWriteSeq[providerId] == null) this.providerWriteSeq[providerId] = 0;
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
++this.providerDecryptSeq[providerId];
// Capture pre-connect identity before overwriting state.
const previous = this.state.providers[providerId];
const previousAccountId = previous?.account?.id ?? null;
const previousResource = previous?.resourceId ?? null;
const configFingerprint = (value: unknown): string => {
const normalize = (input: unknown): unknown => {
if (Array.isArray(input)) return input.map(normalize);
if (input && typeof input === 'object') {
return Object.fromEntries(
Object.keys(input as Record<string, unknown>)
.sort()
.map((key) => [key, normalize((input as Record<string, unknown>)[key])]),
);
}
return input;
};
try {
return JSON.stringify(normalize(value ?? null));
} catch {
return String(value);
}
};
const previousConfigFp = configFingerprint(previous?.config ?? null);
const nextAccountId = account?.id ?? null;
const nextResource = resourceId || null;
const nextConfigFp = configFingerprint(configuration ?? null);
this.state.providers[providerId] = {
provider: providerId,
status: 'connected',
config: configuration as ProviderConnection['config'],
...((() => {
const ref = normalizeDurablePluginSyncCredentialRef(credential);
return ref ? { credential: ref } : {};
})()),
account,
resourceId: resourceId || undefined,
};
registerPluginProviderIdImpl.call(this, providerId);
await this.saveProviderConnection(providerId, this.state.providers[providerId]);
// Preserve merge base / anchors when reconnecting the same account+resource
// and configuration; clear when backend identity or config changes.
const previousCredentialFp = `${previous?.credential?.kind ?? ''}\0${previous?.credential?.id ?? ''}\0${previous?.credential?.key ?? ''}`;
const nextCredentialFp = `${this.state.providers[providerId].credential?.kind ?? ''}\0${this.state.providers[providerId].credential?.id ?? ''}\0${this.state.providers[providerId].credential?.key ?? ''}`;
if (
previousAccountId !== nextAccountId
|| previousResource !== nextResource
|| previousConfigFp !== nextConfigFp
|| previousCredentialFp !== nextCredentialFp
) {
clearProviderMergeStateImpl.call(this, providerId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider: providerId,
account,
});
this.notifyStateChange();
} catch (error) {
this.updateProviderStatus(providerId, 'error', String(error));
throw error;
}
}
export function resetProviderStatusImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
const restoreState = this.providerAuthRestoreState[provider];
if (
authAttemptId != null &&
restoreState &&
restoreState.attemptId !== authAttemptId
) {
return;
}
if (restoreState) {
this.state.providers[provider] = { ...restoreState.connection };
if (restoreState.adapter) {
this.adapters.set(provider, restoreState.adapter);
} else {
this.adapters.delete(provider);
}
this.notifyStateChange();
} else if (this.state.providers[provider]?.status === 'connecting') {
this.updateProviderStatus(provider, 'disconnected');
return;
}
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
this.providerAuthRestoreState[provider] = null;
}
}
export function setProviderErrorImpl(this: any,provider: CloudProvider, error: string): void {
this.updateProviderStatus(provider, 'error', error);
}
export function clearConnectingStatusImpl(this: any,provider: CloudProvider): void {
if (this.state.providers[provider]?.status !== 'connecting') {
return;
}
this.updateProviderStatus(provider, 'disconnected');
}
export function clearProviderErrorImpl(this: any,provider: CloudProvider): void {
const connection = this.state.providers[provider];
if (!connection?.error && connection?.status !== 'error') {
return;
}
this.state.providers[provider] = {
...connection,
status: connection.status === 'error' ? 'disconnected' : connection.status,
error: undefined,
};
this.notifyStateChange();
}
export function cancelProviderAuthAttemptImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
if (
authAttemptId != null &&
!this.isActiveAuthAttempt(provider, authAttemptId)
) {
return;
}
this.resetProviderStatus(provider, authAttemptId);
++this.providerAuthAttemptSeq[provider];
const restoreState = this.providerAuthRestoreState[provider];
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
this.providerAuthRestoreState[provider] = null;
}
}
export async function disconnectProviderImpl(this: any,provider: CloudProvider): Promise<void> {
this.cancelProviderAuthAttempt(provider);
const adapter = this.adapters.get(provider);
if (adapter) {
adapter.signOut();
this.adapters.delete(provider);
}
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
provider,
status: 'disconnected',
};
await this.saveProviderConnection(provider, this.state.providers[provider]);
// Explicit disconnect removes the dynamic provider from the restart registry.
// Missing plugins with preserved config remain registered and are not coerced away.
unregisterPluginProviderIdImpl.call(this, provider);
// Clear all trusted merge state so a later account/resource cannot reuse
// an unrelated snapshot or convergent baseline.
clearProviderMergeStateImpl.call(this, provider);
this.removeFromStorage(this.providerAccountIdKey(provider));
// Drop OS-backed sync secrets so disconnect does not leave reusable passwords.
if (isPluginCloudProviderId(provider)) {
try {
const { deletePluginSyncSecrets } = await import('../adapters/pluginSyncIpcHost');
await deletePluginSyncSecrets({ providerId: provider });
} catch {
/* best-effort; disconnect still succeeds */
}
}
// Reset BLOCKED state if it was present — disconnect implicitly resolves
// any pending shrink-block warning since there's no provider to push to.
this.exitBlockedState();
if (this.state.syncState === 'BLOCKED') {
this.state.syncState = 'IDLE';
}
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
}
export function updateProviderStatusImpl(this: any,
provider: CloudProvider,
status: ProviderConnection['status'],
error?: string
): void {
// Bump sequence to invalidate any in-flight async decrypt for this provider
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
status,
error,
};
this.notifyStateChange(); // Notify UI of status change
}
export function isActiveAuthAttemptImpl(this: any,provider: CloudProvider, authAttemptId: number): boolean {
return this.providerAuthAttemptSeq[provider] === authAttemptId;
}
export function buildAccountFromConfigImpl(this: any,
provider: 'webdav' | 's3',
config: WebDAVConfig | S3Config
): ProviderAccount {
if (provider === 'webdav') {
const endpoint = (config as WebDAVConfig).endpoint;
return { id: endpoint, name: endpoint };
}
const s3 = config as S3Config;
return { id: `${s3.bucket}@${s3.endpoint}`, name: `${s3.bucket} (${s3.region})` };
}
export function syncAnchorKeyImpl(this: any,provider: CloudProvider): string {
return `${SYNC_REMOTE_ANCHOR_STORAGE_KEY}_${provider}`;
}
export function createSyncedFileSignatureImpl(this: any,syncedFile: SyncedFile | null): Promise<string | null> {
return createSyncedFileSignatureCore(syncedFile);
}
export function loadSyncAnchorImpl(this: any,provider: CloudProvider): ProviderSyncAnchor | null {
return this.loadFromStorage<ProviderSyncAnchor>(this.syncAnchorKey(provider));
}
export async function saveSyncAnchorImpl(this: any,
provider: CloudProvider,
syncedFile: SyncedFile | null,
resourceId?: string | null,
): Promise<void> {
this.saveToStorage(this.syncAnchorKey(provider), {
signature: await this.createSyncedFileSignature(syncedFile),
version: syncedFile?.meta.version ?? 0,
updatedAt: syncedFile?.meta.updatedAt ?? 0,
deviceId: syncedFile?.meta.deviceId,
resourceId: resourceId ?? this.state.providers[provider].resourceId ?? null,
observedAt: Date.now(),
} satisfies ProviderSyncAnchor);
}
export function clearSyncAnchorImpl(this: any,provider?: CloudProvider): void {
if (provider) {
this.removeFromStorage(this.syncAnchorKey(provider));
return;
}
const providers = new Set<CloudProvider>([
'github', 'google', 'onedrive', 'webdav', 's3',
]);
for (const id of Object.keys(this.state?.providers ?? {})) {
providers.add(id as CloudProvider);
}
if (typeof this.listRegisteredPluginProviderIds === 'function') {
for (const id of this.listRegisteredPluginProviderIds()) {
providers.add(id as CloudProvider);
}
}
for (const p of providers) {
this.removeFromStorage(this.syncAnchorKey(p));
}
}
export async function inspectProviderRemoteStateImpl(this: any,
provider: CloudProvider,
adapter: CloudAdapter,
): Promise<{
remoteChanged: boolean;
remoteFile: SyncedFile | null;
error?: string;
}> {
try {
const remoteFile = await adapter.download();
const currentSignature = await this.createSyncedFileSignature(remoteFile);
const anchor = this.loadSyncAnchor(provider);
const currentResourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
const decision = decideRemoteChanged({
currentSignature,
currentResourceId,
anchor,
hasRemoteFile: Boolean(remoteFile),
});
return {
remoteChanged: decision.remoteChanged,
remoteFile,
};
} catch (error) {
// A dead OneDrive refresh token surfaces here during sync preflight,
// syncAll preflight, and startup inspection. Clear the stale credentials
// so the provider drops to a reconnect state instead of being retried.
if (typeof this.handleProviderReauthRequired === 'function') {
this.handleProviderReauthRequired(provider, error);
}
return {
remoteChanged: false,
remoteFile: null,
error: String(error),
};
}
}
export async function checkProviderConflictImpl(this: any,
provider: CloudProvider,
adapter: CloudAdapter
): Promise<{
conflict: boolean;
remoteFile?: SyncedFile;
}> {
const inspection = await this.inspectProviderRemoteState(provider, adapter);
if (inspection.error) {
throw new Error(inspection.error);
}
return {
conflict: inspection.remoteChanged && Boolean(inspection.remoteFile),
remoteFile: inspection.remoteFile ?? undefined,
};
}
export async function inspectProviderRemoteImpl(this: any,provider: CloudProvider): Promise<{
remoteChanged: boolean;
remoteFile: SyncedFile | null;
payload: SyncPayload | null;
}> {
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
throw new Error('Vault is locked');
}
const adapter = await this.getConnectedAdapter(provider);
const inspection = await this.inspectProviderRemoteState(provider, adapter);
if (inspection.error) {
throw new Error(inspection.error);
}
if (!inspection.remoteFile) {
return {
remoteChanged: inspection.remoteChanged,
remoteFile: null,
payload: null,
};
}
return {
remoteChanged: inspection.remoteChanged,
remoteFile: inspection.remoteFile,
payload: await EncryptionService.decryptPayload(inspection.remoteFile, this.masterPassword),
};
}
export async function commitRemoteInspectionImpl(this: any,
provider: CloudProvider,
remoteFile: SyncedFile,
payload: SyncPayload,
opts: { recordDownload?: boolean } = {},
): Promise<void> {
const adapter = await this.getConnectedAdapter(provider);
const resourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
if (resourceId && this.state.providers[provider].resourceId !== resourceId) {
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
resourceId,
};
}
this.state.localVersion = remoteFile.meta.version;
this.state.localUpdatedAt = remoteFile.meta.updatedAt;
this.state.remoteVersion = remoteFile.meta.version;
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
this.state.providers[provider].lastSync = Date.now();
this.state.providers[provider].lastSyncVersion = remoteFile.meta.version;
await this.saveSyncBase(payload, provider);
this.saveSyncConfig();
await this.saveSyncAnchor(provider, remoteFile, resourceId);
await this.saveProviderConnection(provider, this.state.providers[provider]);
if (opts.recordDownload === true) {
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'download',
success: true,
localVersion: remoteFile.meta.version,
remoteVersion: remoteFile.meta.version,
deviceName: remoteFile.meta.deviceName,
});
}
this.notifyStateChange();
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createConvergentSyncStateFromPayload } from '../../../domain/convergentSync/index.ts';
import { SYNC_STORAGE_KEYS, type MasterKeyConfig, type SyncPayload } from '../../../domain/sync.ts';
import {
loadConvergentReplicaImpl,
reencryptSyncStorageImpl,
saveConvergentReplicaImpl,
} from './convergentSyncStorageMethods.ts';
import {
decryptLocalStorageValue,
encryptLocalStorageValue,
} from './encryptedLocalStorage.ts';
import { EncryptionService } from '../EncryptionService.ts';
import { changeMasterKeyImpl } from './stateAndSecurityMethods.ts';
const NOW = 1_700_000_000_000;
function payload(): SyncPayload {
return {
hosts: [],
keys: [],
snippets: [],
customGroups: [],
syncedAt: NOW,
};
}
async function key(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
}
function manager(storage: Map<string, unknown>, derivedKey: CryptoKey) {
return {
state: { unlockedKey: { derivedKey } },
loadFromStorage(storageKey: string) {
return storage.get(storageKey) ?? null;
},
saveToStorage(storageKey: string, value: unknown) {
storage.set(storageKey, value);
},
removeFromStorage(storageKey: string) {
storage.delete(storageKey);
},
syncBaseKey(provider?: string) {
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${provider ? `_${provider}` : ''}`;
},
syncSnapshotsKey(provider?: string) {
return `netcatty_sync_snapshots_v1${provider ? `_${provider}` : ''}`;
},
convergentProviderBaselineKey(provider: string) {
return `${SYNC_STORAGE_KEYS.CONVERGENT_PROVIDER_BASELINE}_${provider}`;
},
};
}
test('canonical replica records are encrypted and fail closed when corrupted', async () => {
const storage = new Map<string, unknown>();
const encryptionKey = await key();
const subject = manager(storage, encryptionKey);
const state = createConvergentSyncStateFromPayload(payload(), 'device-a', NOW);
await saveConvergentReplicaImpl.call(subject, { schemaVersion: 2, state, updatedAt: NOW });
assert.equal(typeof storage.get(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA), 'string');
assert.equal((await loadConvergentReplicaImpl.call(subject))?.updatedAt, NOW);
storage.set(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA, 'not-valid-ciphertext');
await assert.rejects(() => loadConvergentReplicaImpl.call(subject));
});
test('master key rotation re-encrypts all sync records before committing config', async () => {
const storage = new Map<string, unknown>();
const oldKey = await key();
const newKey = await key();
const subject = manager(storage, oldKey);
const baseKey = subject.syncBaseKey('github');
storage.set(baseKey, await encryptLocalStorageValue({ secret: 'value' }, oldKey));
const oldConfig: MasterKeyConfig = {
verificationHash: 'old',
salt: 'old-salt',
kdf: 'PBKDF2',
createdAt: NOW,
};
const newConfig: MasterKeyConfig = { ...oldConfig, verificationHash: 'new', salt: 'new-salt' };
storage.set(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, oldConfig);
await reencryptSyncStorageImpl.call(subject, oldKey, newKey, newConfig);
const encoded = storage.get(baseKey) as string;
assert.deepEqual(await decryptLocalStorageValue(encoded, newKey), { secret: 'value' });
await assert.rejects(() => decryptLocalStorageValue(encoded, oldKey));
assert.deepEqual(storage.get(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG), newConfig);
});
test('master key rotation rolls ciphertext back when the config commit fails', async () => {
const storage = new Map<string, unknown>();
const oldKey = await key();
const newKey = await key();
const subject = manager(storage, oldKey);
const baseKey = subject.syncBaseKey('github');
const original = await encryptLocalStorageValue({ secret: 'value' }, oldKey);
storage.set(baseKey, original);
const oldConfig: MasterKeyConfig = {
verificationHash: 'old',
salt: 'old-salt',
kdf: 'PBKDF2',
createdAt: NOW,
};
storage.set(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, oldConfig);
let failConfigWrite = true;
subject.saveToStorage = (storageKey: string, value: unknown) => {
if (storageKey === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG && failConfigWrite) {
failConfigWrite = false;
throw new Error('quota');
}
storage.set(storageKey, value);
};
await assert.rejects(
() => reencryptSyncStorageImpl.call(subject, oldKey, newKey, { ...oldConfig, salt: 'new' }),
/quota/,
);
assert.equal(storage.get(baseKey), original);
assert.deepEqual(storage.get(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG), oldConfig);
assert.deepEqual(await decryptLocalStorageValue(original, oldKey), { secret: 'value' });
});
test('master key rotation aborts when an initially absent sync record appears', async () => {
const storage = new Map<string, unknown>();
const oldKey = await key();
const newKey = await key();
const subject = manager(storage, oldKey);
const oldConfig: MasterKeyConfig = {
verificationHash: 'old',
salt: 'old-salt',
kdf: 'PBKDF2',
createdAt: NOW,
};
const newConfig: MasterKeyConfig = { ...oldConfig, verificationHash: 'new' };
const appearedKey = subject.convergentProviderBaselineKey('github');
const appearedCiphertext = await encryptLocalStorageValue({ created: 'concurrently' }, oldKey);
storage.set(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, oldConfig);
const loadFromStorage = subject.loadFromStorage.bind(subject);
let appearedReads = 0;
subject.loadFromStorage = (storageKey: string) => {
if (storageKey === appearedKey) {
appearedReads += 1;
if (appearedReads === 1) return null;
storage.set(appearedKey, appearedCiphertext);
return appearedCiphertext;
}
return loadFromStorage(storageKey);
};
await assert.rejects(
() => reencryptSyncStorageImpl.call(subject, oldKey, newKey, newConfig),
/Sync data changed while the master key was being rotated/,
);
assert.deepEqual(storage.get(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG), oldConfig);
assert.equal(storage.get(appearedKey), appearedCiphertext);
assert.deepEqual(await decryptLocalStorageValue(appearedCiphertext, oldKey), {
created: 'concurrently',
});
});
test('master key state changes only after derived-key records are re-encrypted', async () => {
const oldChange = EncryptionService.changeMasterPassword;
const oldUnlock = EncryptionService.unlockMasterKey;
const oldConfig: MasterKeyConfig = {
verificationHash: 'old',
salt: 'old-salt',
kdf: 'PBKDF2',
createdAt: NOW,
};
const newConfig: MasterKeyConfig = { ...oldConfig, verificationHash: 'new', salt: 'new-salt' };
const oldKey = await key();
const newKey = await key();
const calls: string[] = [];
EncryptionService.changeMasterPassword = async () => newConfig;
EncryptionService.unlockMasterKey = async (password) => ({
derivedKey: password === 'old-password' ? oldKey : newKey,
salt: new Uint8Array(),
unlockedAt: NOW,
});
try {
const subject = {
state: {
masterKeyConfig: oldConfig,
securityState: 'UNLOCKED',
unlockedKey: { derivedKey: oldKey },
autoSyncEnabled: false,
},
masterPassword: 'old-password',
reencryptSyncStorage: async () => {
calls.push('reencrypt');
assert.equal(subject.state.masterKeyConfig, oldConfig);
assert.equal(subject.masterPassword, 'old-password');
},
bumpSyncSecurityGeneration: () => calls.push('generation'),
emit: () => calls.push('emit'),
};
const changed = await changeMasterKeyImpl.call(subject, 'old-password', 'new-password');
assert.equal(changed, true);
assert.deepEqual(calls, ['reencrypt', 'generation', 'emit']);
assert.equal(subject.state.masterKeyConfig, newConfig);
assert.equal(subject.state.unlockedKey.derivedKey, newKey);
assert.equal(subject.masterPassword, 'new-password');
} finally {
EncryptionService.changeMasterPassword = oldChange;
EncryptionService.unlockMasterKey = oldUnlock;
}
});
test('failed local re-encryption leaves the active master key unchanged', async () => {
const oldChange = EncryptionService.changeMasterPassword;
const oldUnlock = EncryptionService.unlockMasterKey;
const oldConfig: MasterKeyConfig = {
verificationHash: 'old',
salt: 'old-salt',
kdf: 'PBKDF2',
createdAt: NOW,
};
const oldKey = await key();
EncryptionService.changeMasterPassword = async () => ({ ...oldConfig, verificationHash: 'new' });
EncryptionService.unlockMasterKey = async () => ({
derivedKey: oldKey,
salt: new Uint8Array(),
unlockedAt: NOW,
});
try {
const subject = {
state: {
masterKeyConfig: oldConfig,
securityState: 'UNLOCKED',
unlockedKey: { derivedKey: oldKey },
autoSyncEnabled: false,
},
masterPassword: 'old-password',
reencryptSyncStorage: async () => {
throw new Error('storage failed');
},
};
await assert.rejects(
() => changeMasterKeyImpl.call(subject, 'old-password', 'new-password'),
/storage failed/,
);
assert.equal(subject.state.masterKeyConfig, oldConfig);
assert.equal(subject.masterPassword, 'old-password');
assert.equal(subject.state.unlockedKey.derivedKey, oldKey);
} finally {
EncryptionService.changeMasterPassword = oldChange;
EncryptionService.unlockMasterKey = oldUnlock;
}
});

View File

@@ -0,0 +1,196 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
SYNC_STORAGE_KEYS,
type CloudProvider,
type ConvergentProviderBaselineV2,
type ConvergentReplicaRecordV2,
type MasterKeyConfig,
} from '../../../domain/sync';
import {
assertValidConvergentSyncState,
canonicalizeConvergentSyncState,
stripConvergentSyncEnvelope,
} from '../../../domain/convergentSync';
import {
decryptLocalStorageValue,
encryptLocalStorageValue,
} from './encryptedLocalStorage';
/** Built-ins plus any dynamic plugin providers currently in manager state. */
function listedProviders(manager: any): CloudProvider[] {
const fromState = Object.keys(manager?.state?.providers ?? {}) as CloudProvider[];
if (fromState.length > 0) return fromState.sort();
return ['github', 'google', 'onedrive', 'webdav', 's3'];
}
export function convergentProviderBaselineKeyImpl(this: any, provider: CloudProvider): string {
return `${SYNC_STORAGE_KEYS.CONVERGENT_PROVIDER_BASELINE}_${provider}`;
}
function requireLocalEncryptionKey(manager: any): CryptoKey {
const key = manager.state.unlockedKey?.derivedKey;
if (!key) throw new Error('Convergent sync encryption key is unavailable');
return key;
}
export async function saveConvergentReplicaImpl(
this: any,
record: ConvergentReplicaRecordV2,
): Promise<void> {
if (record.schemaVersion !== 2) throw new Error('Unsupported convergent replica schema');
assertValidConvergentSyncState(record.state);
const normalized: ConvergentReplicaRecordV2 = {
schemaVersion: 2,
state: canonicalizeConvergentSyncState(record.state),
updatedAt: record.updatedAt,
};
if (this.saveToStorage(
SYNC_STORAGE_KEYS.CONVERGENT_REPLICA,
await encryptLocalStorageValue(normalized, requireLocalEncryptionKey(this)),
) === false) throw new Error('Unable to persist convergent sync replica');
}
export async function loadConvergentReplicaImpl(this: any): Promise<ConvergentReplicaRecordV2 | null> {
const encoded = this.loadFromStorage(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA) as unknown;
if (!encoded) return null;
if (typeof encoded !== 'string') throw new Error('Convergent replica record is invalid');
const record = await decryptLocalStorageValue<ConvergentReplicaRecordV2>(
encoded,
requireLocalEncryptionKey(this),
);
if (record?.schemaVersion !== 2 || !Number.isFinite(record.updatedAt)) {
throw new Error('Convergent replica record has an unsupported schema');
}
assertValidConvergentSyncState(record.state);
return {
schemaVersion: 2,
state: canonicalizeConvergentSyncState(record.state),
updatedAt: record.updatedAt,
};
}
export async function saveConvergentProviderBaselineImpl(
this: any,
baseline: ConvergentProviderBaselineV2,
): Promise<void> {
if (baseline.schemaVersion !== 2) throw new Error('Unsupported convergent baseline schema');
assertValidConvergentSyncState(baseline.state);
const normalized: ConvergentProviderBaselineV2 = {
...baseline,
materializedPayload: stripConvergentSyncEnvelope(baseline.materializedPayload),
state: canonicalizeConvergentSyncState(baseline.state),
};
if (this.saveToStorage(
this.convergentProviderBaselineKey(baseline.provider),
await encryptLocalStorageValue(normalized, requireLocalEncryptionKey(this)),
) === false) throw new Error(`Unable to persist convergent baseline for ${baseline.provider}`);
}
export async function loadConvergentProviderBaselineImpl(
this: any,
provider: CloudProvider,
): Promise<ConvergentProviderBaselineV2 | null> {
const encoded = this.loadFromStorage(this.convergentProviderBaselineKey(provider)) as unknown;
if (!encoded) return null;
if (typeof encoded !== 'string') throw new Error(`Convergent baseline for ${provider} is invalid`);
const baseline = await decryptLocalStorageValue<ConvergentProviderBaselineV2>(
encoded,
requireLocalEncryptionKey(this),
);
if (baseline?.schemaVersion !== 2 || baseline.provider !== provider) {
throw new Error(`Convergent baseline for ${provider} has an unsupported schema`);
}
assertValidConvergentSyncState(baseline.state);
return {
...baseline,
materializedPayload: stripConvergentSyncEnvelope(baseline.materializedPayload),
state: canonicalizeConvergentSyncState(baseline.state),
};
}
export function clearConvergentSyncStorageImpl(this: any, confirmed: boolean): void {
if (!confirmed) throw new Error('Explicit confirmation is required to remove convergent sync state');
this.removeFromStorage(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA);
for (const provider of listedProviders(this)) {
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
}
}
function encryptedSyncStorageKeys(manager: any): string[] {
const keys = new Set<string>([
manager.syncBaseKey(),
manager.syncSnapshotsKey(),
SYNC_STORAGE_KEYS.CONVERGENT_REPLICA,
]);
for (const provider of listedProviders(manager)) {
keys.add(manager.syncBaseKey(provider));
keys.add(manager.syncSnapshotsKey(provider));
keys.add(manager.convergentProviderBaselineKey(provider));
}
return [...keys];
}
/**
* Re-encrypt every derived-key local record before committing the new master
* configuration. Values are prepared first, concurrent changes abort the
* transaction, and any write failure rolls all keys back to their exact prior
* ciphertext.
*/
export async function reencryptSyncStorageImpl(
this: any,
oldKey: CryptoKey,
newKey: CryptoKey,
newConfig: MasterKeyConfig,
): Promise<void> {
const keys = encryptedSyncStorageKeys(this);
const previousConfig = (
this.loadFromStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG)
?? this.state.masterKeyConfig
) as MasterKeyConfig | null;
const originals = new Map<string, string | null>();
const replacements = new Map<string, string>();
for (const key of keys) {
const encoded = this.loadFromStorage(key) as unknown;
if (encoded == null) {
originals.set(key, null);
continue;
}
if (typeof encoded !== 'string') throw new Error(`Encrypted sync record ${key} is invalid`);
originals.set(key, encoded);
const value = await decryptLocalStorageValue<unknown>(encoded, oldKey);
replacements.set(key, await encryptLocalStorageValue(value, newKey));
}
for (const [key, original] of originals) {
const current = this.loadFromStorage(key) as unknown;
if ((current ?? null) !== original) {
throw new Error('Sync data changed while the master key was being rotated');
}
}
const currentConfig = this.loadFromStorage(
SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG,
) as MasterKeyConfig | null;
if (JSON.stringify(currentConfig) !== JSON.stringify(previousConfig)) {
throw new Error('Master key configuration changed while it was being rotated');
}
const committed: string[] = [];
try {
for (const [key, replacement] of replacements) {
if (this.saveToStorage(key, replacement) === false) {
throw new Error(`Unable to persist re-encrypted sync record: ${key}`);
}
committed.push(key);
}
if (this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, newConfig) === false) {
throw new Error('Unable to persist the new master key configuration');
}
} catch (error) {
for (const key of committed.reverse()) {
const original = originals.get(key);
if (original !== undefined) this.saveToStorage(key, original);
}
if (previousConfig) this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, previousConfig);
else this.removeFromStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG);
throw error;
}
}

View File

@@ -0,0 +1,26 @@
export async function encryptLocalStorageValue(value: unknown, key: CryptoKey): Promise<string> {
const data = new TextEncoder().encode(JSON.stringify(value));
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
let binary = '';
const chunkSize = 8192;
for (let offset = 0; offset < combined.length; offset += chunkSize) {
binary += String.fromCharCode(...combined.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
export async function decryptLocalStorageValue<T>(
encoded: string,
key: CryptoKey,
): Promise<T> {
const combined = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
if (combined.length <= 12) throw new Error('Encrypted local sync record is truncated');
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return JSON.parse(new TextDecoder().decode(decrypted)) as T;
}

View File

@@ -0,0 +1,104 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SYNC_STORAGE_KEYS } from '../../../domain/sync.ts';
import { loadInitialStateImpl, loadProviderConnectionImpl } from './stateAndSecurityMethods.ts';
test('startup does not eagerly write SYNC_PREFERENCES over a concurrent autoSync=false', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.DEVICE_ID, 'test-device');
storage.set(SYNC_STORAGE_KEYS.DEVICE_NAME, 'Test Device');
// Legacy combined blob from pre-split builds.
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
autoSync: true,
interval: 5,
syncStrategy: 'smartMerge',
localVersion: 2,
localUpdatedAt: 100,
remoteVersion: 2,
remoteUpdatedAt: 100,
});
let preferenceReads = 0;
const manager = {
providerWriteSeq: {} as Record<string, number>,
providerDecryptSeq: {} as Record<string, number>,
providerDecrypted: {} as Record<string, boolean>,
providerAuthAttemptSeq: {} as Record<string, number>,
providerAuthRestoreState: {} as Record<string, unknown>,
loadFromStorage(key: string) {
if (key === SYNC_STORAGE_KEYS.SYNC_PREFERENCES) {
preferenceReads += 1;
// Concurrent window already wrote autoSync=false before this
// process's preference read.
return {
autoSync: false,
interval: 5,
syncStrategy: 'smartMerge',
};
}
return storage.get(key) ?? null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
return true;
},
loadProviderConnection(provider: string) {
return loadProviderConnectionImpl.call(this, provider);
},
};
const state = loadInitialStateImpl.call(manager);
assert.equal(
storage.has(SYNC_STORAGE_KEYS.SYNC_PREFERENCES),
false,
'must not eagerly write SYNC_PREFERENCES on startup',
);
assert.equal(preferenceReads, 1);
assert.equal(state.autoSyncEnabled, false);
});
test('startup adopts legacy SYNC_CONFIG prefs without writing SYNC_PREFERENCES', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.DEVICE_ID, 'test-device');
storage.set(SYNC_STORAGE_KEYS.DEVICE_NAME, 'Test Device');
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
autoSync: true,
interval: 15,
syncStrategy: 'preferCloud',
localVersion: 1,
localUpdatedAt: 1,
remoteVersion: 1,
remoteUpdatedAt: 1,
});
const manager = {
providerWriteSeq: {} as Record<string, number>,
providerDecryptSeq: {} as Record<string, number>,
providerDecrypted: {} as Record<string, boolean>,
providerAuthAttemptSeq: {} as Record<string, number>,
providerAuthRestoreState: {} as Record<string, unknown>,
loadFromStorage(key: string) {
return storage.get(key) ?? null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
return true;
},
loadProviderConnection(provider: string) {
return loadProviderConnectionImpl.call(this, provider);
},
};
const state = loadInitialStateImpl.call(manager);
assert.equal(
storage.has(SYNC_STORAGE_KEYS.SYNC_PREFERENCES),
false,
'startup must not eagerly migrate preferences to disk',
);
assert.equal(state.autoSyncEnabled, true);
assert.equal(state.autoSyncInterval, 15);
assert.equal(state.syncStrategy, 'preferCloud');
});

View File

@@ -0,0 +1,263 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
attachTokenRefreshPersistence,
handleProviderReauthRequiredImpl,
persistRefreshedProviderTokensImpl,
} from './stateAndSecurityMethods.ts';
import { inspectProviderRemoteStateImpl } from './authMethods.ts';
import {
ONEDRIVE_REAUTH_REQUIRED_MARKER,
isProviderReadyForSync,
type OAuthTokens,
type ProviderConnection,
} from '../../../domain/sync.ts';
const newTokens = (): OAuthTokens => ({
accessToken: 'fresh-access',
refreshToken: 'rotated-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
});
function createManager() {
const saved: Array<{ provider: string; tokens?: OAuthTokens }> = [];
let notified = 0;
const manager = {
providerDecryptSeq: { onedrive: 0, google: 0 } as Record<string, number>,
adapters: new Map<string, { signOut: () => void }>(),
state: {
providers: {
onedrive: {
provider: 'onedrive',
status: 'connected',
tokens: { accessToken: 'old', refreshToken: 'old-refresh', tokenType: 'Bearer' },
account: { id: 'u1' },
resourceId: 'file-1',
} as ProviderConnection,
} as Record<string, ProviderConnection>,
},
saveProviderConnection: async (provider: string, connection: { tokens?: OAuthTokens }) => {
saved.push({ provider, tokens: connection.tokens });
},
notifyStateChange: () => {
notified += 1;
},
};
return { manager, saved, getNotified: () => notified };
}
test('persistRefreshedProviderTokens updates state, persists, and notifies', async () => {
const { manager, saved, getNotified } = createManager();
const tokens = newTokens();
persistRefreshedProviderTokensImpl.call(manager, 'onedrive', tokens);
// saveProviderConnection is fire-and-forget; let microtasks flush.
await Promise.resolve();
assert.deepEqual(manager.state.providers.onedrive.tokens, tokens);
// Other fields are preserved.
assert.equal(manager.state.providers.onedrive.account?.id, 'u1');
assert.equal(manager.state.providers.onedrive.resourceId, 'file-1');
assert.equal(manager.state.providers.onedrive.status, 'connected');
assert.equal(saved.length, 1);
assert.equal(saved[0].provider, 'onedrive');
assert.deepEqual(saved[0].tokens, tokens);
// Decrypt sequence is bumped so an in-flight decrypt cannot clobber the write.
assert.equal(manager.providerDecryptSeq.onedrive, 1);
assert.equal(getNotified(), 1);
});
test('persistRefreshedProviderTokens is a no-op when the provider was disconnected', async () => {
const { manager, saved } = createManager();
// Simulate a disconnect happening during the async refresh.
manager.state.providers.onedrive = { provider: 'onedrive', status: 'disconnected' };
persistRefreshedProviderTokensImpl.call(manager, 'onedrive', newTokens());
await Promise.resolve();
assert.equal(saved.length, 0);
assert.equal(manager.state.providers.onedrive.tokens, undefined);
});
test('attachTokenRefreshPersistence wires adapters that expose setOnTokensRefreshed', () => {
const { manager, saved } = createManager();
let registered: ((tokens: OAuthTokens) => void) | null = null;
const adapter = {
setOnTokensRefreshed(cb: (tokens: OAuthTokens) => void) {
registered = cb;
},
};
attachTokenRefreshPersistence.call(manager, 'onedrive', adapter as never);
assert.equal(typeof registered, 'function');
// Invoking the registered callback persists, proving the wiring is correct.
const tokens = newTokens();
registered!(tokens);
assert.equal(saved.length, 1);
assert.deepEqual(saved[0].tokens, tokens);
});
test('attachTokenRefreshPersistence is a no-op for adapters without the hook', () => {
const { manager } = createManager();
// Adapter without setOnTokensRefreshed (e.g. GitHub) must not throw.
assert.doesNotThrow(() =>
attachTokenRefreshPersistence.call(manager, 'github', {} as never),
);
});
test('attachTokenRefreshPersistence persists Google tokens refreshed mid-session', async () => {
// End-to-end: the real GoogleDriveAdapter refreshes during an operation and
// the rotated tokens reach saveProviderConnection (the regression #1208 fixed
// for OneDrive — Google previously had no setOnTokensRefreshed hook).
const { manager, saved } = createManager();
manager.providerDecryptSeq.google = 0;
manager.state.providers.google = {
provider: 'google',
status: 'connected',
tokens: { accessToken: 'old', refreshToken: 'google-refresh', tokenType: 'Bearer' },
account: { id: 'g1' },
resourceId: 'gfile-1',
} as ProviderConnection;
const g = globalThis as typeof globalThis & { window?: unknown };
const originalWindow = g.window;
g.window = {
netcatty: {
// Google's refresh response omits refresh_token — the adapter must carry
// the previous one forward so the persisted connection stays refreshable.
googleRefreshAccessToken: async () => ({
accessToken: 'fresh-access',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
}),
googleDriveDownloadSyncFile: async () => ({
syncedFile: { meta: { version: 1 }, payload: 'x' },
}),
},
} as unknown as Window & typeof globalThis;
try {
const { GoogleDriveAdapter } = await import('../adapters/GoogleDriveAdapter.ts');
const adapter = new GoogleDriveAdapter(
{
accessToken: 'old',
refreshToken: 'google-refresh',
// Expired so the operation forces a refresh.
expiresAt: Date.now() - 60_000,
tokenType: 'Bearer',
},
'gfile-1',
);
attachTokenRefreshPersistence.call(manager, 'google', adapter as never);
await adapter.download();
// persistRefreshedProviderTokens fires saveProviderConnection fire-and-forget.
await Promise.resolve();
assert.equal(saved.length, 1);
assert.equal(saved[0].provider, 'google');
assert.equal(saved[0].tokens?.accessToken, 'fresh-access');
// Original refresh token preserved despite the omitted refresh_token.
assert.equal(saved[0].tokens?.refreshToken, 'google-refresh');
// State updated and other fields preserved.
assert.equal(manager.state.providers.google.tokens?.accessToken, 'fresh-access');
assert.equal(manager.state.providers.google.account?.id, 'g1');
assert.equal(manager.state.providers.google.resourceId, 'gfile-1');
assert.equal(manager.providerDecryptSeq.google, 1);
} finally {
g.window = originalWindow;
}
});
test('handleProviderReauthRequired clears OneDrive tokens and stops it being sync-ready', async () => {
const { manager, saved } = createManager();
let signedOut = false;
manager.adapters.set('onedrive', { signOut: () => { signedOut = true; } });
const handled = handleProviderReauthRequiredImpl.call(
manager,
'onedrive',
new Error(
`Download failed: ${ONEDRIVE_REAUTH_REQUIRED_MARKER}: OneDrive session expired, please reconnect. (AADSTS70000)`,
),
);
await Promise.resolve();
assert.equal(handled, true);
assert.equal(signedOut, true);
// Stale adapter is evicted.
assert.equal(manager.adapters.has('onedrive'), false);
const conn = manager.state.providers.onedrive;
assert.equal(conn.tokens, undefined);
assert.equal(conn.status, 'error');
// Account is preserved for display; error message is cleaned of the marker.
assert.equal(conn.account?.id, 'u1');
assert.ok(conn.error && !conn.error.includes(ONEDRIVE_REAUTH_REQUIRED_MARKER));
assert.match(conn.error ?? '', /please reconnect/);
// Crucial: cleared tokens => not ready for sync => auto-sync won't retry.
assert.equal(isProviderReadyForSync(conn), false);
// Persisted the cleared connection.
assert.equal(saved.length, 1);
assert.equal(saved[0].tokens, undefined);
});
test('handleProviderReauthRequired ignores non-OneDrive providers and unrelated errors', () => {
const { manager } = createManager();
// Wrong provider.
assert.equal(
handleProviderReauthRequiredImpl.call(
manager,
'google',
new Error(`${ONEDRIVE_REAUTH_REQUIRED_MARKER}: x`),
),
false,
);
// OneDrive but an ordinary (retryable) error — must not clear tokens.
assert.equal(
handleProviderReauthRequiredImpl.call(manager, 'onedrive', new Error('network timeout')),
false,
);
assert.ok(manager.state.providers.onedrive.tokens);
});
test('inspectProviderRemoteState clears OneDrive tokens on a reauth-required download error', async () => {
const { manager, saved } = createManager();
// Provide the manager surface inspectProviderRemoteState touches.
Object.assign(manager, {
handleProviderReauthRequired(provider: string, error: unknown) {
return handleProviderReauthRequiredImpl.call(manager, provider as never, error);
},
createSyncedFileSignature: async () => null,
loadSyncAnchor: () => null,
});
const adapter = {
resourceId: 'file-1',
download: async () => {
throw new Error(
`Download failed: ${ONEDRIVE_REAUTH_REQUIRED_MARKER}: OneDrive session expired, please reconnect.`,
);
},
};
const result = await inspectProviderRemoteStateImpl.call(manager, 'onedrive', adapter as never);
await Promise.resolve();
// The inspection reports an error (so callers fail closed)...
assert.ok(result.error);
// ...and the dead credentials were cleared so the provider is no longer ready.
assert.equal(manager.state.providers.onedrive.tokens, undefined);
assert.equal(isProviderReadyForSync(manager.state.providers.onedrive), false);
assert.equal(saved.length, 1);
});

View File

@@ -0,0 +1,442 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { CloudProvider, ProviderConnection, SyncedFile } from '../../../domain/sync';
import { SYNC_STORAGE_KEYS } from '../../../domain/sync';
import type { EncryptedObjectStorage } from '../../../domain/encryptedObjectStorage';
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
import {
enforceLegacySingleProviderConnected,
getConnectedAdapterImpl,
listAvailablePluginSyncProviderIdsImpl,
listRegisteredPluginProviderIdsImpl,
loadInitialStateImpl,
loadProviderConnectionImpl,
registerPluginProviderIdImpl,
saveProviderConnectionImpl,
setAvailablePluginSyncProviderIdsImpl,
unregisterPluginProviderIdImpl,
} from './stateAndSecurityMethods';
function makeSyncedFile(version: number, payload = 'cipher'): SyncedFile {
return {
meta: {
version,
updatedAt: 1,
deviceId: 'device',
appVersion: '0.0.0',
iv: 'iv',
salt: 'salt',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
},
payload,
};
}
function memoryStorage(): Map<string, unknown> {
return new Map();
}
type ManagerHarness = {
adapters: Map<string, unknown>;
providerWriteSeq: Record<string, number>;
providerDecryptSeq: Record<string, number>;
providerDecrypted: Record<string, boolean>;
providerAuthAttemptSeq: Record<string, number>;
providerAuthRestoreState: Record<string, unknown>;
decryptionReady: Promise<void>;
state: { providers: Record<string, ProviderConnection> } | null;
createPluginStorage?: (id: string) => Promise<EncryptedObjectStorage>;
loadFromStorage: <T>(key: string) => T | null;
saveToStorage: (key: string, value: unknown) => boolean;
removeFromStorage: (key: string) => void;
loadProviderConnection: (provider: CloudProvider) => ProviderConnection;
notifyStateChange: () => void;
isActiveAuthAttempt: () => boolean;
};
function createManagerHarness(storage: Map<string, unknown>): ManagerHarness {
const manager: ManagerHarness = {
adapters: new Map(),
providerWriteSeq: {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
},
providerDecryptSeq: {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
},
providerDecrypted: {
github: true, google: true, onedrive: true, webdav: true, s3: true,
},
providerAuthAttemptSeq: {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
},
providerAuthRestoreState: {
github: null, google: null, onedrive: null, webdav: null, s3: null,
},
decryptionReady: Promise.resolve(),
state: null,
loadFromStorage<T>(key: string): T | null {
return (storage.has(key) ? storage.get(key) : null) as T | null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
return true;
},
removeFromStorage(key: string) {
storage.delete(key);
},
loadProviderConnection(provider: CloudProvider) {
return loadProviderConnectionImpl.call(manager, provider);
},
notifyStateChange() {},
isActiveAuthAttempt() {
return true;
},
};
return manager;
}
describe('plugin provider manager boundary', () => {
it('loads registered plugin providers into initial state and keeps them across restart', () => {
const storage = memoryStorage();
// Seed device identity so loadInitialStateImpl never touches browser globals.
storage.set(SYNC_STORAGE_KEYS.DEVICE_ID, 'test-device');
storage.set(SYNC_STORAGE_KEYS.DEVICE_NAME, 'Test Device');
const manager = createManagerHarness(storage);
registerPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
storage.set('netcatty_provider_plugin_v1:com.example.backup.sync', {
provider: 'com.example.backup.sync',
status: 'connected',
config: { endpoint: 'https://example.test' },
});
const state = loadInitialStateImpl.call(manager);
assert.ok(state.providers['com.example.backup.sync']);
// Without the plugin host IPC surface, dynamic providers keep config but
// must not join sync cycles as "connected".
assert.equal(state.providers['com.example.backup.sync'].status, 'disconnected');
assert.deepEqual(
(state.providers['com.example.backup.sync'].config as { endpoint?: string })?.endpoint,
'https://example.test',
);
assert.deepEqual(listRegisteredPluginProviderIdsImpl.call(manager), [
'com.example.backup.sync',
]);
});
it('setAvailablePluginSyncProviderIds drops missing providers from the ready set', () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
manager.state = {
providers: {
'com.example.backup.sync': {
provider: 'com.example.backup.sync',
status: 'connected',
config: { endpoint: 'https://example.test' },
},
},
};
manager.adapters = new Map();
registerPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
assert.deepEqual(listAvailablePluginSyncProviderIdsImpl.call(manager), [
'com.example.backup.sync',
]);
// Plugin uninstalled / contribution gone.
setAvailablePluginSyncProviderIdsImpl.call(manager, []);
assert.deepEqual(listAvailablePluginSyncProviderIdsImpl.call(manager), []);
assert.equal(manager.state.providers['com.example.backup.sync'].status, 'disconnected');
});
it('setAvailablePluginSyncProviderIds keeps adapters when availability membership is unchanged', () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
const pluginId = 'com.example.backup.sync';
let signedOut = 0;
manager.state = {
providers: {
[pluginId]: {
provider: pluginId,
status: 'connected',
config: { endpoint: 'https://example.test' },
},
},
};
manager.adapters = new Map([
[pluginId, {
isAuthenticated: true,
accountInfo: { id: 'acct' },
resourceId: null,
signOut() { signedOut += 1; },
async initializeSync() { return null; },
async upload() { return 'ok'; },
async download() { return null; },
async deleteSync() {},
getTokens() { return null; },
}],
]);
registerPluginProviderIdImpl.call(manager, pluginId);
// Same contribution still present (setting-updated noise).
setAvailablePluginSyncProviderIdsImpl.call(manager, [pluginId]);
assert.equal(manager.adapters.has(pluginId), true, 'must keep adapter on no-op membership refresh');
assert.equal(signedOut, 0);
assert.equal(manager.state.providers[pluginId].status, 'connected');
});
it('setAvailablePluginSyncProviderIds drops adapters when a provider re-enters the set', () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
const pluginId = 'com.example.backup.sync';
let signedOut = 0;
manager.state = {
providers: {
[pluginId]: {
provider: pluginId,
status: 'disconnected',
config: { endpoint: 'https://example.test' },
error: 'Plugin sync provider is no longer installed or enabled',
},
},
};
manager.adapters = new Map([
[pluginId, {
isAuthenticated: true,
accountInfo: { id: 'acct' },
resourceId: null,
signOut() { signedOut += 1; },
async initializeSync() { return null; },
async upload() { return 'ok'; },
async download() { return null; },
async deleteSync() {},
getTokens() { return null; },
}],
]);
// Not in available set yet.
setAvailablePluginSyncProviderIdsImpl.call(manager, []);
assert.equal(manager.adapters.has(pluginId), false);
// Stale adapter reattached (should not happen in prod) then re-enter.
manager.adapters.set(pluginId, {
isAuthenticated: true,
accountInfo: { id: 'acct' },
resourceId: null,
signOut() { signedOut += 1; },
async initializeSync() { return null; },
async upload() { return 'ok'; },
async download() { return null; },
async deleteSync() {},
getTokens() { return null; },
});
setAvailablePluginSyncProviderIdsImpl.call(manager, [pluginId]);
assert.equal(manager.adapters.has(pluginId), false, 'must drop adapter when provider re-enters');
assert.equal(signedOut >= 1, true);
assert.equal(manager.state.providers[pluginId].status, 'connected');
});
it('saveProviderConnection registers plugin IDs and disconnect unregisters them', async () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
manager.state = { providers: {} };
const connection: ProviderConnection = {
provider: 'com.example.backup.sync',
status: 'connected',
// Non-secret plugin config: encryptProviderSecrets is a no-op without bridge.
config: { endpoint: 'https://example.test' } as never,
};
await saveProviderConnectionImpl.call(manager, 'com.example.backup.sync', connection);
assert.deepEqual(
storage.get(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS),
['com.example.backup.sync'],
);
assert.ok(storage.get('netcatty_provider_plugin_v1:com.example.backup.sync'));
unregisterPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
assert.equal(storage.has(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS), false);
});
it('getConnectedAdapter uses createPluginStorage for namespaced provider IDs', async () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
const pluginId = 'com.example.backup.sync';
const objectStore = new Map<string, Uint8Array>();
let factoryCalls = 0;
manager.state = {
providers: {
[pluginId]: {
provider: pluginId,
status: 'connected',
config: { endpoint: 'https://example.test' },
},
},
};
manager.providerDecrypted[pluginId] = true;
manager.createPluginStorage = async (id: string): Promise<EncryptedObjectStorage> => {
factoryCalls += 1;
assert.equal(id, pluginId);
return {
providerId: id,
async connect() {
return { account: { id: 'plugin-acct' } };
},
async disconnect() {},
async getAccount() {
return { account: { id: 'plugin-acct' } };
},
async getCapabilities() {
return {
revisions: true,
conditionalWrites: true,
atomicReplacement: true,
};
},
async readObject(key: string) {
const bytes = objectStore.get(key) ?? null;
return bytes
? { found: true, key, bytes, revision: '1' }
: { found: false, key, bytes: null };
},
async writeObject(key: string, bytes: Uint8Array) {
const created = !objectStore.has(key);
objectStore.set(key, bytes);
return { created, revision: '2' };
},
async deleteObject(key: string) {
return { deleted: objectStore.delete(key) };
},
};
};
const adapter = await getConnectedAdapterImpl.call(manager, pluginId);
assert.equal(factoryCalls, 1);
// Config-backed plugin connections report authenticated for cache reuse.
assert.equal(adapter.isAuthenticated, true);
const reused = await getConnectedAdapterImpl.call(manager, pluginId);
assert.equal(reused, adapter);
assert.equal(factoryCalls, 1, 'must not recreate when cached adapter is authenticated');
await adapter.initializeSync();
assert.equal(adapter.accountInfo?.id, 'plugin-acct');
const file = makeSyncedFile(7, 'plugin-cipher');
await adapter.upload(file);
const downloaded = await adapter.download();
assert.equal(downloaded?.payload, 'plugin-cipher');
assert.equal(downloaded?.meta.version, 7);
assert.ok(objectStore.has(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY));
});
it('getConnectedAdapter accepts falsy scalar plugin configs as present', async () => {
for (const scalar of [false, 0, ''] as const) {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
const pluginId = 'com.example.scalar.sync';
let factoryCalls = 0;
manager.state = {
providers: {
[pluginId]: {
provider: pluginId,
status: 'connected',
config: scalar as never,
},
},
};
manager.providerDecrypted[pluginId] = true;
manager.createPluginStorage = async (id: string): Promise<EncryptedObjectStorage> => {
factoryCalls += 1;
assert.equal(id, pluginId);
return {
providerId: id,
async connect() {
return { account: { id: 'scalar-acct' } };
},
async disconnect() {},
async getAccount() {
return { account: { id: 'scalar-acct' } };
},
async getCapabilities() {
return {
revisions: false,
conditionalWrites: false,
atomicReplacement: true,
};
},
async readObject() {
return { found: false, key: 'x', bytes: null };
},
async writeObject() {
return { created: true, revision: '1' };
},
async deleteObject() {
return { deleted: false };
},
};
};
const adapter = await getConnectedAdapterImpl.call(manager, pluginId);
assert.equal(factoryCalls, 1);
assert.equal(adapter.isAuthenticated, true);
}
const empty = createManagerHarness(memoryStorage());
empty.state = {
providers: {
'com.example.empty.sync': {
provider: 'com.example.empty.sync',
status: 'connected',
},
},
};
empty.providerDecrypted['com.example.empty.sync'] = true;
await assert.rejects(
() => getConnectedAdapterImpl.call(empty, 'com.example.empty.sync'),
/Provider not connected/,
);
});
});
describe('createAdapter WebDAV production path', () => {
it('returns an authenticated adapter and reuses it via getConnectedAdapter cache semantics', async () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
manager.state = {
providers: {
webdav: {
provider: 'webdav',
status: 'connected',
config: {
endpoint: 'https://webdav.example.test',
authType: 'basic',
username: 'user',
password: 'secret',
},
resourceId: '/netcatty-vault.json',
},
},
};
manager.providerDecrypted.webdav = true;
const a1 = await getConnectedAdapterImpl.call(manager, 'webdav');
assert.equal(a1.isAuthenticated, true);
assert.equal(a1.resourceId, '/netcatty-vault.json');
const a2 = await getConnectedAdapterImpl.call(manager, 'webdav');
assert.equal(a1, a2, 'must reuse cached adapter when isAuthenticated is true');
});
it('enforceLegacySingleProviderConnected keeps builtin and disconnects plugin', () => {
const providers: Record<string, ProviderConnection> = {
github: { provider: 'github', status: 'connected' },
'com.example.backup.sync': {
provider: 'com.example.backup.sync',
status: 'connected',
config: { endpoint: 'https://example.test' },
},
};
enforceLegacySingleProviderConnected(providers);
assert.equal(providers.github?.status, 'connected');
assert.equal(providers['com.example.backup.sync']?.status, 'disconnected');
assert.deepEqual(
providers['com.example.backup.sync']?.config,
{ endpoint: 'https://example.test' },
);
});
});

View File

@@ -0,0 +1,779 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import packageJson from '../../../package.json';
import { EncryptionService } from '../EncryptionService';
import { mergeSyncPayloads } from '../../../domain/syncMerge';
import { stripSyncPayloadEncryptedCredentials, healPoisonedSecretsForMerge } from '../../../domain/credentials';
import { summarizeSyncChanges, withSyncReliabilityMeta } from '../../../domain/syncReliability';
import { detectSuspiciousShrink, type ShrinkFinding } from '../../../domain/syncGuards';
import { resolveCloudSyncConflictAction } from '../../../domain/syncStrategy';
import { assertConvergentSyncWriteCompatible } from '../../../domain/convergentSync';
import { getConvergentSyncLocalConfig } from '../convergentSyncConfig';
import { syncAllProvidersConvergentlyImpl } from './convergentSyncRuntimeMethods';
import type { CloudAdapter } from '../adapters';
import type GitHubAdapter from '../adapters/GitHubAdapter';
import type {
CloudProvider,
ConflictResolution,
RemoteSyncPayload,
SyncedFile,
SyncFileMeta,
SyncPayload,
SyncResult,
} from '../../../domain/sync';
import { isConditionalWriteConflictError } from '../adapters/encryptedObjectStorageBridge';
function getSyncSecurityGeneration(manager: any): number | undefined {
return typeof manager.getSyncSecurityGeneration === 'function'
? manager.getSyncSecurityGeneration()
: undefined;
}
function assertSyncSecurityGeneration(manager: any, generation?: number): void {
if (typeof manager.assertSyncSecurityGeneration === 'function') {
manager.assertSyncSecurityGeneration(generation);
}
}
/**
* Let the manager clear stale credentials when an error means a provider's
* refresh token is dead (OneDrive). Returns true when it set a reconnect state,
* so the caller skips the generic `error`-with-tokens status that would keep the
* provider "ready" and retrying. Safe no-op when the manager lacks the hook.
*/
function handleProviderReauthRequired(
manager: any,
provider: CloudProvider,
error: unknown,
): boolean {
return typeof manager.handleProviderReauthRequired === 'function'
? manager.handleProviderReauthRequired(provider, error)
: false;
}
async function uploadLocalPayloadImpl(this: any,
provider: CloudProvider,
adapter: CloudAdapter,
payload: SyncPayload,
opts: { overrideShrink?: boolean },
baseVersion: number,
remoteFile?: SyncedFile | null,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
assertConvergentSyncWriteCompatible(remoteFile?.meta, payload);
const overrideShrinkRequested = opts.overrideShrink === true;
const directBase = await this.loadSyncBase(provider);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
let directRemoteRef: SyncPayload | null = null;
if (!directBase && remoteFile) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
try {
directRemoteRef = await EncryptionService.decryptPayload(
remoteFile,
this.masterPassword,
);
} catch {
directRemoteRef = null;
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
}
const metadataBase = directBase ?? directRemoteRef;
const payloadForUpload = withSyncReliabilityMeta(payload, metadataBase, {
deviceId: this.state.deviceId,
now: Date.now(),
});
const directShrink = detectSuspiciousShrink(payloadForUpload, directBase, directRemoteRef);
const shouldBlockDirect = directShrink.suspicious && !overrideShrinkRequested;
const shouldForceDirect = directShrink.suspicious && overrideShrinkRequested;
if (shouldBlockDirect) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = directShrink;
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: directShrink });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
return {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding: directShrink,
};
}
if (shouldForceDirect) {
this.emit({ type: 'SYNC_FORCED', provider, finding: directShrink });
}
const syncedFile = await EncryptionService.encryptPayload(
payloadForUpload,
this.masterPassword,
this.state.deviceId,
this.state.deviceName,
packageJson.version,
baseVersion,
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const result = await this.uploadToProvider(provider, adapter, syncedFile, payloadForUpload, syncSecurityGeneration);
if (result.success) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
} else if (result.conflictDetected) {
// Conflict UI / CONFLICT state already set by uploadToProvider.
} else {
this.state.syncState = 'ERROR';
if (result.error) {
this.state.lastError = result.error;
}
}
return result;
}
async function downloadRemoteConflictPayloadImpl(this: any,
provider: CloudProvider,
remoteFile: SyncedFile,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
let remotePayload: SyncPayload;
assertSyncSecurityGeneration(this, syncSecurityGeneration);
try {
remotePayload = await EncryptionService.decryptPayload(
remoteFile,
this.masterPassword,
);
} catch (decryptError) {
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastError = null;
this.updateProviderStatus(provider, 'connected');
const result: SyncResult = {
success: true,
provider,
action: 'download',
version: remoteFile.meta.version,
mergedPayload: remotePayload,
remoteFile,
};
this.emit({ type: 'SYNC_COMPLETED', provider, result });
return result;
}
export async function uploadToProviderImpl(this: any,
provider: CloudProvider,
adapter: CloudAdapter,
syncedFile: SyncedFile,
payloadForBase?: SyncPayload,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
try {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const resourceId = await adapter.upload(syncedFile, {
signal: this.activeSyncAbortSignal,
});
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.state.lastError = null;
// Update local state (safe to do multiple times if values are same)
this.state.localVersion = syncedFile.meta.version;
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
this.state.remoteVersion = syncedFile.meta.version;
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
// Invalidate any pending provider decrypt so it cannot overwrite
// the lastSync/lastSyncVersion we are about to set.
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
resourceId: resourceId || this.state.providers[provider].resourceId,
lastSync: Date.now(),
lastSyncVersion: syncedFile.meta.version,
};
this.saveSyncConfig();
// Persist base BEFORE anchor so a crash between them degrades
// safely: the stale anchor forces re-inspection next run, which
// merges against the fresh base and cannot silently drift.
if (payloadForBase) {
await this.saveSyncBase(payloadForBase, provider);
}
await this.saveSyncAnchor(provider, syncedFile, resourceId);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange();
// Add to sync history
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'upload',
success: true,
localVersion: syncedFile.meta.version,
remoteVersion: syncedFile.meta.version,
deviceName: this.state.deviceName,
});
this.updateProviderStatus(provider, 'connected');
const result: SyncResult = {
success: true,
provider,
action: 'upload',
version: syncedFile.meta.version,
};
this.emit({ type: 'SYNC_COMPLETED', provider, result });
return result;
} catch (error) {
// Conditional write rejected: surface as conflict so the user can pick
// local vs remote instead of a generic sync failure.
if (isConditionalWriteConflictError(error)) {
try {
const remoteFile = await adapter.download({
signal: this.activeSyncAbortSignal,
});
if (remoteFile) {
this.state.syncState = 'CONFLICT';
this.state.currentConflict = {
provider,
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
localDeviceName: this.state.deviceName,
remoteVersion: remoteFile.meta.version,
remoteUpdatedAt: remoteFile.meta.updatedAt,
remoteDeviceName: remoteFile.meta.deviceName,
};
this.emit({
type: 'CONFLICT_DETECTED',
conflict: this.state.currentConflict,
});
// Leave the provider ready (not stuck on "syncing") while the user resolves.
this.updateProviderStatus(provider, 'connected');
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'upload',
success: false,
localVersion: this.state.localVersion,
remoteVersion: remoteFile.meta.version,
deviceName: this.state.deviceName,
error: String(error),
});
return {
success: false,
provider,
action: 'none',
conflictDetected: true,
error: String(error),
};
}
} catch {
// Fall through to generic ERROR if we cannot load remote for the UI.
}
}
this.state.lastError = String(error);
if (!handleProviderReauthRequired(this, provider, error)) {
this.updateProviderStatus(provider, 'error', String(error));
}
// Add to sync history
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'upload',
success: false,
localVersion: this.state.localVersion,
deviceName: this.state.deviceName,
error: String(error),
});
this.emit({ type: 'SYNC_ERROR', provider, error: String(error) });
return {
success: false,
provider,
action: 'none',
error: String(error),
};
}
}
export function buildPayloadImpl(this: any,data: {
hosts: SyncPayload['hosts'];
keys: SyncPayload['keys'];
proxyProfiles?: SyncPayload['proxyProfiles'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
settings?: SyncPayload['settings'];
}): SyncPayload {
return {
...data,
syncedAt: Date.now(),
};
}
export function selectConvergentSyncToProviderResult(
provider: CloudProvider,
results: Map<CloudProvider, SyncResult>,
): SyncResult {
const requested = results.get(provider) ?? {
success: false,
provider,
action: 'none',
error: 'Provider is not available for convergent sync',
} satisfies SyncResult;
if (requested.mergedPayload) return requested;
// Convergent sync fans out to every provider. A non-target provider can
// verify a newer joined replica even when the requested provider fails;
// preserve that aggregate payload so the caller can update the local vault
// before reporting the requested provider's failure.
const aggregateMergedPayload = [...results.values()]
.find((result) => result.mergedPayload);
return aggregateMergedPayload?.mergedPayload
? {
...requested,
mergedPayload: aggregateMergedPayload.mergedPayload,
...(aggregateMergedPayload.mergedPayloadApplied
? { mergedPayloadApplied: true }
: {}),
}
: requested;
}
export async function syncToProviderImpl(this: any,
provider: CloudProvider,
payload: SyncPayload,
opts: {
overrideShrink?: boolean;
applyConvergentPayload?: (
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>;
} = {},
): Promise<SyncResult> {
const convergentConfig = getConvergentSyncLocalConfig();
if (convergentConfig.initialized) {
if (!convergentConfig.enabled) {
return {
success: false,
provider,
action: 'none',
error: 'Convergent sync is paused on this device',
};
}
const results = await syncAllProvidersConvergentlyImpl.call(this, payload, {
overrideShrink: opts.overrideShrink,
applyPayload: opts.applyConvergentPayload,
});
return selectConvergentSyncToProviderResult(provider, results);
}
if (this.state.securityState !== 'UNLOCKED') {
return {
success: false,
provider,
action: 'none',
error: 'Vault is locked',
};
}
if (!this.masterPassword) {
return {
success: false,
provider,
action: 'none',
error: 'Master password not available',
};
}
const overrideShrinkRequested = opts.overrideShrink === true;
const syncSecurityGeneration = getSyncSecurityGeneration(this);
let adapter: CloudAdapter;
try {
adapter = await this.getConnectedAdapter(provider);
} catch {
return {
success: false,
provider,
action: 'none',
error: 'Provider not connected',
};
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.updateProviderStatus(provider, 'syncing');
this.state.lastError = null;
this.state.syncState = 'SYNCING';
this.emit({ type: 'SYNC_STARTED', provider });
try {
// 1. Check for conflict. `checkProviderConflict` throws on
// inspect failure, which the outer try/catch routes to the
// SYNC_ERROR path — so we never reach the upload branch with an
// unknown remote state.
const checkResult = await this.checkProviderConflict(provider, adapter);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
if (checkResult.conflict && checkResult.remoteFile) {
const conflictAction = resolveCloudSyncConflictAction(this.state.syncStrategy, {
hasConflict: checkResult.conflict,
hasRemoteFile: Boolean(checkResult.remoteFile),
});
if (conflictAction === 'download-remote') {
return await downloadRemoteConflictPayloadImpl.call(
this,
provider,
checkResult.remoteFile,
syncSecurityGeneration,
);
}
if (conflictAction === 'upload-local') {
return await uploadLocalPayloadImpl.call(
this,
provider,
adapter,
payload,
opts,
checkResult.remoteFile.meta.version,
checkResult.remoteFile,
syncSecurityGeneration,
);
}
let remotePayloadForConflict: SyncPayload | null = null;
let baseForConflict: SyncPayload | null = null;
// Remote is newer — attempt three-way merge instead of blocking
try {
let remotePayload: SyncPayload;
try {
remotePayload = await EncryptionService.decryptPayload(
checkResult.remoteFile,
this.masterPassword,
);
remotePayloadForConflict = remotePayload;
} catch (decryptError) {
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const base = await this.loadSyncBase(provider);
baseForConflict = base;
const localHealed = healPoisonedSecretsForMerge(payload, remotePayload, base);
remotePayload = healPoisonedSecretsForMerge(remotePayload, payload, base);
remotePayloadForConflict = remotePayload;
const mergeResult = mergeSyncPayloads(base, localHealed, remotePayload);
const mergedPayload = withSyncReliabilityMeta(
stripSyncPayloadEncryptedCredentials(mergeResult.payload),
base,
{
deviceId: this.state.deviceId,
now: Date.now(),
},
);
assertConvergentSyncWriteCompatible(checkResult.remoteFile.meta, mergedPayload);
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
// Shrink guard: refuse to push a merged payload that silently deletes
// entities we still have in base. The merge itself is correct if local
// state is trustworthy — but a degraded local (keychain failure,
// partial load) can make merge produce a smaller-than-expected result.
const mergedShrink = detectSuspiciousShrink(mergedPayload, base, remotePayload);
const shouldBlockMerged = mergedShrink.suspicious && !overrideShrinkRequested;
const shouldForceMerged = mergedShrink.suspicious && overrideShrinkRequested;
if (shouldBlockMerged) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = mergedShrink;
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: mergedShrink });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
return {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding: mergedShrink,
};
}
if (shouldForceMerged) {
this.emit({ type: 'SYNC_FORCED', provider, finding: mergedShrink });
}
// Encrypt and upload merged payload
const mergedSyncedFile = await EncryptionService.encryptPayload(
mergedPayload,
this.masterPassword,
this.state.deviceId,
this.state.deviceName,
packageJson.version,
checkResult.remoteFile.meta.version, // base on remote version
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const uploadResult = await this.uploadToProvider(
provider,
adapter,
mergedSyncedFile,
mergedPayload,
syncSecurityGeneration,
);
if (uploadResult.success) {
// Base was persisted inside uploadToProvider before the
// anchor advanced, so a crash between them cannot leave a
// stale base pointing at pre-merge state.
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'merge',
success: true,
localVersion: mergedSyncedFile.meta.version,
remoteVersion: checkResult.remoteFile.meta.version,
deviceName: this.state.deviceName,
});
return {
...uploadResult,
action: 'merge',
mergedPayload,
};
}
// Upload after merge failed — preserve CONFLICT when conditional write
// already surfaced a conflict; otherwise mark ERROR so we leave SYNCING.
if (!uploadResult.conflictDetected) {
this.state.syncState = 'ERROR';
this.state.lastError = uploadResult.error || 'Upload failed after merge';
}
return uploadResult;
} catch (mergeError) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
// Merge failed — fall back to conflict UI
console.error('[CloudSyncManager] Merge failed, falling back to conflict UI', mergeError);
const remoteFile = checkResult.remoteFile;
this.state.syncState = 'CONFLICT';
this.state.currentConflict = {
provider,
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
localDeviceName: this.state.deviceName,
remoteVersion: remoteFile.meta.version,
remoteUpdatedAt: remoteFile.meta.updatedAt,
remoteDeviceName: remoteFile.meta.deviceName,
...(remotePayloadForConflict
? { changeSummary: summarizeSyncChanges(baseForConflict, payload, remotePayloadForConflict) }
: {}),
};
this.emit({
type: 'CONFLICT_DETECTED',
conflict: this.state.currentConflict,
});
return {
success: false,
provider,
action: 'none',
conflictDetected: true,
};
}
}
return await uploadLocalPayloadImpl.call(
this,
provider,
adapter,
payload,
opts,
this.state.localVersion,
checkResult.remoteFile,
syncSecurityGeneration,
);
} catch (error) {
this.state.syncState = 'ERROR';
this.state.lastError = String(error);
// A dead OneDrive refresh token clears its own credentials and sets a
// clean reconnect status; only fall back to the generic error status when
// it wasn't a reauth-required failure.
if (!handleProviderReauthRequired(this, provider, error)) {
this.updateProviderStatus(provider, 'error', String(error));
}
// Add to sync history
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'upload',
success: false,
localVersion: this.state.localVersion,
deviceName: this.state.deviceName,
error: String(error),
});
this.emit({ type: 'SYNC_ERROR', provider, error: String(error) });
return {
success: false,
provider,
action: 'none',
error: String(error),
};
}
}
export async function downloadFromProviderImpl(this: any,provider: CloudProvider): Promise<RemoteSyncPayload | null> {
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
throw new Error('Vault is locked');
}
const adapter = await this.getConnectedAdapter(provider);
try {
let remoteFile: SyncedFile | null;
try {
remoteFile = await adapter.download({
signal: this.activeSyncAbortSignal,
});
} catch (downloadError) {
throw new Error(`Download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}`);
}
if (!remoteFile) {
return null;
}
// Decrypt
let payload: SyncPayload;
try {
payload = await EncryptionService.decryptPayload(remoteFile, this.masterPassword);
} catch (decryptError) {
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
}
return { provider, payload, remoteFile };
} catch (error) {
// Surface a reconnect state if the failure was a dead OneDrive refresh
// token (clears stale credentials); the error is still rethrown to the
// caller below.
handleProviderReauthRequired(this, provider, error);
// Add to sync history
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'download',
success: false,
localVersion: this.state.localVersion,
error: String(error),
});
throw error;
}
}
export async function getGistRevisionHistoryImpl(this: any): Promise<Array<{ version: string; date: Date }>> {
let adapter: GitHubAdapter;
try {
adapter = await this.getConnectedAdapter('github') as GitHubAdapter;
} catch {
return [];
}
if (!adapter.getHistory) return [];
return adapter.getHistory();
}
export async function downloadGistRevisionImpl(this: any,sha: string): Promise<{
payload: SyncPayload;
meta: SyncFileMeta;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
noteCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
} | null> {
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
throw new Error('Vault is locked');
}
let adapter: GitHubAdapter;
try {
adapter = await this.getConnectedAdapter('github') as GitHubAdapter;
} catch {
throw new Error('GitHub adapter not available');
}
if (!adapter.downloadRevision) throw new Error('GitHub adapter not available');
const syncedFile = await adapter.downloadRevision(sha);
if (!syncedFile) return null;
const payload = await EncryptionService.decryptPayload(syncedFile, this.masterPassword);
return {
payload,
meta: syncedFile.meta,
preview: {
hostCount: payload.hosts?.length ?? 0,
keyCount: payload.keys?.length ?? 0,
snippetCount: payload.snippets?.length ?? 0,
noteCount: payload.notes?.length ?? 0,
identityCount: payload.identities?.length ?? 0,
portForwardingRuleCount: payload.portForwardingRules?.length ?? 0,
},
};
}
export async function resolveConflictImpl(this: any,resolution: ConflictResolution): Promise<RemoteSyncPayload | null> {
if (!this.state.currentConflict) {
throw new Error('No conflict to resolve');
}
const { provider } = this.state.currentConflict;
this.emit({ type: 'CONFLICT_RESOLVED', resolution });
if (resolution === 'USE_REMOTE') {
// Download and return remote data
const payload = await this.downloadFromProvider(provider);
this.state.currentConflict = null;
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.notifyStateChange(); // Notify UI of conflict resolution
return payload;
} else {
// USE_LOCAL - just clear conflict, caller will re-sync
this.state.currentConflict = null;
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.notifyStateChange(); // Notify UI of conflict resolution
return null;
}
}
export function exitBlockedStateImpl(this: any): void {
if (this.state.syncState === 'BLOCKED') {
this.state.lastShrinkFinding = undefined;
this.emit({ type: 'SYNC_BLOCKED_CLEARED' });
}
}
export function clearShrinkBlockedStateImpl(this: any): void {
if (this.state.syncState === 'BLOCKED') {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.notifyStateChange();
}
}
export function getShrinkBlockedFindingImpl(this: any): Extract<ShrinkFinding, { suspicious: true }> | null {
if (this.state.syncState !== 'BLOCKED') return null;
return this.state.lastShrinkFinding ?? null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,908 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EncryptionService } from "../EncryptionService.ts";
import {
clearProviderMergeStateImpl,
commitRemoteInspectionImpl,
} from "./authMethods.ts";
import {
selectConvergentSyncToProviderResult,
syncToProviderImpl,
uploadToProviderImpl,
} from "./providerSyncMethods.ts";
import {
clearSyncBaseImpl,
loadSyncSnapshotsImpl,
saveSyncBaseImpl,
syncAllProvidersImpl,
} from "./syncAllStorageMethods.ts";
import type {
CloudProvider,
SyncedFile,
SyncPayload,
SyncResult,
} from "../../../domain/sync.ts";
import { setConvergentSyncLocalConfig } from "../convergentSyncConfig.ts";
function payload(hostId: string): SyncPayload {
return payloadWithHosts([hostId]);
}
function payloadWithHosts(hostIds: string[]): SyncPayload {
return {
hosts: hostIds.map((hostId) => ({
id: hostId,
label: hostId,
hostname: `${hostId}.example.com`,
port: 22,
username: "root",
tags: [],
os: "linux",
})),
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
portForwardingRules: [],
groupConfigs: [],
settings: undefined,
syncedAt: 0,
};
}
function remoteFile(provider: CloudProvider, version: number, updatedAt: number): SyncedFile {
return {
meta: {
version,
updatedAt,
deviceId: `${provider}-device`,
deviceName: provider,
appVersion: "0.0.0",
iv: "",
salt: "",
algorithm: "AES-256-GCM",
kdf: "PBKDF2",
kdfIterations: 1,
},
payload: provider,
};
}
test("provider identity changes clear v1 base, v2 baseline, and remote anchor together", () => {
const removed: string[] = [];
const manager = {
syncBaseKey: (provider: CloudProvider) => `base:${provider}`,
convergentProviderBaselineKey: (provider: CloudProvider) => `convergent:${provider}`,
removeFromStorage: (key: string) => removed.push(key),
clearSyncAnchor: (provider: CloudProvider) => removed.push(`anchor:${provider}`),
};
clearProviderMergeStateImpl.call(manager, "github");
assert.deepEqual(removed, ["base:github", "convergent:github", "anchor:github"]);
});
test("clearing all merge bases also removes every convergent provider baseline", () => {
const removed = new Set<string>();
const providers: CloudProvider[] = ["github", "google", "onedrive", "webdav", "s3"];
const manager = {
removeFromStorage: (key: string) => removed.add(key),
syncBaseKey: (provider?: CloudProvider) => `base:${provider ?? "default"}`,
syncSnapshotsKey: (provider?: CloudProvider) => `snapshots:${provider ?? "default"}`,
convergentProviderBaselineKey: (provider: CloudProvider) => `convergent:${provider}`,
clearSyncAnchor: () => {},
};
clearSyncBaseImpl.call(manager);
for (const provider of providers) {
assert.equal(removed.has(`convergent:${provider}`), true);
}
});
test("syncAllProviders uses the newest cloud payload without merging other remotes when cloud wins", async () => {
const originalDecryptPayload = EncryptionService.decryptPayload;
const originalEncryptPayload = EncryptionService.encryptPayload;
const githubRemote = remoteFile("github", 3, 300);
const googleRemote = remoteFile("google", 2, 200);
const githubPayload = payload("github-winner");
const localPayload = payload("local");
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
const committed: CloudProvider[] = [];
EncryptionService.decryptPayload = async (file: SyncedFile) => {
if (file === githubRemote) return githubPayload;
return payload("google-loser");
};
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
...remoteFile("github", 4, 400),
payload: JSON.stringify(outgoing),
});
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
google: { enabled: true, connected: true, status: "connected" },
onedrive: { enabled: false, connected: false, status: "disconnected" },
webdav: { enabled: false, connected: false, status: "disconnected" },
s3: { enabled: false, connected: false, status: "disconnected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "preferCloud",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async (provider: CloudProvider) => ({
conflict: true,
remoteFile: provider === "github" ? githubRemote : googleRemote,
}),
loadSyncBase: async () => payload("base"),
commitRemoteInspection: async (provider: CloudProvider) => {
committed.push(provider);
},
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
uploaded.push({ provider, payload: outgoing });
return { success: true, provider, action: "upload" as const, version: 4 };
},
exitBlockedState: () => {},
notifyStateChange: () => {},
};
const results = await syncAllProvidersImpl.call(manager, localPayload);
assert.equal(results.get("github")?.action, "download");
assert.deepEqual(results.get("github")?.mergedPayload, githubPayload);
assert.equal(results.get("github")?.remoteFile, githubRemote);
assert.equal(uploaded.length, 1);
assert.equal(uploaded[0].provider, "google");
assert.equal(uploaded[0].payload.hosts[0]?.id, "github-winner");
assert.equal(uploaded[0].payload.syncMeta?.schemaVersion, 1);
assert.deepEqual(committed, []);
} finally {
EncryptionService.decryptPayload = originalDecryptPayload;
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("syncToProvider uses the checked remote as metadata base when no stored base exists", async () => {
const originalDecryptPayload = EncryptionService.decryptPayload;
const originalEncryptPayload = EncryptionService.encryptPayload;
const checkedRemote = remoteFile("github", 3, 300);
const remotePayload = payloadWithHosts(["kept", "deleted-on-local"]);
const localPayload = payload("kept");
let uploadedPayload: SyncPayload | undefined;
EncryptionService.decryptPayload = async (file: SyncedFile) => {
assert.equal(file, checkedRemote);
return remotePayload;
};
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
...remoteFile("github", 4, 400),
payload: JSON.stringify(outgoing),
});
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
providerDecryptSeq: { github: 0 },
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async () => ({ provider: "github" }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
loadSyncBase: async () => null,
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
uploadedPayload = outgoing;
return { success: true, provider, action: "upload" as const, version: 4 };
},
exitBlockedState: () => {},
};
const result = await syncToProviderImpl.call(manager, "github", localPayload);
assert.equal(result.success, true);
assert.deepEqual(uploadedPayload?.syncMeta?.deletions, [{
entityType: "hosts",
id: "deleted-on-local",
deletedAt: uploadedPayload?.syncMeta?.generatedAt,
deviceId: "local-device",
}]);
} finally {
EncryptionService.decryptPayload = originalDecryptPayload;
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("syncToProvider refuses to downgrade a checked convergent remote", async () => {
const checkedRemote = remoteFile("github", 3, 300);
checkedRemote.meta.syncSchemaVersion = 2;
let encrypted = false;
const originalEncryptPayload = EncryptionService.encryptPayload;
EncryptionService.encryptPayload = async () => {
encrypted = true;
return checkedRemote;
};
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: { github: { status: "connected" } },
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async () => ({ provider: "github" }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
addSyncHistoryEntry: () => {},
};
const result = await syncToProviderImpl.call(manager, "github", payload("local"));
assert.equal(result.success, false);
assert.equal(encrypted, false);
assert.match(result.error ?? "", /Enable or migrate convergent sync/);
} finally {
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("syncToProvider aborts an upload when the master key changes after encryption", async () => {
const originalEncryptPayload = EncryptionService.encryptPayload;
const localPayload = payload("local");
let generation = 0;
let uploaded = false;
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => {
generation += 1;
return {
...remoteFile("github", 2, 200),
payload: JSON.stringify(outgoing),
};
};
try {
const manager = {
masterPassword: "old-master-password",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getSyncSecurityGeneration: () => 0,
assertSyncSecurityGeneration: (expected: number) => {
if (generation !== expected) {
throw new Error("Sync cancelled because master key changed");
}
},
getConnectedAdapter: async () => ({ provider: "github" }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: false }),
loadSyncBase: async () => null,
uploadToProvider: async (provider: CloudProvider) => {
uploaded = true;
return { success: true, provider, action: "upload" as const, version: 2 };
},
exitBlockedState: () => {},
addSyncHistoryEntry: () => {},
};
const result = await syncToProviderImpl.call(manager, "github", localPayload);
assert.equal(uploaded, false);
assert.equal(result.success, false);
assert.match(result.error ?? "", /master key changed/);
} finally {
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("uploadToProvider skips local commits when the master key changes during upload", async () => {
let generation = 0;
let savedAnchor = false;
let savedBase = false;
let savedProvider = false;
const file = remoteFile("github", 2, 200);
const manager = {
providerDecryptSeq: { github: 0 },
state: {
providers: {
github: { enabled: true, connected: true, status: "syncing" },
},
lastError: null,
syncState: "SYNCING",
localVersion: 1,
localUpdatedAt: 100,
remoteVersion: 1,
remoteUpdatedAt: 100,
deviceName: "Local",
},
assertSyncSecurityGeneration: (expected: number) => {
if (generation !== expected) {
throw new Error("Sync cancelled because master key changed");
}
},
saveSyncConfig: () => {},
saveSyncBase: async () => {
savedBase = true;
},
saveSyncAnchor: async () => {
savedAnchor = true;
},
saveProviderConnection: async () => {
savedProvider = true;
},
notifyStateChange: () => {},
addSyncHistoryEntry: () => {},
updateProviderStatus: () => {},
emit: () => {},
};
const adapter = {
upload: async () => {
generation += 1;
return "resource-id";
},
};
const result = await uploadToProviderImpl.call(
manager,
"github",
adapter,
file,
payload("local"),
0,
);
assert.equal(result.success, false);
assert.match(result.error ?? "", /master key changed/);
assert.equal(savedBase, false);
assert.equal(savedAnchor, false);
assert.equal(savedProvider, false);
});
test("syncAllProviders uses the checked remote as metadata base when provider base is missing", async () => {
const originalDecryptPayload = EncryptionService.decryptPayload;
const originalEncryptPayload = EncryptionService.encryptPayload;
const checkedRemote = remoteFile("github", 3, 300);
const remotePayload = payloadWithHosts(["kept", "deleted-on-local"]);
const localPayload = payload("kept");
let uploadedPayload: SyncPayload | undefined;
EncryptionService.decryptPayload = async (file: SyncedFile) => {
assert.equal(file, checkedRemote);
return remotePayload;
};
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
...remoteFile("github", 4, 400),
payload: JSON.stringify(outgoing),
});
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
google: { enabled: false, connected: false, status: "disconnected" },
onedrive: { enabled: false, connected: false, status: "disconnected" },
webdav: { enabled: false, connected: false, status: "disconnected" },
s3: { enabled: false, connected: false, status: "disconnected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async () => ({ provider: "github" }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
loadSyncBase: async () => null,
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
uploadedPayload = outgoing;
return { success: true, provider, action: "upload" as const, version: 4 };
},
exitBlockedState: () => {},
notifyStateChange: () => {},
};
const results = await syncAllProvidersImpl.call(manager, localPayload);
assert.equal(results.get("github")?.success, true);
assert.deepEqual(uploadedPayload?.syncMeta?.deletions, [{
entityType: "hosts",
id: "deleted-on-local",
deletedAt: uploadedPayload?.syncMeta?.generatedAt,
deviceId: "local-device",
}]);
} finally {
EncryptionService.decryptPayload = originalDecryptPayload;
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("commitRemoteInspection saves the comparison base before advancing the remote anchor", async () => {
const calls: string[] = [];
const file = remoteFile("github", 5, 500);
const incoming = payload("cloud");
const manager = {
providerDecryptSeq: { github: 0 },
state: {
providers: {
github: { resourceId: "old", lastSync: 0, lastSyncVersion: 0 },
},
localVersion: 0,
localUpdatedAt: 0,
remoteVersion: 0,
remoteUpdatedAt: 0,
},
getConnectedAdapter: async () => ({ resourceId: "remote-resource" }),
saveSyncConfig: () => calls.push("config"),
saveSyncBase: async () => calls.push("base"),
saveSyncAnchor: async () => calls.push("anchor"),
saveProviderConnection: async () => calls.push("connection"),
addSyncHistoryEntry: () => calls.push("history"),
notifyStateChange: () => calls.push("notify"),
};
await commitRemoteInspectionImpl.call(manager, "github", file, incoming, {
recordDownload: true,
});
assert.deepEqual(calls, ["base", "config", "anchor", "connection", "history", "notify"]);
});
test("commitRemoteInspection does not advance the remote anchor when saving the base fails", async () => {
const calls: string[] = [];
const manager = {
providerDecryptSeq: { github: 0 },
state: {
providers: {
github: { resourceId: "remote-resource", lastSync: 0, lastSyncVersion: 0 },
},
localVersion: 0,
localUpdatedAt: 0,
remoteVersion: 0,
remoteUpdatedAt: 0,
},
getConnectedAdapter: async () => ({ resourceId: "remote-resource" }),
saveSyncConfig: () => calls.push("config"),
saveSyncBase: async () => {
calls.push("base");
throw new Error("base failed");
},
saveSyncAnchor: async () => calls.push("anchor"),
saveProviderConnection: async () => calls.push("connection"),
addSyncHistoryEntry: () => calls.push("history"),
notifyStateChange: () => calls.push("notify"),
};
await assert.rejects(
() => commitRemoteInspectionImpl.call(manager, "github", remoteFile("github", 5, 500), payload("cloud")),
/base failed/,
);
assert.deepEqual(calls, ["base"]);
});
test("saveSyncBase reports storage failures so callers do not advance anchors", async () => {
const originalWarn = console.warn;
const manager = {
state: {
unlockedKey: {
derivedKey: await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"],
),
},
},
syncBaseKey: () => "sync-base",
saveToStorage: () => {
throw new Error("storage full");
},
};
console.warn = () => {};
try {
await assert.rejects(
() => saveSyncBaseImpl.call(manager, payload("cloud"), "github"),
/storage full/,
);
} finally {
console.warn = originalWarn;
}
});
test("saveSyncBase reports a missing local encryption key", async () => {
const manager = {
state: { unlockedKey: null },
syncBaseKey: () => "sync-base",
saveToStorage: () => {},
};
await assert.rejects(
() => saveSyncBaseImpl.call(manager, payload("cloud"), "github"),
/Sync base encryption key is unavailable/,
);
});
test("saveSyncBase keeps a bounded encrypted snapshot history before replacing the base", async () => {
const stored = new Map<string, string>();
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"],
);
const manager = {
state: { unlockedKey: { derivedKey: key } },
syncBaseKey: (provider?: CloudProvider) => `base-${provider ?? "default"}`,
syncSnapshotsKey: (provider?: CloudProvider) => `snapshots-${provider ?? "default"}`,
saveToStorage: (storageKey: string, value: string) => stored.set(storageKey, value),
loadFromStorage: (storageKey: string) => stored.get(storageKey),
};
await saveSyncBaseImpl.call(manager, payload("base-0"), "github");
for (let i = 1; i <= 7; i += 1) {
await saveSyncBaseImpl.call(manager, payload(`base-${i}`), "github");
}
const snapshots = await loadSyncSnapshotsImpl.call(manager, "github");
assert.equal(snapshots.length, 5);
assert.deepEqual(
snapshots.map((snapshot) => snapshot.payload.hosts[0]?.id),
["base-6", "base-5", "base-4", "base-3", "base-2"],
);
});
test("syncAllProviders builds provider-specific sync metadata from each provider base", async () => {
const originalEncryptPayload = EncryptionService.encryptPayload;
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
const baseByProvider = {
github: payload("shared"),
google: payload("deleted-on-local"),
} as Partial<Record<CloudProvider, SyncPayload>>;
const localPayload = payload("shared");
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
...remoteFile("github", 4, 400),
payload: JSON.stringify(outgoing),
});
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
google: { enabled: true, connected: true, status: "connected" },
onedrive: { enabled: false, connected: false, status: "disconnected" },
webdav: { enabled: false, connected: false, status: "disconnected" },
s3: { enabled: false, connected: false, status: "disconnected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: false, remoteFile: null }),
loadSyncBase: async (provider: CloudProvider) => baseByProvider[provider] ?? null,
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
uploaded.push({ provider, payload: outgoing });
return { success: true, provider, action: "upload" as const, version: 4 };
},
exitBlockedState: () => {},
notifyStateChange: () => {},
};
await syncAllProvidersImpl.call(manager, localPayload);
assert.equal(uploaded.length, 2);
assert.deepEqual(uploaded.find((entry) => entry.provider === "github")?.payload.syncMeta?.deletions, []);
assert.deepEqual(uploaded.find((entry) => entry.provider === "google")?.payload.syncMeta?.deletions, [{
entityType: "hosts",
id: "deleted-on-local",
deletedAt: uploaded.find((entry) => entry.provider === "google")?.payload.syncMeta?.generatedAt,
deviceId: "local-device",
}]);
} finally {
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("syncAllProviders upload-local override overwrites remote without decrypting when password differs", async () => {
const originalDecryptPayload = EncryptionService.decryptPayload;
const originalEncryptPayload = EncryptionService.encryptPayload;
const checkedRemote = remoteFile("github", 5, 500);
const localPayload = payload("local-after-reinstall");
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
const encryptBaseVersions: number[] = [];
let decryptCalls = 0;
EncryptionService.decryptPayload = async () => {
decryptCalls += 1;
throw new Error("OperationError: unable to authenticate data");
};
EncryptionService.encryptPayload = async (
outgoing: SyncPayload,
_password: string,
_deviceId: string,
_deviceName: string,
_appVersion: string,
existingVersion?: number,
) => {
encryptBaseVersions.push(existingVersion ?? 0);
return {
...remoteFile("github", (existingVersion ?? 0) + 1, 600),
payload: JSON.stringify(outgoing),
};
};
try {
const manager = {
masterPassword: "new-master-password",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
google: { enabled: false, connected: false, status: "disconnected" },
onedrive: { enabled: false, connected: false, status: "disconnected" },
webdav: { enabled: false, connected: false, status: "disconnected" },
s3: { enabled: false, connected: false, status: "disconnected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: true, remoteFile: checkedRemote }),
loadSyncBase: async () => null,
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
uploaded.push({ provider, payload: outgoing });
return { success: true, provider, action: "upload" as const, version: 6 };
},
exitBlockedState: () => {},
notifyStateChange: () => {},
};
const conflicted = await syncAllProvidersImpl.call(manager, localPayload);
assert.equal(conflicted.get("github")?.success, false);
assert.equal(conflicted.get("github")?.conflictDetected, true);
assert.equal(conflicted.get("github")?.error, undefined);
assert.equal(uploaded.length, 0);
const forced = await syncAllProvidersImpl.call(manager, localPayload, {
conflictActionOverride: "upload-local",
overrideShrink: true,
});
assert.equal(forced.get("github")?.success, true);
assert.equal(forced.get("github")?.action, "upload");
assert.equal(uploaded.length, 1);
assert.equal(uploaded[0]?.payload.hosts[0]?.id, "local-after-reinstall");
// Keep-local under smartMerge must base on the conflicting remote version
// (v5 → encrypted as v6), matching single-provider upload-local.
assert.deepEqual(encryptBaseVersions, [5]);
// Shrink-guard may attempt decrypt and ignore failure; merge path must not run.
assert.ok(decryptCalls >= 1);
} finally {
EncryptionService.decryptPayload = originalDecryptPayload;
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("an initialized but paused v2 replica cannot fall through to legacy provider writes", async () => {
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const values = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
try {
setConvergentSyncLocalConfig({ enabled: false, initialized: true });
let adapterRequested = false;
const manager = {
state: {
providers: {
github: { provider: "github", status: "connected" },
},
},
getConnectedAdapter: async () => {
adapterRequested = true;
throw new Error("legacy path must not run");
},
};
const all = await syncAllProvidersImpl.call(manager, payload("local"));
const one = await syncToProviderImpl.call(manager, "github", payload("local"));
assert.equal(all.get("github")?.success, false);
assert.match(all.get("github")?.error ?? "", /paused/i);
assert.equal(one.success, false);
assert.match(one.error ?? "", /paused/i);
assert.equal(adapterRequested, false);
} finally {
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
}
});
test("syncToProvider preserves a merged payload discovered by a non-target provider", () => {
const mergedPayload = payload("remote-merged");
const results = new Map<CloudProvider, SyncResult>([
["github", {
success: false,
provider: "github",
action: "none",
error: "github unavailable",
}],
["google", {
success: true,
provider: "google",
action: "merge",
mergedPayload,
}],
]);
const selected = selectConvergentSyncToProviderResult("github", results);
assert.equal(selected.success, false);
assert.equal(selected.provider, "github");
assert.equal(selected.error, "github unavailable");
assert.equal(selected.mergedPayload, mergedPayload);
assert.equal(selected.remoteFile, undefined);
});
test("syncAllProviders smart-merge strips device-bound enc:v1 secrets before upload", async () => {
const originalDecryptPayload = EncryptionService.decryptPayload;
const originalEncryptPayload = EncryptionService.encryptPayload;
const completeBlob = Buffer.alloc(31, 0);
Buffer.from("v10", "utf8").copy(completeBlob, 0);
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
const checkedRemote = remoteFile("github", 5, 500);
const localPayload = {
...payload("shared"),
hosts: [{
...payload("shared").hosts[0]!,
password: "kept-secret",
}],
};
const remotePoisoned: SyncPayload = {
...payload("shared"),
hosts: [
{
...payload("shared").hosts[0]!,
label: "remote-label",
password: ENC,
},
],
};
const uploaded: SyncPayload[] = [];
EncryptionService.decryptPayload = async () => remotePoisoned;
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
...remoteFile("github", 6, 600),
payload: JSON.stringify(outgoing),
});
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
google: { enabled: false, connected: false, status: "disconnected" },
onedrive: { enabled: false, connected: false, status: "disconnected" },
webdav: { enabled: false, connected: false, status: "disconnected" },
s3: { enabled: false, connected: false, status: "disconnected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: true, remoteFile: checkedRemote }),
loadSyncBase: async () => ({
...payload("shared"),
hosts: [{
...payload("shared").hosts[0]!,
password: "kept-secret",
}],
}),
uploadToProvider: async (
_provider: CloudProvider,
_adapter: unknown,
_file: SyncedFile,
outgoing: SyncPayload,
) => {
uploaded.push(outgoing);
return { success: true, provider: "github" as const, action: "upload" as const, version: 6 };
},
exitBlockedState: () => {},
notifyStateChange: () => {},
};
const results = await syncAllProvidersImpl.call(manager, localPayload);
assert.equal(results.get("github")?.success, true);
assert.equal(uploaded.length, 1);
const sharedHost = uploaded[0]?.hosts.find((host) => host.id === "shared");
assert.ok(sharedHost);
// Local/base usable secret must survive; remote non-secret edits can still apply.
assert.equal(sharedHost?.password, "kept-secret");
assert.equal(sharedHost?.label, "remote-label");
} finally {
EncryptionService.decryptPayload = originalDecryptPayload;
EncryptionService.encryptPayload = originalEncryptPayload;
}
});

View File

@@ -0,0 +1,976 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
SYNC_CONSTANTS,
SYNC_STORAGE_KEYS,
isProviderReadyForSync,
} from '../../../domain/sync';
import packageJson from '../../../package.json';
import { EncryptionService } from '../EncryptionService';
import { mergeSyncPayloads } from '../../../domain/syncMerge';
import { stripSyncPayloadEncryptedCredentials, healPoisonedSecretsForMerge } from '../../../domain/credentials';
import {
SYNC_SNAPSHOT_LIMIT,
summarizeSyncChanges,
withSyncReliabilityMeta,
} from '../../../domain/syncReliability';
import { detectSuspiciousShrink, type ShrinkFinding } from '../../../domain/syncGuards';
import { resolveCloudSyncConflictAction, type CloudSyncConflictAction, type CloudSyncStrategy } from '../../../domain/syncStrategy';
import { assertConvergentSyncWriteCompatible } from '../../../domain/convergentSync';
import { getConvergentSyncLocalConfig } from '../convergentSyncConfig';
import { syncAllProvidersConvergentlyImpl } from './convergentSyncRuntimeMethods';
import {
coalesceStoredSyncPreferences,
hasSyncPreferenceFields,
resolveSyncPreferencesForPersist,
resolveSyncVersionsForPersist,
} from './syncConfigPersist';
import type { CloudAdapter } from '../adapters';
import type {
CloudProvider,
ProviderConnection,
SyncedFile,
SyncHistoryEntry,
SyncSnapshotEntry,
SyncPayload,
SyncResult,
} from '../../../domain/sync';
// CloudProvider used when clearing dynamic plugin-provider bases/anchors.
import {
decryptLocalStorageValue,
encryptLocalStorageValue,
} from './encryptedLocalStorage';
function getSyncSecurityGeneration(manager: any): number | undefined {
return typeof manager.getSyncSecurityGeneration === 'function'
? manager.getSyncSecurityGeneration()
: undefined;
}
function assertSyncSecurityGeneration(manager: any, generation?: number): void {
if (typeof manager.assertSyncSecurityGeneration === 'function') {
manager.assertSyncSecurityGeneration(generation);
}
}
async function downloadRemoteForSyncAllImpl(this: any,
provider: CloudProvider,
remoteFile: SyncedFile,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const payload = stripSyncPayloadEncryptedCredentials(
await EncryptionService.decryptPayload(remoteFile, this.masterPassword),
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.updateProviderStatus(provider, 'connected');
const result: SyncResult = {
success: true,
provider,
action: 'download',
version: remoteFile.meta.version,
mergedPayload: payload,
remoteFile,
};
this.emit({ type: 'SYNC_COMPLETED', provider, result });
return result;
}
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
const SYNC_SNAPSHOTS_STORAGE_KEY = 'netcatty_sync_snapshots_v1';
async function loadRawSyncBase(this: any, provider?: CloudProvider): Promise<SyncPayload | null> {
const key = this.state.unlockedKey?.derivedKey;
if (!key || typeof this.loadFromStorage !== 'function') return null;
const encoded = this.loadFromStorage(this.syncBaseKey(provider));
if (!encoded || typeof encoded !== 'string') return null;
return decryptLocalStorageValue<SyncPayload>(encoded, key);
}
async function rememberCurrentSyncBaseSnapshot(this: any, provider?: CloudProvider): Promise<void> {
if (typeof this.syncSnapshotsKey !== 'function') return;
const previous = await loadRawSyncBase.call(this, provider);
if (!previous) return;
const snapshots = await loadSyncSnapshotsImpl.call(this, provider);
const entry: SyncSnapshotEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
timestamp: Date.now(),
...(provider ? { provider } : {}),
payload: previous,
};
await saveSyncSnapshotsImpl.call(this, [entry, ...snapshots].slice(0, SYNC_SNAPSHOT_LIMIT), provider);
}
export async function syncAllProvidersImpl(this: any,
inputPayload?: SyncPayload,
opts: {
overrideShrink?: boolean;
conflictActionOverride?: CloudSyncConflictAction;
applyConvergentPayload?: (
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>;
} = {},
): Promise<Map<CloudProvider, SyncResult>> {
const results = new Map<CloudProvider, SyncResult>();
let payload = inputPayload;
let wasMerged = false;
const convergentConfig = getConvergentSyncLocalConfig();
if (convergentConfig.initialized && inputPayload) {
if (convergentConfig.enabled) {
return syncAllProvidersConvergentlyImpl.call(this, inputPayload, {
...opts,
applyPayload: opts.applyConvergentPayload,
});
}
const message = 'Convergent sync is paused on this device';
for (const [provider, connection] of Object.entries(
this.state.providers as Record<CloudProvider, ProviderConnection>,
)) {
if (!isProviderReadyForSync(connection)) continue;
results.set(provider as CloudProvider, {
success: false,
provider: provider as CloudProvider,
action: 'none',
error: message,
});
}
return results;
}
const overrideShrinkRequested = opts.overrideShrink === true;
const syncSecurityGeneration = getSyncSecurityGeneration(this);
if (!payload) {
// Caller should provide payload from app state
return results;
}
if (this.state.securityState !== 'UNLOCKED') {
return results; // Or throw? Caller handles it.
}
if (!this.masterPassword) {
return results;
}
const connectedProviders = Object.entries(
this.state.providers as Record<CloudProvider, ProviderConnection>,
)
.filter(([provider, connection]) => {
if (!isProviderReadyForSync(connection)) return false;
if (connection.status === 'error') {
this.state.providers[provider as CloudProvider].status = 'connected';
this.state.providers[provider as CloudProvider].error = undefined;
// Clear cached adapter so a fresh one is created with current (decrypted) tokens
this.adapters.delete(provider as CloudProvider);
}
return true;
})
.map(([p]) => p as CloudProvider);
if (connectedProviders.length === 0) {
return results;
}
this.state.lastError = null;
this.state.syncState = 'SYNCING';
// 1. Parallel Checks
const checkTasks = connectedProviders.map(async (provider) => {
try {
// We handle connection error here to prevent one provider blocking others
const adapter = await this.getConnectedAdapter(provider);
this.updateProviderStatus(provider, 'syncing');
this.emit({ type: 'SYNC_STARTED', provider });
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const check = await this.checkProviderConflict(provider, adapter);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
return { provider, adapter, check };
} catch (error) {
return { provider, error: String(error) };
}
});
const checkResults = await Promise.all(checkTasks);
// 2. Analyze Results & Handle Conflicts — merge ALL conflicting providers
//
// Contract: every connected provider is assumed to mirror the *same*
// logical vault. When providers hold divergent content (e.g. user
// intentionally points GitHub and OneDrive at separate accounts with
// different data), uploading the conflict-merged payload below will
// overwrite provider-unique content on non-conflicting providers. A
// proper fix requires per-provider compare-and-swap (follow-up work,
// see I-1 and `docs/`). Until then, we log a diagnostic warning when
// we detect cross-provider base divergence so the issue is visible in
// support logs.
const conflicts = checkResults.filter((r) => !r.error && r.check?.conflict && r.check?.remoteFile);
// Instrumentation only — detect divergent provider bases (an
// unsupported configuration). Cheap: bases are already persisted
// and we only read their aggregate counts.
if (checkResults.filter((r) => !r.error).length > 1) {
try {
const summaries = await Promise.all(
checkResults
.filter((r) => !r.error)
.map(async (r) => {
const base = await this.loadSyncBase(r.provider as CloudProvider);
return {
provider: r.provider,
hosts: base?.hosts?.length ?? 0,
keys: base?.keys?.length ?? 0,
snippets: base?.snippets?.length ?? 0,
};
}),
);
const signatures = summaries.map((s) => `${s.hosts}/${s.keys}/${s.snippets}`);
const allSame = signatures.every((sig) => sig === signatures[0]);
if (!allSame) {
console.warn(
'[CloudSyncManager] syncAll: connected providers hold divergent bases (multi-account setup?). Uploading the conflict-merged payload will replace each provider\'s current remote. See I-7 in PR #720 for context.',
summaries,
);
// Surface the same finding to the UI so multi-account / intentionally
// diverged configurations can be warned visibly instead of silently
// having one provider's data merged over another's (#779 follow-up).
this.emit({
type: 'PROVIDERS_DIVERGED',
summaries: summaries.map((s) => ({
provider: s.provider as CloudProvider,
hosts: s.hosts,
keys: s.keys,
snippets: s.snippets,
})),
});
}
} catch (diagError) {
// Non-fatal diagnostic; never let it block the sync.
console.warn('[CloudSyncManager] syncAll: base-divergence check failed:', diagError);
}
}
if (conflicts.length > 0) {
const conflictAction = opts.conflictActionOverride
?? resolveCloudSyncConflictAction(this.state.syncStrategy, {
hasConflict: true,
hasRemoteFile: true,
});
if (conflictAction === 'download-remote') {
const newestConflict = conflicts.reduce((latest, entry) => {
const latestUpdatedAt = latest.check?.remoteFile?.meta.updatedAt ?? 0;
const entryUpdatedAt = entry.check?.remoteFile?.meta.updatedAt ?? 0;
return entryUpdatedAt > latestUpdatedAt ? entry : latest;
});
try {
const remoteResult = await downloadRemoteForSyncAllImpl.call(
this,
newestConflict.provider as CloudProvider,
newestConflict.check!.remoteFile!,
syncSecurityGeneration,
);
const sourceProvider = newestConflict.provider as CloudProvider;
results.set(sourceProvider, remoteResult);
payload = remoteResult.mergedPayload ?? payload;
for (const r of checkResults) {
const provider = r.provider as CloudProvider;
if (provider === sourceProvider) continue;
if (r.check) {
r.check.conflict = false;
}
}
} catch (error) {
const msg = String(error);
this.state.syncState = 'ERROR';
this.state.lastError = msg;
this.updateProviderStatus(newestConflict.provider as CloudProvider, 'error', msg);
this.emit({ type: 'SYNC_ERROR', provider: newestConflict.provider as CloudProvider, error: msg });
results.set(newestConflict.provider as CloudProvider, {
success: false,
provider: newestConflict.provider as CloudProvider,
action: 'none',
error: msg,
});
return results;
}
}
if (conflictAction === 'upload-local') {
for (const r of checkResults) {
if (r.check) r.check.conflict = false;
}
} else if (conflictAction === 'smart-merge') {
// Three-way merge: incorporate remote data from every conflicting provider
try {
let merged = payload;
for (const c of conflicts) {
const providerBase = await this.loadSyncBase(c.provider as CloudProvider);
const remoteRaw = await EncryptionService.decryptPayload(
c.check!.remoteFile!,
this.masterPassword,
);
const localHealed = healPoisonedSecretsForMerge(merged, remoteRaw, providerBase);
const remotePayload = healPoisonedSecretsForMerge(
remoteRaw,
merged,
providerBase,
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const result = mergeSyncPayloads(providerBase, localHealed, remotePayload);
merged = result.payload;
}
const mergeResult = {
payload: stripSyncPayloadEncryptedCredentials(merged),
};
console.info('[CloudSyncManager] syncAll: three-way merge completed');
// Replace payload with merged payload for upload to all providers
payload = mergeResult.payload;
wasMerged = true;
// Re-classify: all providers (including the conflicting one) should now upload
// Clear the conflict check result so all go through the upload path
for (const r of checkResults) {
if (r.check) r.check.conflict = false;
}
} catch (mergeError) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
// Merge failed — fall back to conflict UI
console.error('[CloudSyncManager] syncAll: merge failed', mergeError);
const { provider, check } = conflicts[0];
const remoteFile = check!.remoteFile!;
let conflictSummary;
try {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const base = await this.loadSyncBase(provider as CloudProvider);
const remotePayload = await EncryptionService.decryptPayload(remoteFile, this.masterPassword);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
conflictSummary = summarizeSyncChanges(
base,
payload,
remotePayload,
);
} catch {
conflictSummary = undefined;
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.state.syncState = 'CONFLICT';
this.state.currentConflict = {
provider: provider as CloudProvider,
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
localDeviceName: this.state.deviceName,
remoteVersion: remoteFile.meta.version,
remoteUpdatedAt: remoteFile.meta.updatedAt,
remoteDeviceName: remoteFile.meta.deviceName,
...(conflictSummary ? { changeSummary: conflictSummary } : {}),
};
this.emit({
type: 'CONFLICT_DETECTED',
conflict: this.state.currentConflict,
});
for (const r of checkResults) {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
} else if (r.provider === conflicts[0].provider) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
conflictDetected: true,
});
} else {
this.updateProviderStatus(r.provider as CloudProvider, 'connected');
results.set(r.provider as CloudProvider, {
success: true,
provider: r.provider as CloudProvider,
action: 'none',
});
}
}
return results;
}
}
}
// Shrink guard (multi-provider): check the final outgoing payload against
// each provider's stored base. If ANY provider would suffer a suspicious
// shrink, block ALL uploads — the same payload goes to every provider, so
// any one provider's "would lose too much" is a global block. Override flag
// is one-shot and clears regardless of outcome.
const shrinkSuspectByProvider: Array<{
provider: CloudProvider;
finding: Extract<ShrinkFinding, { suspicious: true }>;
}> = [];
const candidateProviders = checkResults
.filter((r) => !r.error && !r.check?.conflict && r.adapter)
.map((r) => r.provider as CloudProvider);
for (const provider of candidateProviders) {
const providerBase = await this.loadSyncBase(provider);
// When no stored base exists, fall back to the remote payload fetched
// during the parallel check above — the shrink guard needs a reference
// or it fails open and lets degraded local state overwrite remote
// (#779). checkResults carries the per-provider remoteFile already.
let providerRemoteRef: SyncPayload | null = null;
if (!providerBase) {
const entry = checkResults.find((r) => r.provider === provider);
const remoteFile = entry?.check?.remoteFile;
if (remoteFile) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
try {
providerRemoteRef = await EncryptionService.decryptPayload(
remoteFile,
this.masterPassword,
);
} catch {
providerRemoteRef = null;
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
}
}
const finding = detectSuspiciousShrink(payload, providerBase, providerRemoteRef);
if (finding.suspicious) {
shrinkSuspectByProvider.push({ provider, finding });
}
}
const shouldBlockAll = shrinkSuspectByProvider.length > 0 && !overrideShrinkRequested;
const shouldForceAll = shrinkSuspectByProvider.length > 0 && overrideShrinkRequested;
if (shouldBlockAll) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = shrinkSuspectByProvider[0].finding;
for (const { provider, finding } of shrinkSuspectByProvider) {
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
results.set(provider, {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding,
});
}
// Process check errors from the parallel check phase so a provider that
// failed during checkProviderConflict is not silently dropped from results.
checkResults.forEach((r) => {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
}
});
// Providers in candidateProviders that didn't trip the shrink check still
// share the same payload — mark them as not-uploaded so the caller doesn't
// think a "successful" no-op happened.
const blockedProviders = new Set(shrinkSuspectByProvider.map((e) => e.provider));
for (const provider of candidateProviders) {
if (!results.has(provider) && !blockedProviders.has(provider)) {
results.set(provider, {
success: false,
provider,
action: 'none',
error: 'Sync blocked: another provider would lose too much data',
});
this.updateProviderStatus(provider, 'error', 'Sync blocked due to peer provider');
}
}
return results;
}
if (shouldForceAll) {
for (const { provider, finding } of shrinkSuspectByProvider) {
this.emit({ type: 'SYNC_FORCED', provider, finding });
}
}
// 3. Encrypt Once
const validUploads = checkResults.filter(
(r) => !r.error && !r.check?.conflict && r.adapter
) as { provider: CloudProvider; adapter: CloudAdapter }[];
if (validUploads.length === 0) {
// Process errors if any
checkResults.forEach((r) => {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
}
});
if (Array.from(results.values()).some((r) => r.success)) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
} else {
this.state.syncState = 'ERROR';
}
this.notifyStateChange();
return results;
}
// Use the highest version as base: either local or any remote that was merged
// or forcibly overwritten. Explicit keep-local (conflictActionOverride
// upload-local) can run while syncStrategy is still smartMerge; without
// taking the remote version here, encryptPayload would mint local+1 and
// regress past a higher conflicting remote (e.g. local v1 over remote v5).
let baseVersion = this.state.localVersion;
if (
wasMerged
|| (
conflicts.length > 0
&& (
this.state.syncStrategy !== 'smartMerge'
|| opts.conflictActionOverride === 'upload-local'
)
)
) {
for (const c of conflicts) {
const rv = c.check?.remoteFile?.meta?.version ?? 0;
if (rv > baseVersion) baseVersion = rv;
}
}
// 4. Parallel Uploads — each provider gets metadata derived from its own
// base, then that exact payload is persisted as the provider base
// inside uploadToProvider BEFORE the per-provider anchor advances.
// Ordering matters: a crash between the two writes must leave the
// stale anchor re-triggering inspection on next startup, not a
// fresh anchor paired with a stale base.
const uploadTasks = validUploads.map(async ({ provider, adapter }) => {
try {
const entry = checkResults.find((result) => result.provider === provider);
assertConvergentSyncWriteCompatible(entry?.check?.remoteFile?.meta, payload);
const providerBase = await this.loadSyncBase(provider);
let providerRemoteRef: SyncPayload | null = null;
if (!providerBase) {
const remoteFile = entry?.check?.remoteFile;
if (remoteFile) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
try {
providerRemoteRef = await EncryptionService.decryptPayload(
remoteFile,
this.masterPassword,
);
} catch {
providerRemoteRef = null;
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
}
}
const providerPayload = withSyncReliabilityMeta(payload, providerBase ?? providerRemoteRef, {
deviceId: this.state.deviceId,
now: Date.now(),
});
const syncedFile = await EncryptionService.encryptPayload(
providerPayload,
this.masterPassword,
this.state.deviceId,
this.state.deviceName,
packageJson.version,
baseVersion
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const result = await this.uploadToProvider(provider, adapter, syncedFile, providerPayload, syncSecurityGeneration);
results.set(provider, result);
} catch (error) {
const msg = String(error);
this.state.lastError = msg;
this.updateProviderStatus(provider, 'error', msg);
this.emit({ type: 'SYNC_ERROR', provider, error: msg });
results.set(provider, {
success: false,
provider,
action: 'none',
error: msg,
});
}
});
await Promise.all(uploadTasks);
// 5. Final State Update
const resultList = Array.from(results.values());
const hasSuccess = resultList.some((r) => r.success);
const hasConflict = resultList.some((r) => r.conflictDetected);
if (hasConflict) {
// Prefer CONFLICT over IDLE even when another provider succeeded, so the
// conflict UI from uploadToProvider is not wiped by a mixed multi-provider run.
this.state.syncState = 'CONFLICT';
if (wasMerged && payload) {
for (const [p, r] of results) {
if (r.success) {
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
}
}
}
} else if (hasSuccess) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
// If a merge happened, attach the merged payload to successful results
// so callers can apply remote additions to local state
if (wasMerged && payload) {
for (const [p, r] of results) {
if (r.success) {
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
}
}
}
} else {
this.state.syncState = 'ERROR';
// lastError is set by uploadToProvider
}
this.notifyStateChange(); // Notify UI that sync is complete
// Process errors from initial checks (if any)
checkResults.forEach((r) => {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
}
});
return results;
}
export function setDeviceNameImpl(this: any,name: string): void {
this.state.deviceName = name;
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_NAME, name);
this.notifyStateChange();
}
export function setAutoSyncImpl(this: any,enabled: boolean, intervalMinutes?: number): void {
this.state.autoSyncEnabled = enabled;
const memoryKeys: Array<'autoSync' | 'interval'> = ['autoSync'];
if (intervalMinutes) {
this.state.autoSyncInterval = Math.max(
SYNC_CONSTANTS.MIN_SYNC_INTERVAL,
Math.min(SYNC_CONSTANTS.MAX_SYNC_INTERVAL, intervalMinutes)
);
memoryKeys.push('interval');
}
// Preference write: only the fields this setter owns — leave syncStrategy
// (and interval when unchanged) to whatever is already persisted so another
// window's concurrent edit is not overwritten by stale memory.
this.saveSyncConfig({ preferencesFromMemory: true, memoryKeys });
this.notifyStateChange(); // Notify UI of state change
if (enabled && this.state.securityState === 'UNLOCKED') {
this.startAutoSync();
} else {
this.stopAutoSync();
}
}
export function startAutoSyncImpl(this: any): void {
if (this.autoSyncTimer) {
return;
}
this.autoSyncTimer = setInterval(
() => {
// Auto-sync callback - caller should provide payload
this.emit({ type: 'SYNC_STARTED', provider: 'github' }); // Trigger UI to initiate sync
},
this.state.autoSyncInterval * 60 * 1000
);
}
export function stopAutoSyncImpl(this: any): void {
if (this.autoSyncTimer) {
clearInterval(this.autoSyncTimer);
this.autoSyncTimer = null;
}
}
export function saveSyncConfigImpl(
this: any,
opts?: {
preferencesFromMemory?: boolean;
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
},
): void {
const preferencesFromMemory = opts?.preferencesFromMemory === true;
const memoryKeys = opts?.memoryKeys;
type StoredPrefs = {
autoSync?: boolean;
interval?: number;
syncStrategy?: unknown;
};
type StoredConfig = StoredPrefs & {
localVersion?: number;
localUpdatedAt?: number;
remoteVersion?: number;
remoteUpdatedAt?: number;
};
const adoptPreferences = (nextPrefs: {
autoSync: boolean;
interval: number;
syncStrategy: CloudSyncStrategy;
}): boolean => {
const autoSyncChanged = this.state.autoSyncEnabled !== nextPrefs.autoSync;
const intervalChanged = this.state.autoSyncInterval !== nextPrefs.interval;
const strategyChanged = this.state.syncStrategy !== nextPrefs.syncStrategy;
this.state.autoSyncEnabled = nextPrefs.autoSync;
this.state.autoSyncInterval = nextPrefs.interval;
this.state.syncStrategy = nextPrefs.syncStrategy;
if (autoSyncChanged) {
if (nextPrefs.autoSync && this.state.securityState === 'UNLOCKED') {
this.startAutoSync?.();
} else {
this.stopAutoSync?.();
}
}
return autoSyncChanged || intervalChanged || strategyChanged;
};
const memoryPreferences = {
autoSync: this.state.autoSyncEnabled,
interval: this.state.autoSyncInterval,
syncStrategy: this.state.syncStrategy,
};
// Preference writers only touch SYNC_PREFERENCES so a concurrent
// version bump cannot re-enable auto-sync via a shared RMW blob (#2976).
// When memoryKeys is set, merge owned fields onto the stored snapshot so
// a strategy-only write cannot revive a stale autoSync from memory.
if (preferencesFromMemory) {
const storedPreferences = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as
| StoredPrefs
| null
| undefined;
const storedConfig = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_CONFIG) as
| StoredConfig
| null
| undefined;
const nextPreferences = resolveSyncPreferencesForPersist({
memory: memoryPreferences,
stored: coalesceStoredSyncPreferences(storedPreferences, storedConfig),
preferencesFromMemory: true,
memoryKeys,
});
this.saveToStorage(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, nextPreferences);
return;
}
const storedPreferences = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as
| StoredPrefs
| null
| undefined;
const storedConfig = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_CONFIG) as
| StoredConfig
| null
| undefined;
const hasSeparatePreferences = Boolean(
storedPreferences && typeof storedPreferences === 'object',
);
const nextVersions = resolveSyncVersionsForPersist({
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
remoteVersion: this.state.remoteVersion,
remoteUpdatedAt: this.state.remoteUpdatedAt,
});
// Version-only saves never write SYNC_PREFERENCES. The dedicated key is
// created only by preference writers (setAutoSync / setSyncStrategy).
// A check-then-write migrate here can overwrite a concurrent
// autoSync=false from another window (#2976).
if (hasSeparatePreferences || !hasSyncPreferenceFields(storedConfig)) {
this.saveToStorage(SYNC_STORAGE_KEYS.SYNC_CONFIG, nextVersions);
} else {
// Keep legacy preference fields in SYNC_CONFIG until a preference
// writer splits them out. Take those fields from storage, never
// from this window's possibly stale memory.
const preservedPreferences = resolveSyncPreferencesForPersist({
memory: memoryPreferences,
stored: coalesceStoredSyncPreferences(null, storedConfig),
preferencesFromMemory: false,
});
this.saveToStorage(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
...preservedPreferences,
...nextVersions,
});
}
// Re-read preferences after the version write so a toggle that landed
// during the version persist window is adopted into this process.
const latestPreferences = resolveSyncPreferencesForPersist({
memory: memoryPreferences,
stored: coalesceStoredSyncPreferences(
this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as StoredPrefs | null | undefined,
this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_CONFIG) as StoredConfig | null | undefined,
),
preferencesFromMemory: false,
});
const shouldNotifyPreferenceAdopt = adoptPreferences(latestPreferences);
if (shouldNotifyPreferenceAdopt) {
this.notifyStateChange?.();
}
}
export function syncBaseKeyImpl(this: any,provider?: CloudProvider): string {
const suffix = provider ? `_${provider}` : '';
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${suffix}`;
}
export function providerAccountIdKeyImpl(this: any,provider: CloudProvider): string {
return `netcatty.sync.accountId.${provider}`;
}
export function loadProviderAccountIdImpl(this: any,provider: CloudProvider): string | null {
const stored = this.loadFromStorage(this.providerAccountIdKey(provider));
return typeof stored === 'string' ? stored : null;
}
export function saveProviderAccountIdImpl(this: any,provider: CloudProvider, id: string): void {
this.saveToStorage(this.providerAccountIdKey(provider), id);
}
export async function saveSyncBaseImpl(this: any,payload: SyncPayload, provider?: CloudProvider): Promise<void> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) {
throw new Error('Sync base encryption key is unavailable');
}
try {
try {
await rememberCurrentSyncBaseSnapshot.call(this, provider);
} catch (snapshotError) {
console.warn('[CloudSyncManager] Failed to save previous sync snapshot', snapshotError);
}
if (
this.saveToStorage(
this.syncBaseKey(provider),
await encryptLocalStorageValue(payload, key),
) === false
) {
throw new Error('Unable to persist sync base');
}
} catch (error) {
console.warn('[CloudSyncManager] Failed to save sync base', error);
throw error;
}
}
export async function loadSyncBaseImpl(this: any,provider?: CloudProvider): Promise<SyncPayload | null> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) return null;
try {
const encoded = this.loadFromStorage(this.syncBaseKey(provider)) as unknown;
if (!encoded || typeof encoded !== 'string') return null;
return decryptLocalStorageValue<SyncPayload>(encoded, key);
} catch {
return null;
}
}
export function syncSnapshotsKeyImpl(this: any,provider?: CloudProvider): string {
const suffix = provider ? `_${provider}` : '';
return `${SYNC_SNAPSHOTS_STORAGE_KEY}${suffix}`;
}
export async function loadSyncSnapshotsImpl(this: any,provider?: CloudProvider): Promise<SyncSnapshotEntry[]> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) return [];
try {
const encoded = this.loadFromStorage(this.syncSnapshotsKey(provider)) as unknown;
if (!encoded || typeof encoded !== 'string') return [];
const snapshots = await decryptLocalStorageValue<SyncSnapshotEntry[]>(encoded, key);
return Array.isArray(snapshots) ? snapshots : [];
} catch {
return [];
}
}
export async function saveSyncSnapshotsImpl(this: any,snapshots: SyncSnapshotEntry[], provider?: CloudProvider): Promise<void> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) {
throw new Error('Sync snapshot encryption key is unavailable');
}
if (this.saveToStorage(
this.syncSnapshotsKey(provider),
await encryptLocalStorageValue(snapshots.slice(0, SYNC_SNAPSHOT_LIMIT), key),
) === false) throw new Error('Unable to persist sync snapshots');
}
export function clearSyncBaseImpl(this: any): void {
this.removeFromStorage(SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD);
if (typeof this.syncSnapshotsKey === 'function') {
this.removeFromStorage(this.syncSnapshotsKey());
}
const providers = new Set<CloudProvider>([
'github', 'google', 'onedrive', 'webdav', 's3',
]);
for (const id of Object.keys(this.state?.providers ?? {})) {
providers.add(id as CloudProvider);
}
if (typeof this.listRegisteredPluginProviderIds === 'function') {
for (const id of this.listRegisteredPluginProviderIds()) {
providers.add(id as CloudProvider);
}
}
for (const p of providers) {
this.removeFromStorage(this.syncBaseKey(p));
this.removeFromStorage(this.convergentProviderBaselineKey(p));
if (typeof this.syncSnapshotsKey === 'function') {
this.removeFromStorage(this.syncSnapshotsKey(p));
}
}
this.clearSyncAnchor();
}
export function addSyncHistoryEntryImpl(this: any,entry: Omit<SyncHistoryEntry, 'id'>): void {
const newEntry: SyncHistoryEntry = {
...entry,
id: crypto.randomUUID(),
};
// Keep only the last 50 entries
this.state.syncHistory = [newEntry, ...this.state.syncHistory].slice(0, 50);
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, this.state.syncHistory);
this.notifyStateChange(); // Notify UI of new history entry
}
export function resetLocalVersionImpl(this: any): void {
this.state.localVersion = 0;
this.state.localUpdatedAt = 0;
this.state.syncHistory = [];
this.saveSyncConfig();
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, []);
this.clearSyncBase();
this.clearSyncAnchor();
this.notifyStateChange();
}

View File

@@ -0,0 +1,435 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SYNC_STORAGE_KEYS } from '../../../domain/sync.ts';
import { saveSyncConfigImpl, setAutoSyncImpl } from './syncAllStorageMethods.ts';
test('version saveSyncConfig does not clobber another window autoSync=false', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
autoSync: false,
interval: 5,
syncStrategy: 'smartMerge',
});
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
localVersion: 3,
localUpdatedAt: 100,
remoteVersion: 3,
remoteUpdatedAt: 100,
});
let stopCount = 0;
const manager = {
state: {
autoSyncEnabled: true, // stale — this window has not observed the other window yet
autoSyncInterval: 5,
localVersion: 4,
localUpdatedAt: 200,
remoteVersion: 4,
remoteUpdatedAt: 200,
syncStrategy: 'smartMerge',
securityState: 'UNLOCKED',
},
loadFromStorage(key: string) {
return storage.get(key) ?? null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
return true;
},
saveSyncConfig() {
saveSyncConfigImpl.call(this);
},
notifyStateChange() {},
startAutoSync() {},
stopAutoSync() {
stopCount += 1;
},
};
// Simulate post-upload version bump save (default: preferences from storage)
saveSyncConfigImpl.call(manager);
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as {
autoSync?: boolean;
localVersion: number;
};
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
assert.equal(savedPrefs.autoSync, false, 'must not re-enable auto-sync from stale memory');
assert.equal(savedConfig.autoSync, undefined, 'version blob must omit preference fields');
assert.equal(savedConfig.localVersion, 4);
assert.equal(manager.state.autoSyncEnabled, false, 'memory should adopt storage preference');
assert.equal(stopCount, 1);
});
test('version save after mid-flight preference toggle keeps autoSync=false', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
autoSync: true,
interval: 5,
syncStrategy: 'smartMerge',
});
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
localVersion: 3,
localUpdatedAt: 100,
remoteVersion: 3,
remoteUpdatedAt: 100,
});
let preferenceReads = 0;
const manager = {
state: {
autoSyncEnabled: true,
autoSyncInterval: 5,
localVersion: 4,
localUpdatedAt: 200,
remoteVersion: 4,
remoteUpdatedAt: 200,
syncStrategy: 'smartMerge',
securityState: 'UNLOCKED',
},
loadFromStorage(key: string) {
if (key === SYNC_STORAGE_KEYS.SYNC_PREFERENCES) {
preferenceReads += 1;
// First existence check still sees autoSync=true; a Settings toggle
// lands before the post-write re-read used for memory adoption.
if (preferenceReads === 1) {
return storage.get(key) ?? null;
}
return {
autoSync: false,
interval: 5,
syncStrategy: 'smartMerge',
};
}
return storage.get(key) ?? null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
// Concurrent Settings window disables auto-sync while versions persist.
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
autoSync: false,
interval: 5,
syncStrategy: 'smartMerge',
});
}
return true;
},
notifyStateChange() {},
startAutoSync() {},
stopAutoSync() {},
};
saveSyncConfigImpl.call(manager);
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as { autoSync?: boolean };
assert.equal(savedPrefs.autoSync, false);
assert.equal(savedConfig.autoSync, undefined);
assert.equal(manager.state.autoSyncEnabled, false);
});
test('setAutoSync still persists the explicit preference from memory', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
localVersion: 1,
localUpdatedAt: 1,
remoteVersion: 1,
remoteUpdatedAt: 1,
});
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
autoSync: true,
interval: 5,
syncStrategy: 'smartMerge',
});
const manager = {
state: {
autoSyncEnabled: true,
autoSyncInterval: 5,
localVersion: 1,
localUpdatedAt: 1,
remoteVersion: 1,
remoteUpdatedAt: 1,
syncStrategy: 'smartMerge',
securityState: 'UNLOCKED',
},
loadFromStorage(key: string) {
return storage.get(key) ?? null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
return true;
},
saveSyncConfig(opts?: {
preferencesFromMemory?: boolean;
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
}) {
saveSyncConfigImpl.call(this, opts);
},
notifyStateChange() {},
startAutoSync() {},
stopAutoSync() {},
};
setAutoSyncImpl.call(manager, false);
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as { autoSync?: boolean };
assert.equal(savedPrefs.autoSync, false);
assert.equal(savedConfig.autoSync, undefined);
assert.equal(manager.state.autoSyncEnabled, false);
});
test('strategy preference write does not re-enable stale autoSync from memory', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
autoSync: false,
interval: 15,
syncStrategy: 'smartMerge',
});
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
localVersion: 2,
localUpdatedAt: 20,
remoteVersion: 2,
remoteUpdatedAt: 20,
});
const manager = {
state: {
// Stale: another window already disabled auto-sync in storage.
autoSyncEnabled: true,
autoSyncInterval: 5,
localVersion: 2,
localUpdatedAt: 20,
remoteVersion: 2,
remoteUpdatedAt: 20,
syncStrategy: 'preferLocal',
securityState: 'UNLOCKED',
},
loadFromStorage(key: string) {
return storage.get(key) ?? null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
return true;
},
saveSyncConfig(opts?: {
preferencesFromMemory?: boolean;
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
}) {
saveSyncConfigImpl.call(this, opts);
},
notifyStateChange() {},
startAutoSync() {},
stopAutoSync() {},
};
saveSyncConfigImpl.call(manager, {
preferencesFromMemory: true,
memoryKeys: ['syncStrategy'],
});
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as {
autoSync: boolean;
interval: number;
syncStrategy: string;
};
assert.equal(savedPrefs.autoSync, false);
assert.equal(savedPrefs.interval, 15);
assert.equal(savedPrefs.syncStrategy, 'preferLocal');
});
function createPersistHarness(storage: Map<string, unknown>, state: {
autoSyncEnabled: boolean;
autoSyncInterval: number;
localVersion: number;
localUpdatedAt: number;
remoteVersion: number;
remoteUpdatedAt: number;
syncStrategy: string;
securityState?: string;
}) {
return {
state: {
securityState: 'UNLOCKED',
...state,
},
loadFromStorage(key: string) {
return storage.get(key) ?? null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
return true;
},
saveSyncConfig(opts?: {
preferencesFromMemory?: boolean;
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
}) {
saveSyncConfigImpl.call(this, opts);
},
notifyStateChange() {},
startAutoSync() {},
stopAutoSync() {},
};
}
test('version-only save does not create SYNC_PREFERENCES during first upgrade', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
autoSync: true,
interval: 15,
syncStrategy: 'preferCloud',
localVersion: 3,
localUpdatedAt: 100,
remoteVersion: 3,
remoteUpdatedAt: 100,
});
const manager = createPersistHarness(storage, {
autoSyncEnabled: true,
autoSyncInterval: 15,
localVersion: 4,
localUpdatedAt: 200,
remoteVersion: 4,
remoteUpdatedAt: 200,
syncStrategy: 'preferCloud',
});
saveSyncConfigImpl.call(manager);
assert.equal(storage.has(SYNC_STORAGE_KEYS.SYNC_PREFERENCES), false);
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as {
autoSync?: boolean;
interval?: number;
syncStrategy?: string;
localVersion: number;
};
assert.equal(savedConfig.autoSync, true);
assert.equal(savedConfig.interval, 15);
assert.equal(savedConfig.syncStrategy, 'preferCloud');
assert.equal(savedConfig.localVersion, 4);
});
test('version-only save preserves stored legacy autoSync=false over stale memory', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
autoSync: false,
interval: 5,
syncStrategy: 'smartMerge',
localVersion: 3,
localUpdatedAt: 100,
remoteVersion: 3,
remoteUpdatedAt: 100,
});
const manager = createPersistHarness(storage, {
autoSyncEnabled: true,
autoSyncInterval: 5,
localVersion: 4,
localUpdatedAt: 200,
remoteVersion: 4,
remoteUpdatedAt: 200,
syncStrategy: 'smartMerge',
});
saveSyncConfigImpl.call(manager);
assert.equal(storage.has(SYNC_STORAGE_KEYS.SYNC_PREFERENCES), false);
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as { autoSync?: boolean };
assert.equal(savedConfig.autoSync, false);
assert.equal(manager.state.autoSyncEnabled, false);
});
test('version-only save cannot overwrite a concurrent first-upgrade autoSync=false', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
autoSync: true,
interval: 5,
syncStrategy: 'smartMerge',
localVersion: 3,
localUpdatedAt: 100,
remoteVersion: 3,
remoteUpdatedAt: 100,
});
let preferenceReads = 0;
const manager = {
...createPersistHarness(storage, {
autoSyncEnabled: true,
autoSyncInterval: 5,
localVersion: 4,
localUpdatedAt: 200,
remoteVersion: 4,
remoteUpdatedAt: 200,
syncStrategy: 'smartMerge',
}),
loadFromStorage(key: string) {
if (key === SYNC_STORAGE_KEYS.SYNC_PREFERENCES) {
preferenceReads += 1;
// Settings toggle lands after the existence check and before the
// post-write reread — the previous migrate wrote stale autoSync=true
// into this key and clobbered the toggle.
if (preferenceReads === 1) return null;
return storage.get(key) ?? null;
}
return storage.get(key) ?? null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
storage.set(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, {
autoSync: false,
interval: 5,
syncStrategy: 'smartMerge',
});
}
return true;
},
};
saveSyncConfigImpl.call(manager);
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
assert.equal(savedPrefs.autoSync, false, 'must not migrate stale autoSync=true over a concurrent toggle');
assert.equal(manager.state.autoSyncEnabled, false);
});
test('first-upgrade setAutoSync(false) then version save does not re-enable', () => {
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
autoSync: true,
interval: 5,
syncStrategy: 'smartMerge',
localVersion: 3,
localUpdatedAt: 100,
remoteVersion: 3,
remoteUpdatedAt: 100,
});
const manager = createPersistHarness(storage, {
autoSyncEnabled: true,
autoSyncInterval: 5,
localVersion: 3,
localUpdatedAt: 100,
remoteVersion: 3,
remoteUpdatedAt: 100,
syncStrategy: 'smartMerge',
});
setAutoSyncImpl.call(manager, false);
manager.state.localVersion = 4;
manager.state.localUpdatedAt = 200;
saveSyncConfigImpl.call(manager);
const savedPrefs = storage.get(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as { autoSync: boolean };
const savedConfig = storage.get(SYNC_STORAGE_KEYS.SYNC_CONFIG) as {
autoSync?: boolean;
localVersion: number;
};
assert.equal(savedPrefs.autoSync, false);
assert.equal(savedConfig.autoSync, undefined);
assert.equal(savedConfig.localVersion, 4);
assert.equal(manager.state.autoSyncEnabled, false);
});

View File

@@ -0,0 +1,151 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
coalesceStoredSyncPreferences,
hasSyncPreferenceFields,
resolveSyncConfigForPersist,
resolveSyncPreferencesForPersist,
resolveSyncVersionsForPersist,
} from './syncConfigPersist.ts';
const memoryBase = {
autoSync: true,
interval: 5,
localVersion: 12,
localUpdatedAt: 1000,
remoteVersion: 11,
remoteUpdatedAt: 900,
syncStrategy: 'smartMerge' as const,
};
test('version-only persist keeps another window autoSync=false from storage', () => {
const next = resolveSyncConfigForPersist({
memory: memoryBase,
stored: {
autoSync: false,
interval: 5,
localVersion: 10,
localUpdatedAt: 800,
remoteVersion: 10,
remoteUpdatedAt: 800,
syncStrategy: 'smartMerge',
},
preferencesFromMemory: false,
});
assert.equal(next.autoSync, false);
assert.equal(next.localVersion, 12);
assert.equal(next.remoteVersion, 11);
});
test('explicit preference persist writes memory autoSync', () => {
const next = resolveSyncConfigForPersist({
memory: { ...memoryBase, autoSync: false },
stored: {
autoSync: true,
interval: 5,
localVersion: 10,
localUpdatedAt: 800,
remoteVersion: 10,
remoteUpdatedAt: 800,
syncStrategy: 'smartMerge',
},
preferencesFromMemory: true,
});
assert.equal(next.autoSync, false);
assert.equal(next.localVersion, 12);
});
test('strategy-only preference persist keeps stored autoSync', () => {
const next = resolveSyncPreferencesForPersist({
memory: {
autoSync: true,
interval: 5,
syncStrategy: 'preferLocal',
},
stored: {
autoSync: false,
interval: 15,
syncStrategy: 'smartMerge',
},
preferencesFromMemory: true,
memoryKeys: ['syncStrategy'],
});
assert.equal(next.autoSync, false);
assert.equal(next.interval, 15);
assert.equal(next.syncStrategy, 'preferLocal');
});
test('autoSync-only preference persist keeps stored syncStrategy', () => {
const next = resolveSyncPreferencesForPersist({
memory: {
autoSync: false,
interval: 5,
syncStrategy: 'smartMerge',
},
stored: {
autoSync: true,
interval: 15,
syncStrategy: 'preferCloud',
},
preferencesFromMemory: true,
memoryKeys: ['autoSync'],
});
assert.equal(next.autoSync, false);
assert.equal(next.interval, 15);
assert.equal(next.syncStrategy, 'preferCloud');
});
test('version-only persist keeps stored syncStrategy when memory differs', () => {
const next = resolveSyncConfigForPersist({
memory: { ...memoryBase, syncStrategy: 'preferLocal' },
stored: {
autoSync: false,
interval: 15,
syncStrategy: 'preferCloud',
},
});
assert.equal(next.autoSync, false);
assert.equal(next.interval, 15);
assert.equal(next.syncStrategy, 'preferCloud');
});
test('coalesce prefers dedicated preferences over legacy SYNC_CONFIG fields', () => {
const coalesced = coalesceStoredSyncPreferences(
{ autoSync: false, interval: 15, syncStrategy: 'preferCloud' },
{ autoSync: true, interval: 5, syncStrategy: 'smartMerge' },
);
assert.equal(coalesced?.autoSync, false);
assert.equal(coalesced?.interval, 15);
});
test('hasSyncPreferenceFields ignores version-only blobs', () => {
assert.equal(hasSyncPreferenceFields(null), false);
assert.equal(hasSyncPreferenceFields({}), false);
assert.equal(hasSyncPreferenceFields({ autoSync: false }), true);
assert.equal(hasSyncPreferenceFields({ interval: 15 }), true);
assert.equal(hasSyncPreferenceFields({ syncStrategy: 'preferCloud' }), true);
});
test('preference and version resolvers stay independent', () => {
const prefs = resolveSyncPreferencesForPersist({
memory: { autoSync: true, interval: 5, syncStrategy: 'smartMerge' },
stored: { autoSync: false, interval: 15, syncStrategy: 'preferCloud' },
preferencesFromMemory: false,
});
const versions = resolveSyncVersionsForPersist({
localVersion: 9,
localUpdatedAt: 1,
remoteVersion: 8,
remoteUpdatedAt: 2,
});
assert.equal(prefs.autoSync, false);
assert.equal(versions.localVersion, 9);
assert.equal('localVersion' in prefs, false);
assert.equal('autoSync' in versions, false);
});

View File

@@ -0,0 +1,132 @@
import {
DEFAULT_CLOUD_SYNC_STRATEGY,
normalizeCloudSyncStrategy,
type CloudSyncStrategy,
} from '../../../domain/syncStrategy';
export type SyncPreferencePersistFields = {
autoSync: boolean;
interval: number;
syncStrategy: CloudSyncStrategy;
};
export type SyncPreferenceKey = keyof SyncPreferencePersistFields;
export type SyncVersionPersistFields = {
localVersion: number;
localUpdatedAt: number;
remoteVersion: number;
remoteUpdatedAt: number;
};
export type SyncConfigPersistFields = SyncPreferencePersistFields & SyncVersionPersistFields;
/**
* Prefer the dedicated preferences key; fall back to legacy fields still
* embedded in SYNC_CONFIG from older builds.
*/
export function coalesceStoredSyncPreferences(
preferences: Partial<SyncPreferencePersistFields> | null | undefined,
legacyConfig: Partial<SyncPreferencePersistFields> | null | undefined,
): Partial<SyncPreferencePersistFields> | null {
if (preferences && typeof preferences === 'object') return preferences;
if (legacyConfig && typeof legacyConfig === 'object') return legacyConfig;
return null;
}
export function hasSyncPreferenceFields(
value: Partial<SyncPreferencePersistFields> | null | undefined,
): boolean {
if (!value || typeof value !== 'object') return false;
return value.autoSync !== undefined
|| value.interval !== undefined
|| value.syncStrategy !== undefined;
}
export function resolveSyncPreferencesForPersist(input: {
memory: SyncPreferencePersistFields;
stored: Partial<SyncPreferencePersistFields> | null | undefined;
/**
* When true, preference fields come from this window's memory — used by
* setAutoSync / setSyncStrategy. When false (default), preference fields
* are taken from storage so a version-only save cannot invent prefs.
*/
preferencesFromMemory?: boolean;
/**
* When preferencesFromMemory is true, only these keys are taken from
* memory; other preference fields keep the stored value so a setter in
* one window cannot clobber a concurrent preference edit from another.
* Omit to take every preference field from memory (full snapshot).
*/
memoryKeys?: readonly SyncPreferenceKey[];
}): SyncPreferencePersistFields {
const { memory, stored, preferencesFromMemory = false, memoryKeys } = input;
const fromStorage = stored && typeof stored === 'object' ? stored : null;
const takeFromMemory = (key: SyncPreferenceKey): boolean => {
if (!fromStorage) return true;
if (!preferencesFromMemory) return false;
if (!memoryKeys) return true;
return memoryKeys.includes(key);
};
return {
autoSync: Boolean(
takeFromMemory('autoSync')
? memory.autoSync
: fromStorage?.autoSync !== undefined
? fromStorage.autoSync
: memory.autoSync,
),
interval: Number(
takeFromMemory('interval')
? memory.interval
: fromStorage?.interval !== undefined
? fromStorage.interval
: memory.interval,
),
syncStrategy: normalizeCloudSyncStrategy(
takeFromMemory('syncStrategy')
? memory.syncStrategy
: fromStorage?.syncStrategy !== undefined
? fromStorage.syncStrategy
: memory.syncStrategy ?? DEFAULT_CLOUD_SYNC_STRATEGY,
),
};
}
export function resolveSyncVersionsForPersist(
memory: SyncVersionPersistFields,
): SyncVersionPersistFields {
return {
localVersion: Number(memory.localVersion),
localUpdatedAt: Number(memory.localUpdatedAt),
remoteVersion: Number(memory.remoteVersion),
remoteUpdatedAt: Number(memory.remoteUpdatedAt),
};
}
/**
* @deprecated Prefer resolveSyncPreferencesForPersist + resolveSyncVersionsForPersist.
* Kept for unit tests that assert the combined legacy shape.
*/
export function resolveSyncConfigForPersist(input: {
memory: SyncConfigPersistFields;
stored: Partial<SyncConfigPersistFields> | null | undefined;
preferencesFromMemory?: boolean;
memoryKeys?: readonly SyncPreferenceKey[];
}): SyncConfigPersistFields {
return {
...resolveSyncPreferencesForPersist({
memory: {
autoSync: input.memory.autoSync,
interval: input.memory.interval,
syncStrategy: input.memory.syncStrategy,
},
stored: input.stored,
preferencesFromMemory: input.preferencesFromMemory,
memoryKeys: input.memoryKeys,
}),
...resolveSyncVersionsForPersist(input.memory),
};
}

View File

@@ -0,0 +1,162 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SYNC_CONSTANTS, SYNC_STORAGE_KEYS } from '../../../domain/sync.ts';
import { handleStorageEventImpl } from './stateAndSecurityMethods.ts';
test('SYNC_PREFERENCES storage event stops auto-sync when another window disables it', () => {
const fakeStorage = {};
const originalWindow = globalThis.window;
let stopCount = 0;
let startCount = 0;
(globalThis as typeof globalThis & { window?: unknown }).window = {
localStorage: fakeStorage,
};
const manager = {
state: {
autoSyncEnabled: true,
autoSyncInterval: 5,
localVersion: 1,
localUpdatedAt: 1,
remoteVersion: 1,
remoteUpdatedAt: 1,
syncStrategy: 'smartMerge',
securityState: 'UNLOCKED',
},
loadFromStorage: () => null,
safeJsonParse: (value: string | null) => (value ? JSON.parse(value) : null),
startAutoSync: () => {
startCount += 1;
},
stopAutoSync: () => {
stopCount += 1;
},
notifyStateChange: () => {},
};
try {
handleStorageEventImpl.call(manager, {
storageArea: fakeStorage,
key: SYNC_STORAGE_KEYS.SYNC_PREFERENCES,
newValue: JSON.stringify({
autoSync: false,
interval: SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
syncStrategy: 'smartMerge',
}),
} as StorageEvent);
} finally {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
}
assert.equal(manager.state.autoSyncEnabled, false);
assert.equal(stopCount, 1);
assert.equal(startCount, 0);
});
test('version-only SYNC_CONFIG storage event does not rewrite preferences', () => {
const fakeStorage = {};
const originalWindow = globalThis.window;
let stopCount = 0;
(globalThis as typeof globalThis & { window?: unknown }).window = {
localStorage: fakeStorage,
};
const manager = {
state: {
autoSyncEnabled: false,
autoSyncInterval: 5,
localVersion: 1,
localUpdatedAt: 1,
remoteVersion: 1,
remoteUpdatedAt: 1,
syncStrategy: 'smartMerge',
securityState: 'UNLOCKED',
},
loadFromStorage: (key: string) => {
if (key === SYNC_STORAGE_KEYS.SYNC_PREFERENCES) {
return { autoSync: false, interval: 5, syncStrategy: 'smartMerge' };
}
return null;
},
safeJsonParse: (value: string | null) => (value ? JSON.parse(value) : null),
startAutoSync: () => {},
stopAutoSync: () => {
stopCount += 1;
},
notifyStateChange: () => {},
};
try {
handleStorageEventImpl.call(manager, {
storageArea: fakeStorage,
key: SYNC_STORAGE_KEYS.SYNC_CONFIG,
newValue: JSON.stringify({
localVersion: 2,
localUpdatedAt: 2,
remoteVersion: 2,
remoteUpdatedAt: 2,
}),
} as StorageEvent);
} finally {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
}
assert.equal(manager.state.autoSyncEnabled, false);
assert.equal(manager.state.localVersion, 2);
assert.equal(stopCount, 0);
});
test('legacy combined SYNC_CONFIG storage event still applies preferences when prefs key absent', () => {
const fakeStorage = {};
const originalWindow = globalThis.window;
let stopCount = 0;
(globalThis as typeof globalThis & { window?: unknown }).window = {
localStorage: fakeStorage,
};
const manager = {
state: {
autoSyncEnabled: true,
autoSyncInterval: 5,
localVersion: 1,
localUpdatedAt: 1,
remoteVersion: 1,
remoteUpdatedAt: 1,
syncStrategy: 'smartMerge',
securityState: 'UNLOCKED',
},
loadFromStorage: () => null,
safeJsonParse: (value: string | null) => (value ? JSON.parse(value) : null),
startAutoSync: () => {},
stopAutoSync: () => {
stopCount += 1;
},
notifyStateChange: () => {},
};
try {
handleStorageEventImpl.call(manager, {
storageArea: fakeStorage,
key: SYNC_STORAGE_KEYS.SYNC_CONFIG,
newValue: JSON.stringify({
autoSync: false,
interval: SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
localVersion: 2,
localUpdatedAt: 2,
remoteVersion: 2,
remoteUpdatedAt: 2,
syncStrategy: 'smartMerge',
}),
} as StorageEvent);
} finally {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
}
assert.equal(manager.state.autoSyncEnabled, false);
assert.equal(manager.state.localVersion, 2);
assert.equal(stopCount, 1);
});

View File

@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EncryptionService } from '../EncryptionService';
import { unlockImpl } from './stateAndSecurityMethods';
for (const scenario of ['rotated-valid-password', 'rotated-invalid-old-password', 'locked-during-unlock'] as const) {
test(`unlock rejects a superseded request instead of reporting a bad password: ${scenario}`, async () => {
const oldConfig = await EncryptionService.createMasterKeyConfig('old-fixture-password');
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
let generation = 0;
const manager = {
state: { masterKeyConfig: oldConfig, securityState: 'LOCKED', unlockedKey: null },
getSyncSecurityGeneration: () => generation,
};
const pending = unlockImpl.call(manager,
scenario === 'rotated-invalid-old-password' ? 'new-fixture-password' : 'old-fixture-password');
if (scenario !== 'locked-during-unlock') manager.state.masterKeyConfig = newConfig;
generation += 1;
await assert.rejects(pending, /changed while unlocking/i);
assert.equal(manager.state.securityState, 'LOCKED');
assert.equal(manager.state.unlockedKey, null);
});
}
test('a wrong password for the current configuration remains an ordinary verification failure', async () => {
const config = await EncryptionService.createMasterKeyConfig('correct-fixture-password');
const manager = { state: { masterKeyConfig: config, securityState: 'LOCKED', unlockedKey: null } };
assert.equal(await unlockImpl.call(manager, 'wrong-fixture-password'), false);
assert.equal(manager.state.unlockedKey, null);
});