[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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createConvergentSyncStateFromPayload,
serializeConvergentSyncState,
validateConvergentSyncPayload,
withConvergentSyncEnvelope,
} from '../../domain/convergentSync/index.ts';
import type { SyncPayload } from '../../domain/sync.ts';
import { EncryptionService } from './EncryptionService.ts';
test('v2 metadata exposes only the schema while the envelope remains encrypted', async () => {
const now = 1_700_000_000_000;
const legacy: SyncPayload = {
hosts: [],
keys: [{
id: 'key-1',
label: 'Secret key',
type: 'ED25519',
privateKey: 'never-plaintext',
source: 'imported',
category: 'key',
created: now,
}],
snippets: [],
customGroups: [],
syncedAt: now,
};
const state = createConvergentSyncStateFromPayload(legacy, 'device-a', now);
const payload = withConvergentSyncEnvelope(state, { syncedAt: now });
const file = await EncryptionService.encryptPayload(
payload,
'correct horse battery staple',
'device-a',
'Device A',
'1.0.0',
);
assert.equal(file.meta.syncSchemaVersion, 2);
assert.equal(JSON.stringify(file.meta).includes('never-plaintext'), false);
assert.equal(file.payload.includes('never-plaintext'), false);
const decrypted = await EncryptionService.decryptPayload(
file,
'correct horse battery staple',
);
assert.equal(
serializeConvergentSyncState(validateConvergentSyncPayload(file.meta, decrypted)!),
serializeConvergentSyncState(state),
);
});

View File

@@ -0,0 +1,450 @@
/**
* EncryptionService - Zero-Knowledge Encryption for Cloud Sync
*
* Implements AES-256-GCM encryption with PBKDF2 key derivation.
* All encryption/decryption happens client-side; cloud providers never see plaintext.
*
* Security Model:
* - Master password → PBKDF2 (600k iterations) → AES-256 key
* - Each sync file has unique IV and salt
* - Key verification via hash comparison (not by storing the key)
*/
import {
SYNC_CONSTANTS,
type EncryptionResult,
type DecryptionInput,
type MasterKeyConfig,
type UnlockedMasterKey,
type SyncedFile,
type SyncFileMeta,
type SyncPayload,
} from '../../domain/sync';
import { validateConvergentSyncPayload } from '../../domain/convergentSync';
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Convert Uint8Array to ArrayBuffer for Web Crypto API compatibility
* TypeScript 5.x requires explicit conversion from Uint8Array<ArrayBufferLike> to BufferSource
*/
const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
};
/**
* Convert ArrayBuffer to Base64 string
*/
export const arrayBufferToBase64 = (buffer: ArrayBuffer | Uint8Array): string => {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
};
/**
* Convert Base64 string to Uint8Array
*/
export const base64ToUint8Array = (base64: string): Uint8Array => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
/**
* Generate cryptographically secure random bytes
*/
export const generateRandomBytes = (length: number): Uint8Array => {
return crypto.getRandomValues(new Uint8Array(length));
};
/**
* Compute SHA-256 hash of data
*/
export const sha256 = async (data: Uint8Array): Promise<Uint8Array> => {
const hashBuffer = await crypto.subtle.digest('SHA-256', toArrayBuffer(data));
return new Uint8Array(hashBuffer);
};
/**
* Convert string to Uint8Array using UTF-8 encoding
*/
const stringToBytes = (str: string): Uint8Array => {
return new TextEncoder().encode(str);
};
/**
* Convert Uint8Array to string using UTF-8 decoding
*/
const bytesToString = (bytes: Uint8Array): string => {
return new TextDecoder().decode(bytes);
};
// ============================================================================
// Key Derivation
// ============================================================================
/**
* Derive an AES-256 key from password using PBKDF2
*
* @param password - User's master password
* @param salt - Random salt (32 bytes recommended)
* @param iterations - PBKDF2 iterations (600000 recommended)
* @returns CryptoKey suitable for AES-256-GCM operations
*/
export const deriveKey = async (
password: string,
salt: Uint8Array,
iterations: number = SYNC_CONSTANTS.PBKDF2_ITERATIONS
): Promise<CryptoKey> => {
// Import password as key material
const passwordKey = await crypto.subtle.importKey(
'raw',
toArrayBuffer(stringToBytes(password)),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
// Derive AES key using PBKDF2
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: toArrayBuffer(salt),
iterations: iterations,
hash: SYNC_CONSTANTS.PBKDF2_HASH,
},
passwordKey,
{
name: 'AES-GCM',
length: SYNC_CONSTANTS.AES_KEY_LENGTH,
},
true, // extractable for verification
['encrypt', 'decrypt']
);
return derivedKey;
};
/**
* Export CryptoKey to raw bytes for verification purposes
*/
export const exportKey = async (key: CryptoKey): Promise<Uint8Array> => {
const exported = await crypto.subtle.exportKey('raw', key);
return new Uint8Array(exported);
};
/**
* Create a verification hash from derived key
* Used to verify correct password without storing the key
*/
export const createVerificationHash = async (derivedKey: CryptoKey): Promise<string> => {
const keyBytes = await exportKey(derivedKey);
const hash = await sha256(keyBytes);
return arrayBufferToBase64(hash);
};
/**
* Verify that a password produces the expected verification hash
*/
export const verifyPassword = async (
password: string,
config: MasterKeyConfig
): Promise<boolean> => {
try {
const salt = base64ToUint8Array(config.salt);
const derivedKey = await deriveKey(
password,
salt,
config.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS
);
const hash = await createVerificationHash(derivedKey);
return hash === config.verificationHash;
} catch {
return false;
}
};
// ============================================================================
// Encryption / Decryption
// ============================================================================
/**
* Encrypt plaintext using AES-256-GCM
*
* @param plaintext - Data to encrypt (as string)
* @param key - AES-256 CryptoKey
* @param salt - Salt used for key derivation (stored in result)
* @returns Encrypted data with IV
*/
export const encrypt = async (
plaintext: string,
key: CryptoKey,
salt: Uint8Array
): Promise<EncryptionResult> => {
// Generate random IV
const iv = generateRandomBytes(SYNC_CONSTANTS.GCM_IV_LENGTH);
// Encrypt
const ciphertextBuffer = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: toArrayBuffer(iv),
tagLength: SYNC_CONSTANTS.GCM_TAG_LENGTH,
},
key,
toArrayBuffer(stringToBytes(plaintext))
);
return {
ciphertext: new Uint8Array(ciphertextBuffer),
iv: iv,
salt: salt,
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
kdfIterations: SYNC_CONSTANTS.PBKDF2_ITERATIONS,
};
};
/**
* Decrypt ciphertext using AES-256-GCM
*
* @param input - Encrypted data with IV
* @param key - AES-256 CryptoKey
* @returns Decrypted plaintext
*/
export const decrypt = async (
input: DecryptionInput,
key: CryptoKey
): Promise<string> => {
const plaintextBuffer = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: toArrayBuffer(input.iv),
tagLength: SYNC_CONSTANTS.GCM_TAG_LENGTH,
},
key,
toArrayBuffer(input.ciphertext)
);
return bytesToString(new Uint8Array(plaintextBuffer));
};
// ============================================================================
// High-Level Encryption API
// ============================================================================
/**
* Encrypt a sync payload to create a SyncedFile
*
* @param payload - Data to encrypt
* @param password - Master password
* @param deviceId - Device identifier
* @param appVersion - App version string
* @returns Complete SyncedFile ready for upload
*/
export const encryptPayload = async (
payload: SyncPayload,
password: string,
deviceId: string,
deviceName: string,
appVersion: string,
existingVersion?: number
): Promise<SyncedFile> => {
const syncSchemaVersion = payload.convergentSync?.schemaVersion;
if (syncSchemaVersion !== undefined) {
validateConvergentSyncPayload(
{ syncSchemaVersion },
payload,
);
}
// Generate new salt for each encryption
const salt = generateRandomBytes(SYNC_CONSTANTS.SALT_LENGTH);
// Derive key from password
const key = await deriveKey(password, salt);
// Encrypt the payload
const plaintext = JSON.stringify(payload);
const encrypted = await encrypt(plaintext, key, salt);
// Create metadata
const meta: SyncFileMeta = {
version: (existingVersion || 0) + 1,
updatedAt: Date.now(),
deviceId: deviceId,
deviceName: deviceName,
appVersion: appVersion,
iv: arrayBufferToBase64(encrypted.iv),
salt: arrayBufferToBase64(encrypted.salt),
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
kdfIterations: SYNC_CONSTANTS.PBKDF2_ITERATIONS,
...(syncSchemaVersion ? { syncSchemaVersion } : {}),
};
return {
meta,
payload: arrayBufferToBase64(encrypted.ciphertext),
};
};
/**
* Decrypt a SyncedFile to retrieve the payload
*
* @param syncedFile - Encrypted file from cloud
* @param password - Master password
* @returns Decrypted payload
*/
export const decryptPayload = async (
syncedFile: SyncedFile,
password: string
): Promise<SyncPayload> => {
const { meta, payload } = syncedFile;
// Decode Base64 values
const salt = base64ToUint8Array(meta.salt);
const iv = base64ToUint8Array(meta.iv);
const ciphertext = base64ToUint8Array(payload);
// Derive key from password
const key = await deriveKey(
password,
salt,
meta.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS
);
// Decrypt
const decrypted = await decrypt(
{ ciphertext, iv, salt, kdf: meta.kdf, kdfIterations: meta.kdfIterations },
key
);
const parsed = JSON.parse(decrypted) as SyncPayload;
validateConvergentSyncPayload(meta, parsed);
return parsed;
};
/**
* Verify a SyncedFile can be decrypted with given password
* Does not return the payload, just validates the password
*/
export const verifySyncedFile = async (
syncedFile: SyncedFile,
password: string
): Promise<boolean> => {
try {
await decryptPayload(syncedFile, password);
return true;
} catch {
return false;
}
};
// ============================================================================
// Master Key Management
// ============================================================================
/**
* Create a new master key configuration
*
* @param password - User's master password
* @returns Configuration to store (contains verification hash, not the key)
*/
export const createMasterKeyConfig = async (
password: string
): Promise<MasterKeyConfig> => {
const salt = generateRandomBytes(SYNC_CONSTANTS.SALT_LENGTH);
const key = await deriveKey(password, salt);
const verificationHash = await createVerificationHash(key);
return {
verificationHash,
salt: arrayBufferToBase64(salt),
kdf: 'PBKDF2',
kdfIterations: SYNC_CONSTANTS.PBKDF2_ITERATIONS,
createdAt: Date.now(),
};
};
/**
* Unlock the master key and return it for use
*
* @param password - User's master password
* @param config - Stored master key configuration
* @returns Unlocked key state (keep in memory only!)
*/
export const unlockMasterKey = async (
password: string,
config: MasterKeyConfig
): Promise<UnlockedMasterKey | null> => {
const isValid = await verifyPassword(password, config);
if (!isValid) return null;
const salt = base64ToUint8Array(config.salt);
const derivedKey = await deriveKey(
password,
salt,
config.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS
);
return {
derivedKey,
salt,
unlockedAt: Date.now(),
};
};
/**
* Change master password
* Requires re-encrypting all synced data with new password
*
* @param oldPassword - Current master password
* @param newPassword - New master password
* @param config - Current master key configuration
* @returns New configuration, or null if old password is wrong
*/
export const changeMasterPassword = async (
oldPassword: string,
newPassword: string,
config: MasterKeyConfig
): Promise<MasterKeyConfig | null> => {
// Verify old password first
const isValid = await verifyPassword(oldPassword, config);
if (!isValid) return null;
// Create new configuration with new password
return createMasterKeyConfig(newPassword);
};
// ============================================================================
// Export Service Class
// ============================================================================
/**
* EncryptionService class - stateless encryption operations
*/
export class EncryptionService {
static deriveKey = deriveKey;
static encrypt = encrypt;
static decrypt = decrypt;
static encryptPayload = encryptPayload;
static decryptPayload = decryptPayload;
static createMasterKeyConfig = createMasterKeyConfig;
static unlockMasterKey = unlockMasterKey;
static changeMasterPassword = changeMasterPassword;
static verifyPassword = verifyPassword;
static createVerificationHash = createVerificationHash;
static generateRandomBytes = generateRandomBytes;
static arrayBufferToBase64 = arrayBufferToBase64;
static base64ToUint8Array = base64ToUint8Array;
}
export default EncryptionService;

View File

@@ -0,0 +1,246 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { downloadGistRevision, downloadSyncGist } from './GitHubAdapter.ts';
import { SYNC_CONSTANTS } from '../../../domain/sync.ts';
type FetchCall = {
url: string;
headers?: HeadersInit;
};
function installFetchMock(
handler: (url: string, init?: RequestInit) => Promise<Response> | Response,
): { calls: FetchCall[]; restore: () => void } {
const calls: FetchCall[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
calls.push({ url, headers: init?.headers });
return handler(url, init);
}) as typeof fetch;
return {
calls,
restore: () => {
globalThis.fetch = original;
},
};
}
const FULL_SYNCED_FILE = {
meta: {
version: 3,
updatedAt: 1_700_000_000_000,
deviceId: 'device-1',
deviceName: 'Test',
},
payload: `ENC:${'A'.repeat(50_000)}`,
};
const FULL_CONTENT = JSON.stringify(FULL_SYNCED_FILE, null, 2);
// Reproduce #2643: Gist API embeds a truncated mid-string payload (~900 KiB cut).
const TRUNCATED_CONTENT = FULL_CONTENT.slice(0, 900);
const utf8ByteLength = (value: string): number =>
new TextEncoder().encode(value).byteLength;
test('downloadSyncGist fetches raw_url when Gist API marks the file truncated', async () => {
const rawUrl = 'https://gist.githubusercontent.com/u/abc/raw/netcatty-vault.json';
const { calls, restore } = installFetchMock((url) => {
if (url.includes('/gists/') && !url.includes('raw')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
content: TRUNCATED_CONTENT,
truncated: true,
raw_url: rawUrl,
size: utf8ByteLength(FULL_CONTENT),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
if (url === rawUrl) {
return new Response(FULL_CONTENT, { status: 200 });
}
return new Response('not found', { status: 404 });
});
try {
const result = await downloadSyncGist('token-xyz', 'gist-1');
assert.deepEqual(result, FULL_SYNCED_FILE);
const rawCall = calls.find((c) => c.url === rawUrl);
assert.ok(rawCall, 'must fetch raw_url for truncated gist');
assert.deepEqual(rawCall.headers, {
Authorization: 'Bearer token-xyz',
Accept: 'application/vnd.github.raw',
});
} finally {
restore();
}
});
test('downloadSyncGist falls back to raw_url when embedded content is incomplete JSON', async () => {
const rawUrl = 'https://gist.githubusercontent.com/u/abc/raw/netcatty-vault.json';
const { restore } = installFetchMock((url) => {
if (url.includes('/gists/') && !url.includes('raw')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
// truncated flag missing (edge case) but content is cut mid-string
content: TRUNCATED_CONTENT,
raw_url: rawUrl,
size: utf8ByteLength(FULL_CONTENT),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
if (url === rawUrl) {
return new Response(FULL_CONTENT, { status: 200 });
}
return new Response('not found', { status: 404 });
});
try {
const result = await downloadSyncGist('token-xyz', 'gist-1');
assert.deepEqual(result, FULL_SYNCED_FILE);
} finally {
restore();
}
});
test('downloadSyncGist uses embedded content when file is not truncated', async () => {
const small = { meta: { version: 1 }, payload: 'small' };
const body = JSON.stringify(small);
let rawFetches = 0;
const { restore } = installFetchMock((url) => {
if (url.includes('/gists/')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
content: body,
truncated: false,
raw_url: 'https://gist.githubusercontent.com/u/abc/raw/unused',
size: utf8ByteLength(body),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
rawFetches += 1;
return new Response('should not fetch', { status: 500 });
});
try {
const result = await downloadSyncGist('token-xyz', 'gist-1');
assert.deepEqual(result, small);
assert.equal(rawFetches, 0);
} finally {
restore();
}
});
test('downloadSyncGist keeps embedded multibyte content without raw fetch', async () => {
const small = {
meta: {
version: 1,
deviceName: '我的电脑',
},
payload: 'small',
};
const body = JSON.stringify(small);
// GitHub reports UTF-8 bytes; string.length is smaller for CJK, which must not
// be treated as truncation.
assert.ok(utf8ByteLength(body) > body.length);
let rawFetches = 0;
const { restore } = installFetchMock((url) => {
if (url.includes('/gists/')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
content: body,
truncated: false,
raw_url: 'https://gist.githubusercontent.com/u/abc/raw/unused',
size: utf8ByteLength(body),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
rawFetches += 1;
return new Response('should not fetch', { status: 500 });
});
try {
const result = await downloadSyncGist('token-xyz', 'gist-1');
assert.deepEqual(result, small);
assert.equal(rawFetches, 0);
} finally {
restore();
}
});
test('downloadGistRevision also recovers truncated content via raw_url', async () => {
const rawUrl = 'https://gist.githubusercontent.com/u/abc/raw/rev/netcatty-vault.json';
const { restore } = installFetchMock((url) => {
if (url.includes('/gists/gist-1/deadbeef')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
content: TRUNCATED_CONTENT,
truncated: true,
raw_url: rawUrl,
size: utf8ByteLength(FULL_CONTENT),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
if (url === rawUrl) {
return new Response(FULL_CONTENT, { status: 200 });
}
return new Response('not found', { status: 404 });
});
try {
const result = await downloadGistRevision('token-xyz', 'gist-1', 'deadbeef');
assert.deepEqual(result, FULL_SYNCED_FILE);
} finally {
restore();
}
});

View File

@@ -0,0 +1,773 @@
/**
* GitHub OAuth Adapter - Device Flow Implementation
*
* Uses Device Authorization Grant (RFC 8628) which doesn't require a client secret.
* Perfect for desktop apps where the secret cannot be securely stored.
*
* Flow:
* 1. Request device code from GitHub
* 2. User opens browser and enters the code
* 3. Poll for access token until user completes auth
* 4. Use Gist API for sync file storage
*/
import {
SYNC_CONSTANTS,
type OAuthTokens,
type ProviderAccount,
type SyncedFile,
type GitHubDeviceCodeResponse,
} from '../../../domain/sync';
import { netcattyBridge } from '../netcattyBridge';
// ============================================================================
// Types
// ============================================================================
export interface GitHubUser {
id: number;
login: string;
name: string | null;
email: string | null;
avatar_url: string;
}
export interface GitHubGistFile {
filename: string;
/** Embedded body from the Gist API. Truncated at ~1 MB when `truncated` is true. */
content?: string;
truncated?: boolean;
raw_url?: string;
/** File size in UTF-8 bytes (GitHub Gist API). */
size?: number;
}
export interface GitHubGist {
id: string;
description: string;
files: Record<string, GitHubGistFile>;
created_at: string;
updated_at: string;
history?: Array<{
version: string;
committed_at: string;
}>;
}
export interface DeviceFlowState {
deviceCode: string;
userCode: string;
verificationUri: string;
expiresAt: number;
interval: number;
authAttemptId?: number;
}
const createGitHubPollId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `github-poll-${Date.now()}-${Math.random().toString(36).slice(2)}`;
};
const createGitHubCancelError = (): Error => {
const error = new Error('GitHub auth cancelled');
error.name = 'AbortError';
return error;
};
const throwIfAborted = (signal?: AbortSignal): void => {
if (signal?.aborted) {
throw createGitHubCancelError();
}
};
const delayWithSignal = (ms: number, signal?: AbortSignal): Promise<void> => {
if (!signal) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(createGitHubCancelError());
return;
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(createGitHubCancelError());
};
signal.addEventListener('abort', onAbort, { once: true });
});
};
// ============================================================================
// Device Flow Authentication
// ============================================================================
/**
* Start GitHub Device Flow authentication
* Returns codes for user to enter in browser
*/
export const startDeviceFlow = async (): Promise<DeviceFlowState> => {
console.log('[GitHub] Starting device flow...');
console.log('[GitHub] Client ID:', SYNC_CONSTANTS.GITHUB_CLIENT_ID);
const bridge = netcattyBridge.get();
if (bridge?.githubStartDeviceFlow) {
return bridge.githubStartDeviceFlow({
clientId: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
scope: 'gist read:user',
});
}
let response: Response;
try {
response = await fetch(SYNC_CONSTANTS.GITHUB_DEVICE_CODE_URL, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
scope: 'gist read:user',
}).toString(),
});
} catch (fetchError) {
console.error('[GitHub] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
console.log('[GitHub] Response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('[GitHub] Error response:', errorText);
throw new Error(`GitHub device flow failed: ${response.status} - ${errorText}`);
}
const data: GitHubDeviceCodeResponse = await response.json();
console.log('[GitHub] Device flow started, user code:', data.user_code);
return {
deviceCode: data.device_code,
userCode: data.user_code,
verificationUri: data.verification_uri,
expiresAt: Date.now() + data.expires_in * 1000,
interval: data.interval,
};
};
/**
* Poll for access token after user authorizes
*/
export const pollForToken = async (
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void,
signal?: AbortSignal
): Promise<OAuthTokens | null> => {
const pollInterval = Math.max(interval, 5) * 1000; // Minimum 5 seconds
const bridge = netcattyBridge.get();
while (Date.now() < expiresAt) {
await delayWithSignal(pollInterval, signal);
throwIfAborted(signal);
const pollId = createGitHubPollId();
const cancelPoll = () => {
void bridge?.githubCancelDeviceFlowPoll?.(pollId);
};
if (signal) {
signal.addEventListener('abort', cancelPoll, { once: true });
}
try {
let data;
try {
data = bridge?.githubPollDeviceFlowToken
? await bridge.githubPollDeviceFlowToken({
clientId: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
deviceCode,
pollId,
})
: await (async () => {
const response = await fetch(SYNC_CONSTANTS.GITHUB_ACCESS_TOKEN_URL, {
method: 'POST',
signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}).toString(),
});
return response.json();
})();
} catch (error) {
if (
signal?.aborted ||
(error instanceof Error &&
(error.name === 'AbortError' || error.message.toLowerCase().includes('abort')))
) {
throw createGitHubCancelError();
}
throw error;
}
throwIfAborted(signal);
if (data.access_token) {
return {
accessToken: data.access_token,
tokenType: data.token_type || 'bearer',
scope: data.scope,
};
}
if (data.error === 'authorization_pending') {
onPending?.();
continue;
}
if (data.error === 'slow_down') {
// Increase interval as requested
await delayWithSignal(5000, signal);
continue;
}
if (data.error === 'expired_token') {
throw new Error('Device code expired. Please try again.');
}
if (data.error === 'access_denied') {
throw new Error('User denied authorization.');
}
if (data.error) {
throw new Error(`GitHub auth error: ${data.error_description || data.error}`);
}
} finally {
if (signal) {
signal.removeEventListener('abort', cancelPoll);
}
}
}
throw new Error('Device code expired. Please try again.');
};
// ============================================================================
// User Info
// ============================================================================
/**
* Get authenticated user info
*/
export const getUserInfo = async (
accessToken: string,
signal?: AbortSignal
): Promise<ProviderAccount> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/user`, {
signal,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error(`Failed to get user info: ${response.statusText}`);
}
const user: GitHubUser = await response.json();
return {
id: String(user.id),
email: user.email || undefined,
name: user.name || user.login,
avatarUrl: user.avatar_url,
};
};
/**
* Validate access token is still valid
*/
export const validateToken = async (accessToken: string): Promise<boolean> => {
try {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/user`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
return response.ok;
} catch {
return false;
}
};
// ============================================================================
// Gist Operations
// ============================================================================
/**
* Find existing Netcatty sync gist
*/
export const findSyncGist = async (
accessToken: string,
signal?: AbortSignal
): Promise<string | null> => {
// List user's gists and find ours
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists?per_page=100`, {
signal,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error(`Failed to list gists: ${response.statusText}`);
}
const gists: GitHubGist[] = await response.json();
const syncGist = gists.find(g =>
g.description === SYNC_CONSTANTS.GIST_DESCRIPTION &&
g.files[SYNC_CONSTANTS.SYNC_FILE_NAME]
);
return syncGist?.id || null;
};
/**
* Create a new sync gist
*/
export const createSyncGist = async (
accessToken: string,
syncedFile: SyncedFile
): Promise<string> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
public: false,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
content: JSON.stringify(syncedFile, null, 2),
},
},
}),
});
if (!response.ok) {
throw new Error(`Failed to create gist: ${response.statusText}`);
}
const gist: GitHubGist = await response.json();
return gist.id;
};
/**
* Update existing sync gist
*/
export const updateSyncGist = async (
accessToken: string,
gistId: string,
syncedFile: SyncedFile
): Promise<void> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
content: JSON.stringify(syncedFile, null, 2),
},
},
}),
});
if (!response.ok) {
throw new Error(`Failed to update gist: ${response.statusText}`);
}
};
/**
* GitHub's Gist REST API truncates each file's embedded `content` around 1 MB
* and sets `truncated: true`. Full bodies must be fetched from `raw_url`
* (issue #2643: JSON.parse on truncated content → "Unterminated string").
*/
const fetchGistRawContent = async (
accessToken: string,
rawUrl: string,
): Promise<string> => {
const bridge = netcattyBridge.get();
if (bridge?.githubDownloadGistRawContent) {
return bridge.githubDownloadGistRawContent({ accessToken, rawUrl });
}
const response = await fetch(rawUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.raw',
},
});
if (!response.ok) {
throw new Error(`Failed to download full gist content: ${response.statusText}`);
}
return response.text();
};
const utf8ByteLength = (value: string): number =>
new TextEncoder().encode(value).byteLength;
const gistFileNeedsRawFetch = (file: GitHubGistFile): boolean => {
if (file.truncated) return true;
// GitHub `size` is UTF-8 bytes; JS string `.length` is UTF-16 code units.
if (
typeof file.size === 'number' &&
typeof file.content === 'string' &&
file.size > utf8ByteLength(file.content)
) {
return true;
}
return false;
};
const parseSyncedFileFromGistFile = async (
accessToken: string,
file: GitHubGistFile | undefined,
): Promise<SyncedFile | null> => {
if (!file) return null;
const tryParse = (raw: string): SyncedFile => JSON.parse(raw) as SyncedFile;
if (gistFileNeedsRawFetch(file)) {
if (!file.raw_url) {
throw new Error(
'GitHub Gist sync file is truncated and no raw URL is available. ' +
'The vault may exceed GitHub Gist size limits.',
);
}
return tryParse(await fetchGistRawContent(accessToken, file.raw_url));
}
if (!file.content) return null;
try {
return tryParse(file.content);
} catch (error) {
// Defensive path for incomplete embedded JSON without truncated=true (#2643).
if (error instanceof SyntaxError && file.raw_url) {
return tryParse(await fetchGistRawContent(accessToken, file.raw_url));
}
throw error;
}
};
/**
* Download sync file from gist
*/
export const downloadSyncGist = async (
accessToken: string,
gistId: string
): Promise<SyncedFile | null> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`Failed to download gist: ${response.statusText}`);
}
const gist: GitHubGist = await response.json();
return parseSyncedFileFromGistFile(accessToken, gist.files[SYNC_CONSTANTS.SYNC_FILE_NAME]);
};
/**
* Delete sync gist
*/
export const deleteSyncGist = async (
accessToken: string,
gistId: string
): Promise<void> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok && response.status !== 404) {
throw new Error(`Failed to delete gist: ${response.statusText}`);
}
};
/**
* Get gist revision history
*/
export const getGistHistory = async (
accessToken: string,
gistId: string
): Promise<Array<{ version: string; date: Date }>> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error(`Failed to get gist history: ${response.statusText}`);
}
const gist: GitHubGist = await response.json();
return (gist.history || []).map(h => ({
version: h.version,
date: new Date(h.committed_at),
}));
};
/**
* Download a specific historical revision of the sync gist.
* Uses `GET /gists/{gist_id}/{sha}` which returns the gist at that point
* in time. Returns the raw SyncedFile (still encrypted) or null if the
* revision does not contain the sync file.
*/
export const downloadGistRevision = async (
accessToken: string,
gistId: string,
sha: string,
): Promise<SyncedFile | null> => {
const response = await fetch(
`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}/${sha}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
},
);
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Failed to download gist revision: ${response.statusText}`);
}
const gist: GitHubGist = await response.json();
return parseSyncedFileFromGistFile(accessToken, gist.files[SYNC_CONSTANTS.SYNC_FILE_NAME]);
};
// ============================================================================
// GitHub Adapter Class
// ============================================================================
export class GitHubAdapter {
private accessToken: string | null = null;
private gistId: string | null = null;
private account: ProviderAccount | null = null;
constructor(tokens?: OAuthTokens, gistId?: string) {
if (tokens) {
this.accessToken = tokens.accessToken;
}
this.gistId = gistId || null;
}
get isAuthenticated(): boolean {
return !!this.accessToken;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.gistId;
}
/**
* Start Device Flow authentication
*/
async startAuth(): Promise<DeviceFlowState> {
return startDeviceFlow();
}
/**
* Complete authentication by polling for token
*/
async completeAuth(
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void,
signal?: AbortSignal
): Promise<OAuthTokens> {
const tokens = await pollForToken(deviceCode, interval, expiresAt, onPending, signal);
if (!tokens) {
throw new Error('Failed to obtain access token');
}
throwIfAborted(signal);
this.accessToken = tokens.accessToken;
this.account = await getUserInfo(tokens.accessToken, signal);
throwIfAborted(signal);
return tokens;
}
/**
* Set tokens from storage
*/
async setTokens(tokens: OAuthTokens): Promise<void> {
this.accessToken = tokens.accessToken;
if (await validateToken(tokens.accessToken)) {
this.account = await getUserInfo(tokens.accessToken);
} else {
throw new Error('Token is invalid or expired');
}
}
/**
* Sign out
*/
signOut(): void {
this.accessToken = null;
this.gistId = null;
this.account = null;
}
/**
* Initialize or find sync gist
*/
async initializeSync(signal?: AbortSignal): Promise<string | null> {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
this.gistId = await findSyncGist(this.accessToken, signal);
return this.gistId;
}
/**
* Upload sync file
*/
async upload(syncedFile: SyncedFile): Promise<string> {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
if (this.gistId) {
await updateSyncGist(this.accessToken, this.gistId, syncedFile);
return this.gistId;
} else {
this.gistId = await createSyncGist(this.accessToken, syncedFile);
return this.gistId;
}
}
/**
* Download sync file
*/
async download(): Promise<SyncedFile | null> {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
if (!this.gistId) {
this.gistId = await findSyncGist(this.accessToken);
}
if (!this.gistId) {
return null;
}
return downloadSyncGist(this.accessToken, this.gistId);
}
/**
* Delete sync data
*/
async deleteSync(): Promise<void> {
if (!this.accessToken || !this.gistId) {
return;
}
await deleteSyncGist(this.accessToken, this.gistId);
this.gistId = null;
}
/**
* Get revision history for the sync gist. Lazily discovers the gist
* ID if it hasn't been resolved yet (same pattern as `download()`).
*/
async getHistory(): Promise<Array<{ version: string; date: Date }>> {
if (!this.accessToken) return [];
if (!this.gistId) {
this.gistId = await findSyncGist(this.accessToken);
}
if (!this.gistId) return [];
return getGistHistory(this.accessToken, this.gistId);
}
/**
* Download a specific historical revision of the sync gist (still
* encrypted — the caller must decrypt it). Lazily discovers the
* gist ID if needed.
*/
async downloadRevision(sha: string): Promise<SyncedFile | null> {
if (!this.accessToken) return null;
if (!this.gistId) {
this.gistId = await findSyncGist(this.accessToken);
}
if (!this.gistId) return null;
return downloadGistRevision(this.accessToken, this.gistId, sha);
}
/**
* Get tokens for storage
*/
getTokens(): OAuthTokens | null {
if (!this.accessToken) return null;
return {
accessToken: this.accessToken,
tokenType: 'bearer',
};
}
}
export default GitHubAdapter;

View File

@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { GoogleDriveAdapter } from './GoogleDriveAdapter.ts';
import type { OAuthTokens } from '../../../domain/sync.ts';
type WindowGlobal = typeof globalThis & { window?: unknown };
function setBridge(bridge: Record<string, unknown>): () => void {
const g = globalThis as WindowGlobal;
const original = g.window;
// Loosely typed: the real window.netcatty is a large NetcattyBridge; tests
// only stub the handful of Google methods the adapter actually calls.
g.window = { netcatty: bridge } as unknown as Window & typeof globalThis;
return () => {
g.window = original;
};
}
const expiredTokens = (): OAuthTokens => ({
accessToken: 'old-access',
refreshToken: 'old-refresh',
// Already expired so any operation forces a refresh.
expiresAt: Date.now() - 60_000,
tokenType: 'Bearer',
});
test('refreshing tokens during an operation fires the persistence callback with refreshed tokens', async () => {
const refreshed: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
let refreshCalledWith: string | undefined;
const remoteSyncedFile = { meta: { version: 1 }, payload: 'x' };
const restore = setBridge({
googleRefreshAccessToken: async ({ refreshToken }: { refreshToken: string }) => {
refreshCalledWith = refreshToken;
return refreshed;
},
googleDriveDownloadSyncFile: async ({ accessToken }: { accessToken: string }) => {
// Operation must run with the refreshed access token.
assert.equal(accessToken, 'fresh-access');
return { syncedFile: remoteSyncedFile };
},
});
try {
const adapter = new GoogleDriveAdapter(expiredTokens(), 'file-1');
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
const result = await adapter.download();
assert.deepEqual(result, remoteSyncedFile);
assert.equal(refreshCalledWith, 'old-refresh');
assert.equal(persisted.length, 1);
assert.deepEqual(persisted[0], refreshed);
// Adapter also exposes the refreshed tokens for the caller to persist.
assert.deepEqual(adapter.getTokens(), refreshed);
} finally {
restore();
}
});
test('refresh preserves the prior refresh token when Google omits a new one', async () => {
// Google's refresh response frequently has no refresh_token. The persisted
// tokens must keep the previous refresh token, or the connection becomes
// unrefreshable on the next launch.
const refreshedWithoutRefreshToken: OAuthTokens = {
accessToken: 'fresh-access',
// No refreshToken — mirrors a real Google refresh_token response.
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const remoteSyncedFile = { meta: { version: 1 }, payload: 'x' };
const restore = setBridge({
googleRefreshAccessToken: async () => refreshedWithoutRefreshToken,
googleDriveDownloadSyncFile: async () => ({ syncedFile: remoteSyncedFile }),
});
try {
const adapter = new GoogleDriveAdapter(expiredTokens(), 'file-1');
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
await adapter.download();
assert.equal(persisted.length, 1);
// The persisted (and in-memory) tokens carry the original refresh token.
assert.equal(persisted[0].refreshToken, 'old-refresh');
assert.equal(persisted[0].accessToken, 'fresh-access');
assert.equal(adapter.getTokens()?.refreshToken, 'old-refresh');
} finally {
restore();
}
});
test('setTokens refreshes an expired token and persists the refreshed tokens', async () => {
const refreshed: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const restore = setBridge({
googleRefreshAccessToken: async () => refreshed,
googleGetUserInfo: async () => ({
id: 'u1',
email: 'u@example.com',
name: 'User',
picture: '',
}),
});
try {
const adapter = new GoogleDriveAdapter();
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
await adapter.setTokens(expiredTokens());
assert.deepEqual(persisted, [refreshed]);
assert.deepEqual(adapter.getTokens(), refreshed);
assert.equal(adapter.accountInfo?.id, 'u1');
} finally {
restore();
}
});
test('a persistence callback that throws does not abort the operation', async () => {
const refreshed: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const remoteSyncedFile = { meta: { version: 1 }, payload: 'x' };
const restore = setBridge({
googleRefreshAccessToken: async () => refreshed,
googleDriveDownloadSyncFile: async () => ({ syncedFile: remoteSyncedFile }),
});
try {
const adapter = new GoogleDriveAdapter(expiredTokens(), 'file-1');
adapter.setOnTokensRefreshed(() => {
throw new Error('persist boom');
});
// Refresh succeeds, the throwing callback is swallowed, the op completes.
const result = await adapter.download();
assert.deepEqual(result, remoteSyncedFile);
assert.deepEqual(adapter.getTokens(), refreshed);
} finally {
restore();
}
});
test('signOut clears the refresh persistence callback', async () => {
const refreshed: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const restore = setBridge({
googleRefreshAccessToken: async () => refreshed,
googleGetUserInfo: async () => ({
id: 'u1',
email: 'u@example.com',
name: 'User',
picture: '',
}),
});
try {
const adapter = new GoogleDriveAdapter(expiredTokens(), 'file-1');
let callbackFired = false;
adapter.setOnTokensRefreshed(() => {
callbackFired = true;
});
adapter.signOut();
// Re-arm tokens and refresh; the callback must not fire after signOut.
await adapter.setTokens(expiredTokens());
assert.equal(callbackFired, false);
} finally {
restore();
}
});

View File

@@ -0,0 +1,701 @@
/**
* Google Drive OAuth Adapter - PKCE Loopback Flow
*
* Uses Authorization Code Grant with PKCE (RFC 7636) and loopback redirect.
* Data is stored in appDataFolder (hidden, app-specific folder).
*
* Flow:
* 1. Generate PKCE challenge
* 2. Open browser with auth URL
* 3. User authorizes, redirected to loopback
* 4. Exchange code for tokens
* 5. Use Drive API to manage sync file
*/
import {
SYNC_CONSTANTS,
type OAuthTokens,
type ProviderAccount,
type SyncedFile,
type PKCEChallenge,
} from '../../../domain/sync';
import { arrayBufferToBase64, generateRandomBytes } from '../EncryptionService';
import { netcattyBridge } from '../netcattyBridge';
// ============================================================================
// Types
// ============================================================================
export interface GoogleUserInfo {
id: string;
email: string;
name: string;
picture: string;
}
export interface DriveFile {
id: string;
name: string;
modifiedTime: string;
size?: string;
}
// ============================================================================
// PKCE Utilities
// ============================================================================
/**
* Generate a cryptographically random code verifier
*/
const generateCodeVerifier = (): string => {
const bytes = generateRandomBytes(32);
return base64UrlEncode(bytes);
};
/**
* Generate code challenge from verifier (S256)
*/
const generateCodeChallenge = async (verifier: string): Promise<string> => {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(digest));
};
/**
* Base64 URL encoding (no padding, URL-safe chars)
*/
const base64UrlEncode = (bytes: Uint8Array): string => {
const base64 = arrayBufferToBase64(bytes);
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};
/**
* Generate PKCE challenge
*/
export const generatePKCEChallenge = async (): Promise<PKCEChallenge> => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = base64UrlEncode(generateRandomBytes(16));
return {
codeVerifier,
codeChallenge,
state,
};
};
// ============================================================================
// OAuth Flow
// ============================================================================
/**
* Build authorization URL for Google OAuth
*/
export const buildAuthUrl = async (
redirectUri: string
): Promise<{ url: string; pkce: PKCEChallenge }> => {
const pkce = await generatePKCEChallenge();
const params = new URLSearchParams({
client_id: SYNC_CONSTANTS.GOOGLE_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/drive.appdata https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email',
code_challenge: pkce.codeChallenge,
code_challenge_method: 'S256',
state: pkce.state,
access_type: 'offline',
prompt: 'consent',
});
return {
url: `${SYNC_CONSTANTS.GOOGLE_AUTH_URL}?${params.toString()}`,
pkce,
};
};
/**
* Exchange authorization code for tokens
*/
export const exchangeCodeForTokens = async (
code: string,
codeVerifier: string,
redirectUri: string
): Promise<OAuthTokens> => {
const bridge = netcattyBridge.get();
const exchangeViaMain = bridge?.googleExchangeCodeForTokens;
if (!exchangeViaMain) {
throw new Error(
'Google OAuth bridge unavailable (token exchange is blocked by CORS in renderer). Please restart Netcatty.'
);
}
return await exchangeViaMain({
clientId: SYNC_CONSTANTS.GOOGLE_CLIENT_ID,
clientSecret: SYNC_CONSTANTS.GOOGLE_CLIENT_SECRET,
code,
codeVerifier,
redirectUri,
});
};
/**
* Refresh access token
*/
export const refreshAccessToken = async (refreshToken: string): Promise<OAuthTokens> => {
const bridge = netcattyBridge.get();
const refreshViaMain = bridge?.googleRefreshAccessToken;
if (!refreshViaMain) {
throw new Error(
'Google OAuth bridge unavailable (token refresh is blocked by CORS in renderer). Please restart Netcatty.'
);
}
return await refreshViaMain({
clientId: SYNC_CONSTANTS.GOOGLE_CLIENT_ID,
clientSecret: SYNC_CONSTANTS.GOOGLE_CLIENT_SECRET,
refreshToken,
});
};
// ============================================================================
// User Info
// ============================================================================
/**
* Get authenticated user info
*/
export const getUserInfo = async (accessToken: string): Promise<ProviderAccount> => {
const bridge = netcattyBridge.get();
const userInfoViaMain = bridge?.googleGetUserInfo;
if (userInfoViaMain) {
const user = await userInfoViaMain({ accessToken });
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.picture,
};
}
const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to get user info');
}
const user: GoogleUserInfo = await response.json();
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.picture,
};
};
/**
* Validate access token
*/
export const validateToken = async (accessToken: string): Promise<boolean> => {
try {
const bridge = netcattyBridge.get();
const userInfoViaMain = bridge?.googleGetUserInfo;
if (userInfoViaMain) {
await userInfoViaMain({ accessToken });
return true;
}
const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
return response.ok;
} catch {
return false;
}
};
// ============================================================================
// Drive Operations (appDataFolder)
// ============================================================================
/**
* Find sync file in appDataFolder
*/
export const findSyncFile = async (accessToken: string): Promise<string | null> => {
const bridge = netcattyBridge.get();
const findViaMain = bridge?.googleDriveFindSyncFile;
if (findViaMain) {
const { fileId } = await findViaMain({
accessToken,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
});
return fileId || null;
}
const params = new URLSearchParams({
spaces: 'appDataFolder',
q: `name = '${SYNC_CONSTANTS.SYNC_FILE_NAME}'`,
fields: 'files(id, name, modifiedTime)',
});
const url = `${SYNC_CONSTANTS.GOOGLE_DRIVE_API}/files?${params.toString()}`;
console.log('[GoogleDrive] Searching for sync file:', url);
let response: Response;
try {
response = await fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[GoogleDrive] API error:', response.status, errorData);
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Drive API error: ${errorData.error?.message || response.status}`);
}
const data = await response.json();
console.log('[GoogleDrive] Found files:', data.files?.length || 0);
return data.files?.[0]?.id || null;
};
/**
* Create sync file in appDataFolder
*/
export const createSyncFile = async (
accessToken: string,
syncedFile: SyncedFile
): Promise<string> => {
const bridge = netcattyBridge.get();
const createViaMain = bridge?.googleDriveCreateSyncFile;
if (createViaMain) {
const { fileId } = await createViaMain({
accessToken,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
syncedFile,
});
return fileId;
}
const metadata = {
name: SYNC_CONSTANTS.SYNC_FILE_NAME,
parents: ['appDataFolder'],
};
const form = new FormData();
form.append(
'metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
);
form.append(
'file',
new Blob([JSON.stringify(syncedFile, null, 2)], { type: 'application/json' })
);
let response: Response;
try {
response = await fetch(
`${SYNC_CONSTANTS.GOOGLE_DRIVE_API.replace('/v3', '/upload/v3')}/files?uploadType=multipart`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
body: form,
}
);
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Failed to create file: ${errorData.error?.message || response.status}`);
}
const data = await response.json();
return data.id;
};
/**
* Update sync file
*/
export const updateSyncFile = async (
accessToken: string,
fileId: string,
syncedFile: SyncedFile
): Promise<void> => {
const bridge = netcattyBridge.get();
const updateViaMain = bridge?.googleDriveUpdateSyncFile;
if (updateViaMain) {
await updateViaMain({ accessToken, fileId, syncedFile });
return;
}
let response: Response;
try {
response = await fetch(
`${SYNC_CONSTANTS.GOOGLE_DRIVE_API.replace('/v3', '/upload/v3')}/files/${fileId}?uploadType=media`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(syncedFile, null, 2),
}
);
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Failed to update file: ${errorData.error?.message || response.status}`);
}
};
/**
* Download sync file
*/
export const downloadSyncFile = async (
accessToken: string,
fileId: string
): Promise<SyncedFile | null> => {
const bridge = netcattyBridge.get();
const downloadViaMain = bridge?.googleDriveDownloadSyncFile;
if (downloadViaMain) {
const { syncedFile } = await downloadViaMain({ accessToken, fileId });
return (syncedFile as SyncedFile | null) || null;
}
let response: Response;
try {
response = await fetch(
`${SYNC_CONSTANTS.GOOGLE_DRIVE_API}/files/${fileId}?alt=media`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
);
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok) {
if (response.status === 404) {
return null;
}
const errorData = await response.json().catch(() => ({}));
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Failed to download file: ${errorData.error?.message || response.status}`);
}
return response.json();
};
/**
* Delete sync file
*/
export const deleteSyncFile = async (
accessToken: string,
fileId: string
): Promise<void> => {
const bridge = netcattyBridge.get();
const deleteViaMain = bridge?.googleDriveDeleteSyncFile;
if (deleteViaMain) {
await deleteViaMain({ accessToken, fileId });
return;
}
let response: Response;
try {
response = await fetch(`${SYNC_CONSTANTS.GOOGLE_DRIVE_API}/files/${fileId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok && response.status !== 404) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Failed to delete file: ${errorData.error?.message || response.status}`);
}
};
// ============================================================================
// Google Drive Adapter Class
// ============================================================================
export class GoogleDriveAdapter {
private tokens: OAuthTokens | null = null;
private fileId: string | null = null;
private account: ProviderAccount | null = null;
private pkceChallenge: PKCEChallenge | null = null;
/**
* Invoked whenever the access token is silently refreshed. Lets the owner
* (CloudSyncManager) persist the rotated tokens so the next launch doesn't
* load a stale access token and force a reconnect. Mirrors the OneDrive fix
* (#1189 / #1208); Google differs in that its refresh response usually omits a
* new refresh token, so refreshTokens() carries the previous one forward.
*/
private onTokensRefreshed: ((tokens: OAuthTokens) => void) | null = null;
constructor(tokens?: OAuthTokens, fileId?: string) {
if (tokens) {
this.tokens = tokens;
}
this.fileId = fileId || null;
}
/**
* Register a callback that receives refreshed tokens so the caller can
* persist them. Passing null removes the callback.
*/
setOnTokensRefreshed(callback: ((tokens: OAuthTokens) => void) | null): void {
this.onTokensRefreshed = callback;
}
/**
* Refresh the access token using the supplied refresh token, store the
* rotated tokens in-memory, and notify the persistence callback. Google
* typically returns the same (or no) refresh token on refresh, so the prior
* refresh token is preserved when the response omits one — otherwise the
* persisted credentials would lose the ability to refresh again and force a
* reconnect on the next launch.
*/
private async refreshTokens(refreshToken: string): Promise<OAuthTokens> {
const refreshed = await refreshAccessToken(refreshToken);
// Google's refresh response frequently omits refresh_token (it does not
// rotate on every refresh). Never let a missing value clobber the working
// refresh token, or the persisted connection becomes unrefreshable.
const merged: OAuthTokens = {
...refreshed,
refreshToken: refreshed.refreshToken || refreshToken,
};
this.tokens = merged;
try {
this.onTokensRefreshed?.(merged);
} catch {
// Persistence is best-effort; a failed save must not abort the sync that
// triggered the refresh — the fresh tokens still work for this session.
}
return merged;
}
get isAuthenticated(): boolean {
return !!this.tokens?.accessToken;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.fileId;
}
/**
* Start OAuth flow - returns URL to open in browser
*/
async startAuth(redirectUri: string): Promise<string> {
const { url, pkce } = await buildAuthUrl(redirectUri);
this.pkceChallenge = pkce;
return url;
}
/**
* Get PKCE state for verification
*/
getPKCEState(): string | null {
return this.pkceChallenge?.state || null;
}
/**
* Complete authentication with authorization code
*/
async completeAuth(code: string, redirectUri: string): Promise<OAuthTokens> {
if (!this.pkceChallenge) {
throw new Error('No PKCE challenge - start auth first');
}
this.tokens = await exchangeCodeForTokens(
code,
this.pkceChallenge.codeVerifier,
redirectUri
);
this.pkceChallenge = null;
this.account = await getUserInfo(this.tokens.accessToken);
return this.tokens;
}
/**
* Set tokens from storage
*/
async setTokens(tokens: OAuthTokens): Promise<void> {
this.tokens = tokens;
// Refresh if expired
if (tokens.expiresAt && Date.now() > tokens.expiresAt - 60000) {
if (tokens.refreshToken) {
this.tokens = await this.refreshTokens(tokens.refreshToken);
} else {
throw new Error('Token expired and no refresh token');
}
}
if (await validateToken(this.tokens.accessToken)) {
this.account = await getUserInfo(this.tokens.accessToken);
} else {
throw new Error('Token is invalid');
}
}
/**
* Ensure token is fresh
*/
private async ensureValidToken(): Promise<string> {
if (!this.tokens) {
throw new Error('Not authenticated');
}
if (this.tokens.expiresAt && Date.now() > this.tokens.expiresAt - 60000) {
if (this.tokens.refreshToken) {
this.tokens = await this.refreshTokens(this.tokens.refreshToken);
} else {
throw new Error('Token expired');
}
}
return this.tokens.accessToken;
}
/**
* Sign out
*/
signOut(): void {
this.tokens = null;
this.fileId = null;
this.account = null;
this.pkceChallenge = null;
this.onTokensRefreshed = null;
}
/**
* Initialize or find sync file
*/
async initializeSync(): Promise<string | null> {
const accessToken = await this.ensureValidToken();
this.fileId = await findSyncFile(accessToken);
return this.fileId;
}
/**
* Upload sync file
*/
async upload(syncedFile: SyncedFile): Promise<string> {
const accessToken = await this.ensureValidToken();
if (this.fileId) {
await updateSyncFile(accessToken, this.fileId, syncedFile);
return this.fileId;
} else {
this.fileId = await createSyncFile(accessToken, syncedFile);
return this.fileId;
}
}
/**
* Download sync file
*/
async download(): Promise<SyncedFile | null> {
const accessToken = await this.ensureValidToken();
if (!this.fileId) {
this.fileId = await findSyncFile(accessToken);
}
if (!this.fileId) {
return null;
}
return downloadSyncFile(accessToken, this.fileId);
}
/**
* Delete sync data
*/
async deleteSync(): Promise<void> {
if (!this.tokens || !this.fileId) {
return;
}
const accessToken = await this.ensureValidToken();
await deleteSyncFile(accessToken, this.fileId);
this.fileId = null;
}
/**
* Get tokens for storage
*/
getTokens(): OAuthTokens | null {
return this.tokens;
}
}
export default GoogleDriveAdapter;

View File

@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
OneDriveAdapter,
OneDriveReauthRequiredError,
isOneDriveReauthRequiredError,
} from './OneDriveAdapter.ts';
import {
ONEDRIVE_REAUTH_REQUIRED_MARKER,
cleanOneDriveErrorMessage,
type OAuthTokens,
} from '../../../domain/sync.ts';
type WindowGlobal = typeof globalThis & { window?: unknown };
function setBridge(bridge: Record<string, unknown>): () => void {
const g = globalThis as WindowGlobal;
const original = g.window;
// Loosely typed: the real window.netcatty is a large NetcattyBridge; tests
// only stub the handful of OneDrive methods the adapter actually calls.
g.window = { netcatty: bridge } as unknown as Window & typeof globalThis;
return () => {
g.window = original;
};
}
const expiredTokens = (): OAuthTokens => ({
accessToken: 'old-access',
refreshToken: 'old-refresh',
// Already expired so any operation forces a refresh.
expiresAt: Date.now() - 60_000,
tokenType: 'Bearer',
});
test('isOneDriveReauthRequiredError detects the marker and the error class', () => {
assert.equal(isOneDriveReauthRequiredError(new OneDriveReauthRequiredError()), true);
assert.equal(
isOneDriveReauthRequiredError(new Error(`${ONEDRIVE_REAUTH_REQUIRED_MARKER}: x`)),
true,
);
// Survives re-wrapping the way the sync pipeline re-wraps errors.
const wrapped = new Error(String(new OneDriveReauthRequiredError('boom')));
assert.equal(isOneDriveReauthRequiredError(wrapped), true);
assert.equal(isOneDriveReauthRequiredError(new Error('network down')), false);
});
test('cleanOneDriveErrorMessage strips the marker and wrapping prefixes', () => {
const wrapped = `Error: OneDriveReauthRequiredError: ${ONEDRIVE_REAUTH_REQUIRED_MARKER}: OneDrive session expired, please reconnect. (AADSTS70000)`;
assert.equal(
cleanOneDriveErrorMessage(wrapped),
'OneDrive session expired, please reconnect. (AADSTS70000)',
);
// No marker -> returned unchanged.
assert.equal(cleanOneDriveErrorMessage('plain network error'), 'plain network error');
});
test('OneDriveReauthRequiredError always carries the marker in its message', () => {
assert.ok(new OneDriveReauthRequiredError('hi').message.includes(ONEDRIVE_REAUTH_REQUIRED_MARKER));
// Does not double-prefix when the marker is already present.
const once = new OneDriveReauthRequiredError(`${ONEDRIVE_REAUTH_REQUIRED_MARKER}: hi`).message;
assert.equal(once.indexOf(ONEDRIVE_REAUTH_REQUIRED_MARKER), once.lastIndexOf(ONEDRIVE_REAUTH_REQUIRED_MARKER));
});
test('refreshing tokens during an operation fires the persistence callback with rotated tokens', async () => {
const rotated: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'rotated-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
let refreshCalledWith: string | undefined;
// Returning a non-null synced file avoids the eventual-consistency retry loop
// (retryOnNotFound) that backs off on a null result and would slow the test.
const remoteSyncedFile = { meta: { version: 1 }, payload: 'x' };
const restore = setBridge({
onedriveRefreshAccessToken: async ({ refreshToken }: { refreshToken: string }) => {
refreshCalledWith = refreshToken;
return rotated;
},
onedriveDownloadSyncFile: async ({ accessToken }: { accessToken: string }) => {
// Operation must run with the refreshed access token.
assert.equal(accessToken, 'fresh-access');
return { syncedFile: remoteSyncedFile };
},
});
try {
const adapter = new OneDriveAdapter(expiredTokens(), 'file-1');
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
const result = await adapter.download();
assert.deepEqual(result, remoteSyncedFile);
assert.equal(refreshCalledWith, 'old-refresh');
assert.equal(persisted.length, 1);
assert.deepEqual(persisted[0], rotated);
// Adapter also exposes the rotated tokens for the caller to persist.
assert.deepEqual(adapter.getTokens(), rotated);
} finally {
restore();
}
});
test('a dead refresh token surfaces OneDriveReauthRequiredError and does not fire the callback', async () => {
const restore = setBridge({
onedriveRefreshAccessToken: async () => {
throw new Error(
`${ONEDRIVE_REAUTH_REQUIRED_MARKER}: OneDrive session expired, please reconnect. (AADSTS70000)`,
);
},
});
try {
const adapter = new OneDriveAdapter(expiredTokens(), 'file-1');
let callbackFired = false;
adapter.setOnTokensRefreshed(() => {
callbackFired = true;
});
await assert.rejects(
() => adapter.download(),
(err) => {
assert.equal(isOneDriveReauthRequiredError(err), true);
assert.ok(err instanceof OneDriveReauthRequiredError);
return true;
},
);
assert.equal(callbackFired, false);
} finally {
restore();
}
});
test('setTokens refreshes an expired token and persists the rotated tokens', async () => {
const rotated: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'rotated-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const restore = setBridge({
onedriveRefreshAccessToken: async () => rotated,
onedriveGetUserInfo: async () => ({ id: 'u1', email: 'u@example.com', name: 'User' }),
});
try {
const adapter = new OneDriveAdapter();
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
await adapter.setTokens(expiredTokens());
assert.deepEqual(persisted, [rotated]);
assert.deepEqual(adapter.getTokens(), rotated);
assert.equal(adapter.accountInfo?.id, 'u1');
} finally {
restore();
}
});
test('setTokens with an expired token and no refresh token requires reconnect', async () => {
const restore = setBridge({});
try {
const adapter = new OneDriveAdapter();
await assert.rejects(
() =>
adapter.setTokens({
accessToken: 'old-access',
expiresAt: Date.now() - 60_000,
tokenType: 'Bearer',
}),
(err) => {
assert.equal(isOneDriveReauthRequiredError(err), true);
return true;
},
);
} finally {
restore();
}
});
test('signOut clears the refresh persistence callback', async () => {
const rotated: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'rotated-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const restore = setBridge({
onedriveRefreshAccessToken: async () => rotated,
onedriveGetUserInfo: async () => ({ id: 'u1', email: 'u@example.com', name: 'User' }),
});
try {
const adapter = new OneDriveAdapter(expiredTokens(), 'file-1');
let callbackFired = false;
adapter.setOnTokensRefreshed(() => {
callbackFired = true;
});
adapter.signOut();
// Re-arm tokens and refresh; the callback must not fire after signOut.
await adapter.setTokens(expiredTokens());
assert.equal(callbackFired, false);
} finally {
restore();
}
});

View File

@@ -0,0 +1,759 @@
/**
* OneDrive OAuth Adapter - PKCE Loopback Flow with MSAL
*
* Uses MSAL-style Authorization Code Grant with PKCE.
* Data is stored in the app's special folder.
*
* Flow:
* 1. Generate PKCE challenge
* 2. Open browser with auth URL
* 3. User authorizes, redirected to loopback
* 4. Exchange code for tokens
* 5. Use Graph API to manage sync file
*/
import {
SYNC_CONSTANTS,
ONEDRIVE_REAUTH_REQUIRED_MARKER,
isOneDriveReauthRequiredMessage,
type OAuthTokens,
type ProviderAccount,
type SyncedFile,
type PKCEChallenge,
} from '../../../domain/sync';
import { netcattyBridge } from '../netcattyBridge';
import { arrayBufferToBase64, generateRandomBytes } from '../EncryptionService';
// ============================================================================
// Types
// ============================================================================
export interface OneDriveUserInfo {
id: string;
displayName: string;
mail?: string;
userPrincipalName: string;
}
export interface DriveItem {
id: string;
name: string;
lastModifiedDateTime: string;
size?: number;
'@microsoft.graph.downloadUrl'?: string;
}
const ONEDRIVE_SCOPES = [
'https://graph.microsoft.com/Files.ReadWrite.AppFolder',
'https://graph.microsoft.com/User.Read',
'offline_access',
];
const ONEDRIVE_SCOPE = ONEDRIVE_SCOPES.join(' ');
/**
* Raised when the OneDrive refresh token can no longer be exchanged for a new
* access token (expired / revoked / consent withdrawn). The user must
* re-authorize; silent refresh cannot recover. CloudSyncManager detects this to
* surface a clear "reconnect" state instead of a raw error.
*
* The message always carries ONEDRIVE_REAUTH_REQUIRED_MARKER so the condition is
* still detectable after the error is re-wrapped (e.g. `new Error(String(err))`)
* as it bubbles through the provider-agnostic sync pipeline.
*/
export class OneDriveReauthRequiredError extends Error {
constructor(message = 'OneDrive session expired, please reconnect.') {
super(
isOneDriveReauthRequiredMessage(message)
? message
: `${ONEDRIVE_REAUTH_REQUIRED_MARKER}: ${message}`
);
this.name = 'OneDriveReauthRequiredError';
}
}
export const isOneDriveReauthRequiredError = (error: unknown): boolean => {
if (error instanceof OneDriveReauthRequiredError) {
return true;
}
const message = error instanceof Error ? error.message : String(error);
return isOneDriveReauthRequiredMessage(message);
};
const isUnauthorizedError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
return message.includes(' 401') ||
message.includes('401 -') ||
lower.includes('unauthenticated') ||
lower.includes('invalidauthenticationtoken');
};
// ============================================================================
// PKCE Utilities
// ============================================================================
/**
* Base64 URL encoding (no padding, URL-safe chars)
*/
const base64UrlEncode = (bytes: Uint8Array): string => {
const base64 = arrayBufferToBase64(bytes);
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};
/**
* Generate a cryptographically random code verifier
*/
const generateCodeVerifier = (): string => {
const bytes = generateRandomBytes(32);
return base64UrlEncode(bytes);
};
/**
* Generate code challenge from verifier (S256)
*/
const generateCodeChallenge = async (verifier: string): Promise<string> => {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(digest));
};
/**
* Generate PKCE challenge
*/
export const generatePKCEChallenge = async (): Promise<PKCEChallenge> => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = base64UrlEncode(generateRandomBytes(16));
return {
codeVerifier,
codeChallenge,
state,
};
};
// ============================================================================
// OAuth Flow
// ============================================================================
/**
* Build authorization URL for OneDrive OAuth
*/
export const buildAuthUrl = async (
redirectUri: string
): Promise<{ url: string; pkce: PKCEChallenge }> => {
const pkce = await generatePKCEChallenge();
const params = new URLSearchParams({
client_id: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
scope: ONEDRIVE_SCOPE,
code_challenge: pkce.codeChallenge,
code_challenge_method: 'S256',
state: pkce.state,
response_mode: 'query',
prompt: 'consent',
});
return {
url: `${SYNC_CONSTANTS.ONEDRIVE_AUTH_URL}?${params.toString()}`,
pkce,
};
};
/**
* Exchange authorization code for tokens
*/
export const exchangeCodeForTokens = async (
code: string,
codeVerifier: string,
redirectUri: string
): Promise<OAuthTokens> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveExchangeCodeForTokens) {
return bridge.onedriveExchangeCodeForTokens({
clientId: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
code,
codeVerifier,
redirectUri,
scope: ONEDRIVE_SCOPE,
});
}
const response = await fetch(SYNC_CONSTANTS.ONEDRIVE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
scope: ONEDRIVE_SCOPE,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token exchange failed: ${error.error_description || error.error}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
tokenType: data.token_type,
scope: data.scope,
};
};
/**
* Refresh access token
*/
export const refreshAccessToken = async (refreshToken: string): Promise<OAuthTokens> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveRefreshAccessToken) {
return bridge.onedriveRefreshAccessToken({
clientId: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
refreshToken,
scope: ONEDRIVE_SCOPE,
});
}
const response = await fetch(SYNC_CONSTANTS.ONEDRIVE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
refresh_token: refreshToken,
grant_type: 'refresh_token',
scope: ONEDRIVE_SCOPE,
}),
});
if (!response.ok) {
throw new Error('Failed to refresh token');
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
expiresAt: Date.now() + data.expires_in * 1000,
tokenType: data.token_type,
scope: data.scope,
};
};
// ============================================================================
// User Info
// ============================================================================
/**
* Get authenticated user info
*/
export const getUserInfo = async (accessToken: string): Promise<ProviderAccount> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveGetUserInfo) {
const user = await bridge.onedriveGetUserInfo({ accessToken });
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarDataUrl,
};
}
const response = await fetch(`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to get user info');
}
const user: OneDriveUserInfo = await response.json();
// Try to get profile photo
let avatarUrl: string | undefined;
try {
const photoResponse = await fetch(
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/photo/$value`,
{
headers: { 'Authorization': `Bearer ${accessToken}` },
}
);
if (photoResponse.ok) {
const blob = await photoResponse.blob();
avatarUrl = URL.createObjectURL(blob);
}
} catch {
// Photo not available
}
return {
id: user.id,
email: user.mail || user.userPrincipalName,
name: user.displayName,
avatarUrl,
};
};
/**
* Validate access token
*/
export const validateToken = async (accessToken: string): Promise<boolean> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveGetUserInfo) {
try {
await bridge.onedriveGetUserInfo({ accessToken });
return true;
} catch {
return false;
}
}
try {
const response = await fetch(`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
return response.ok;
} catch {
return false;
}
};
// ============================================================================
// OneDrive App Folder Operations
// ============================================================================
const APP_FOLDER_PATH = '/drive/special/approot';
// Eventual-consistency retry for OneDrive "not found" lookups. The Graph API
// can briefly 404 a file that was uploaded seconds ago from another device
// (most commonly when the other device is syncing through the OneDrive
// desktop client and the change has not yet reached Graph). Treating every
// 404 as authoritative "cloud is empty" lets a second device proceed to an
// empty-cloud upload path and overwrite real data (#779). We retry a small
// bounded number of times with short backoff to flush through that window.
const NOT_FOUND_RETRIES = 2;
const NOT_FOUND_BACKOFF_MS = 1500;
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
async function retryOnNotFound<T>(
fetchOnce: () => Promise<T | null>,
): Promise<T | null> {
let result = await fetchOnce();
for (let attempt = 1; attempt <= NOT_FOUND_RETRIES && result === null; attempt++) {
await sleep(NOT_FOUND_BACKOFF_MS * attempt);
result = await fetchOnce();
}
return result;
}
/**
* Ensure app folder exists and find sync file
*/
export const findSyncFile = async (accessToken: string): Promise<string | null> => {
const fetchOnce = async (): Promise<string | null> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveFindSyncFile) {
const result = await bridge.onedriveFindSyncFile({
accessToken,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
});
return result.fileId || null;
}
try {
const response = await fetch(
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error('Failed to find sync file');
}
const item: DriveItem = await response.json();
return item.id;
} catch {
return null;
}
};
return retryOnNotFound(fetchOnce);
};
/**
* Create or update sync file in app folder
*/
export const uploadSyncFile = async (
accessToken: string,
syncedFile: SyncedFile
): Promise<string> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveUploadSyncFile) {
const result = await bridge.onedriveUploadSyncFile({
accessToken,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
syncedFile,
});
if (!result.fileId) {
throw new Error('Failed to upload sync file');
}
return result.fileId;
}
const content = JSON.stringify(syncedFile, null, 2);
const response = await fetch(
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}:/content`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: content,
}
);
if (!response.ok) {
throw new Error('Failed to upload sync file');
}
const item: DriveItem = await response.json();
return item.id;
};
/**
* Download sync file
*/
export const downloadSyncFile = async (
accessToken: string,
fileId?: string
): Promise<SyncedFile | null> => {
const fetchOnce = async (): Promise<SyncedFile | null> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveDownloadSyncFile) {
const result = await bridge.onedriveDownloadSyncFile({
accessToken,
fileId,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
});
return (result.syncedFile as SyncedFile | null) || null;
}
try {
// Can use either file ID or path
const url = fileId
? `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}/content`
: `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}:/content`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error('Failed to download sync file');
}
return response.json();
} catch {
return null;
}
};
return retryOnNotFound(fetchOnce);
};
/**
* Delete sync file
*/
export const deleteSyncFile = async (
accessToken: string,
fileId: string
): Promise<void> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveDeleteSyncFile) {
await bridge.onedriveDeleteSyncFile({ accessToken, fileId });
return;
}
const response = await fetch(
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
);
if (!response.ok && response.status !== 404) {
throw new Error('Failed to delete sync file');
}
};
// ============================================================================
// OneDrive Adapter Class
// ============================================================================
export class OneDriveAdapter {
private tokens: OAuthTokens | null = null;
private fileId: string | null = null;
private account: ProviderAccount | null = null;
private pkceChallenge: PKCEChallenge | null = null;
/**
* Invoked whenever the access token is silently refreshed. Lets the owner
* (CloudSyncManager) persist the rotated tokens — Microsoft consumer refresh
* tokens rotate on every refresh and invalidate the previous one, so without
* persisting the new refresh token the stored one eventually goes stale and
* forces the user to reconnect (#1189).
*/
private onTokensRefreshed: ((tokens: OAuthTokens) => void) | null = null;
constructor(tokens?: OAuthTokens, fileId?: string) {
if (tokens) {
this.tokens = tokens;
}
this.fileId = fileId || null;
}
/**
* Register a callback that receives refreshed tokens so the caller can
* persist them. Passing null removes the callback.
*/
setOnTokensRefreshed(callback: ((tokens: OAuthTokens) => void) | null): void {
this.onTokensRefreshed = callback;
}
/**
* Refresh the access token using the supplied refresh token, store the
* rotated tokens in-memory, and notify the persistence callback. Refresh
* failures caused by a dead refresh token are normalized to
* OneDriveReauthRequiredError so callers can prompt for reconnect.
*/
private async refreshTokens(refreshToken: string): Promise<OAuthTokens> {
let refreshed: OAuthTokens;
try {
refreshed = await refreshAccessToken(refreshToken);
} catch (error) {
if (isOneDriveReauthRequiredError(error)) {
// Preserve the message (it carries the marker the bridge added) so
// downstream layers can still detect this after any re-wrapping.
throw new OneDriveReauthRequiredError(
error instanceof Error ? error.message : String(error)
);
}
throw error;
}
this.tokens = refreshed;
try {
this.onTokensRefreshed?.(refreshed);
} catch {
// Persistence is best-effort; a failed save must not abort the sync that
// triggered the refresh — the fresh tokens still work for this session.
}
return refreshed;
}
get isAuthenticated(): boolean {
return !!this.tokens?.accessToken;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.fileId;
}
/**
* Start OAuth flow - returns URL to open in browser
*/
async startAuth(redirectUri: string): Promise<string> {
const { url, pkce } = await buildAuthUrl(redirectUri);
this.pkceChallenge = pkce;
return url;
}
/**
* Get PKCE state for verification
*/
getPKCEState(): string | null {
return this.pkceChallenge?.state || null;
}
/**
* Complete authentication with authorization code
*/
async completeAuth(code: string, redirectUri: string): Promise<OAuthTokens> {
if (!this.pkceChallenge) {
throw new Error('No PKCE challenge - start auth first');
}
this.tokens = await exchangeCodeForTokens(
code,
this.pkceChallenge.codeVerifier,
redirectUri
);
this.pkceChallenge = null;
this.account = await getUserInfo(this.tokens.accessToken);
return this.tokens;
}
/**
* Set tokens from storage
*/
async setTokens(tokens: OAuthTokens): Promise<void> {
this.tokens = tokens;
// Refresh if expired
if (tokens.expiresAt && Date.now() > tokens.expiresAt - 60000) {
if (tokens.refreshToken) {
this.tokens = await this.refreshTokens(tokens.refreshToken);
} else {
throw new OneDriveReauthRequiredError(
'OneDrive session expired and no refresh token is available, please reconnect.'
);
}
}
if (await validateToken(this.tokens.accessToken)) {
this.account = await getUserInfo(this.tokens.accessToken);
} else {
throw new Error('Token is invalid');
}
}
/**
* Ensure token is fresh
*/
private async ensureValidToken(): Promise<string> {
if (!this.tokens) {
throw new Error('Not authenticated');
}
if (this.tokens.expiresAt && Date.now() > this.tokens.expiresAt - 60000) {
if (this.tokens.refreshToken) {
this.tokens = await this.refreshTokens(this.tokens.refreshToken);
} else {
throw new OneDriveReauthRequiredError(
'OneDrive session expired and no refresh token is available, please reconnect.'
);
}
}
return this.tokens.accessToken;
}
private async runWithAuthRetry<T>(
operation: (accessToken: string) => Promise<T>
): Promise<T> {
const accessToken = await this.ensureValidToken();
try {
return await operation(accessToken);
} catch (error) {
if (isUnauthorizedError(error) && this.tokens?.refreshToken) {
const refreshed = await this.refreshTokens(this.tokens.refreshToken);
return await operation(refreshed.accessToken);
}
throw error;
}
}
/**
* Sign out
*/
signOut(): void {
this.tokens = null;
this.fileId = null;
this.account = null;
this.pkceChallenge = null;
this.onTokensRefreshed = null;
}
/**
* Initialize or find sync file
*/
async initializeSync(): Promise<string | null> {
return this.runWithAuthRetry(async (accessToken) => {
this.fileId = await findSyncFile(accessToken);
return this.fileId;
});
}
/**
* Upload sync file
*/
async upload(syncedFile: SyncedFile): Promise<string> {
return this.runWithAuthRetry(async (accessToken) => {
this.fileId = await uploadSyncFile(accessToken, syncedFile);
return this.fileId;
});
}
/**
* Download sync file
*/
async download(): Promise<SyncedFile | null> {
return this.runWithAuthRetry(async (accessToken) => {
if (!this.fileId) {
this.fileId = await findSyncFile(accessToken);
}
return downloadSyncFile(accessToken, this.fileId || undefined);
});
}
/**
* Delete sync data
*/
async deleteSync(): Promise<void> {
if (!this.tokens || !this.fileId) {
return;
}
await this.runWithAuthRetry(async (accessToken) => {
await deleteSyncFile(accessToken, this.fileId as string);
this.fileId = null;
});
}
/**
* Get tokens for storage
*/
getTokens(): OAuthTokens | null {
return this.tokens;
}
}
export default OneDriveAdapter;

View File

@@ -0,0 +1,253 @@
/**
* S3 Compatible Adapter - AWS SDK v3
*/
import {
S3Client,
HeadObjectCommand,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import {
SYNC_CONSTANTS,
type S3Config,
type SyncedFile,
type ProviderAccount,
type OAuthTokens,
} from '../../../domain/sync';
import { netcattyBridge } from '../netcattyBridge';
const normalizeEndpoint = (endpoint: string): string => {
const trimmed = endpoint.trim();
if (!/^https?:\/\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
};
const toBodyString = async (body: unknown): Promise<string> => {
if (!body) return '';
if (typeof body === 'string') return body;
if (body instanceof Uint8Array) {
return new TextDecoder().decode(body);
}
if (body instanceof Blob) {
return await body.text();
}
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
return await new Response(body).text();
}
if (typeof (body as { transformToString?: () => Promise<string> }).transformToString === 'function') {
return await (body as { transformToString: () => Promise<string> }).transformToString();
}
throw new Error('Unsupported S3 response body');
};
export class S3Adapter {
private config: S3Config | null;
private resource: string | null;
private account: ProviderAccount | null;
private client: S3Client | null;
constructor(config?: S3Config, resourceId?: string) {
this.config = config
? { ...config, endpoint: normalizeEndpoint(config.endpoint) }
: null;
this.resource = resourceId || null;
this.account = this.buildAccountInfo(this.config);
this.client = this.config ? this.createClient(this.config) : null;
}
get isAuthenticated(): boolean {
return !!this.config;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.resource;
}
signOut(): void {
this.config = null;
this.resource = null;
this.account = null;
this.client = null;
}
async initializeSync(): Promise<string | null> {
if (!this.config) {
throw new Error('Missing S3 config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncS3Initialize) {
const result = await bridge.cloudSyncS3Initialize(this.config);
this.resource = result?.resourceId || this.getObjectKey();
return this.resource;
}
const client = this.getClient();
try {
await client.send(new HeadObjectCommand({
Bucket: this.config.bucket,
Key: this.getObjectKey(),
}));
} catch (error) {
if (this.isNotFound(error)) {
// File doesn't exist yet.
} else if (this.isAccessDenied(error)) {
throw new Error('S3 access denied');
} else {
throw error;
}
}
this.resource = this.getObjectKey();
return this.resource;
}
async upload(syncedFile: SyncedFile): Promise<string> {
if (!this.config) {
throw new Error('Missing S3 config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncS3Upload) {
const result = await bridge.cloudSyncS3Upload(this.config, syncedFile);
this.resource = result?.resourceId || this.getObjectKey();
return this.resource;
}
const body = JSON.stringify(syncedFile);
const client = this.getClient();
await client.send(new PutObjectCommand({
Bucket: this.config.bucket,
Key: this.getObjectKey(),
Body: body,
ContentType: 'application/json',
}));
this.resource = this.getObjectKey();
return this.resource;
}
async download(): Promise<SyncedFile | null> {
if (!this.config) {
throw new Error('Missing S3 config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncS3Download) {
const result = await bridge.cloudSyncS3Download(this.config);
return (result?.syncedFile ?? null) as SyncedFile | null;
}
const client = this.getClient();
try {
const response = await client.send(new GetObjectCommand({
Bucket: this.config.bucket,
Key: this.getObjectKey(),
}));
const text = await toBodyString(response.Body);
if (!text) return null;
return JSON.parse(text) as SyncedFile;
} catch (error) {
if (this.isNotFound(error)) {
return null;
}
throw error;
}
}
async deleteSync(): Promise<void> {
if (!this.config) {
return;
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncS3Delete) {
await bridge.cloudSyncS3Delete(this.config);
return;
}
const client = this.getClient();
try {
await client.send(new DeleteObjectCommand({
Bucket: this.config.bucket,
Key: this.getObjectKey(),
}));
} catch (error) {
if (this.isNotFound(error)) {
return;
}
throw error;
}
}
getTokens(): OAuthTokens | null {
return null;
}
private getClient(): S3Client {
if (!this.config || !this.client) {
if (this.config?.allowInsecure) {
throw new Error('S3 insecure connections require the Netcatty desktop sync bridge');
}
throw new Error('Missing S3 config');
}
return this.client;
}
private createClient(config: S3Config): S3Client | null {
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle ?? true,
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
sessionToken: config.sessionToken,
},
};
if (
config.allowInsecure
&& typeof globalThis.process !== 'undefined'
&& typeof require === 'function'
) {
const https = require('https');
const { NodeHttpHandler } = require('@smithy/node-http-handler');
clientConfig.requestHandler = new NodeHttpHandler({
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
});
} else if (config.allowInsecure) {
return null;
}
return new S3Client(clientConfig);
}
private isNotFound(error: unknown): boolean {
return Boolean((error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode === 404);
}
private isAccessDenied(error: unknown): boolean {
return Boolean((error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode === 403);
}
private getObjectKey(): string {
if (!this.config) {
throw new Error('Missing S3 config');
}
const prefix = (this.config.prefix || '').trim().replace(/^\/+|\/+$/g, '');
if (!prefix) {
return SYNC_CONSTANTS.SYNC_FILE_NAME;
}
return `${prefix}/${SYNC_CONSTANTS.SYNC_FILE_NAME}`;
}
private buildAccountInfo(config: S3Config | null): ProviderAccount | null {
if (!config) return null;
const name = `${config.bucket} (${config.region})`;
const id = `${config.bucket}@${config.endpoint}`;
return { id, name };
}
}
export default S3Adapter;

View File

@@ -0,0 +1,377 @@
/**
* WebDAV Adapter - webdav client library
*/
import { AuthType, createClient } from 'webdav';
import {
SYNC_CONSTANTS,
type WebDAVConfig,
type SyncedFile,
type ProviderAccount,
type OAuthTokens,
} from '../../../domain/sync';
import { netcattyBridge } from '../netcattyBridge';
type WebDAVClient = ReturnType<typeof createClient>;
const normalizeEndpoint = (endpoint: string): string => {
const trimmed = endpoint.trim();
if (!/^https?:\/\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
};
const ensureLeadingSlash = (value: string): string =>
value.startsWith('/') ? value : `/${value}`;
/**
* Recover from trailing garbage left by non-truncating WebDAV PUT overwrites
* (#2223). Node/V8: "Unexpected non-whitespace character after JSON at position N".
*/
const parseSyncedFileJson = (raw: string): SyncedFile => {
try {
return JSON.parse(raw) as SyncedFile;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const match = /Unexpected non-whitespace character after JSON at position (\d+)/i.exec(
message,
);
if (match) {
const pos = Number(match[1]);
if (Number.isFinite(pos) && pos > 0 && pos <= raw.length) {
return JSON.parse(raw.slice(0, pos)) as SyncedFile;
}
}
throw error;
}
};
const utf8ByteLength = (value: string): number =>
typeof Buffer !== 'undefined'
? Buffer.byteLength(value, 'utf8')
: new TextEncoder().encode(value).length;
/** Strict: intended body plus optional trailing whitespace only. */
const remoteMatchesUploadedBody = (remoteText: string, body: string): boolean => {
if (!remoteText.startsWith(body)) return false;
return /^\s*$/.test(remoteText.slice(body.length));
};
/**
* Prefer fixed-name temp PUT + MOVE (with pad + verify); else padded in-place
* PUT with strict body match so non-truncating servers cannot leave garbage.
*/
const putWebdavFileReplacing = async (
client: WebDAVClient,
path: string,
body: string,
): Promise<void> => {
const tmpPath = `${path}.tmp`;
const bodyLen = utf8ByteLength(body);
const cleanupTemp = async () => {
try {
if (await client.exists(tmpPath)) {
await client.deleteFile(tmpPath);
}
} catch {
// best-effort
}
};
const readLen = async (target: string): Promise<number> => {
let exists = false;
try {
exists = await client.exists(target);
} catch (error) {
throw new Error(
`WebDAV replace aborted: could not check existing file (${
error instanceof Error ? error.message : String(error)
})`,
);
}
if (!exists) return 0;
try {
const existing = await client.getFileContents(target, { format: 'text' });
if (existing == null) return 0;
return utf8ByteLength(String(existing));
} catch (error) {
throw new Error(
`WebDAV replace aborted: could not read existing file length (${
error instanceof Error ? error.message : String(error)
})`,
);
}
};
try {
let tmpMin = 0;
try {
tmpMin = await readLen(tmpPath);
} catch {
tmpMin = 0;
}
const tmpBody = tmpMin > bodyLen ? body + ' '.repeat(tmpMin - bodyLen) : body;
await client.putFileContents(tmpPath, tmpBody, { overwrite: true });
await client.moveFile(tmpPath, path, { overwrite: true });
const moved = String((await client.getFileContents(path, { format: 'text' })) ?? '');
if (remoteMatchesUploadedBody(moved, body)) {
return;
}
} catch {
// fall through
}
await cleanupTemp();
let minLen = await readLen(path);
minLen = Math.max(minLen, bodyLen);
for (let attempt = 0; attempt < 3; attempt++) {
const payload = minLen > bodyLen ? body + ' '.repeat(minLen - bodyLen) : body;
await client.putFileContents(path, payload, { overwrite: true });
let remoteText = '';
try {
remoteText = String((await client.getFileContents(path, { format: 'text' })) ?? '');
} catch (error) {
throw new Error(
`WebDAV upload verification failed: could not re-read file (${
error instanceof Error ? error.message : String(error)
})`,
);
}
if (remoteMatchesUploadedBody(remoteText, body)) {
return;
}
minLen = Math.max(minLen, utf8ByteLength(remoteText), bodyLen);
}
throw new Error(
'WebDAV upload verification failed: remote file still does not match uploaded body after padded PUT',
);
};
export class WebDAVAdapter {
private config: WebDAVConfig | null;
private resource: string | null;
private account: ProviderAccount | null;
private client: WebDAVClient | null;
constructor(config?: WebDAVConfig, resourceId?: string) {
this.config = config
? { ...config, endpoint: normalizeEndpoint(config.endpoint) }
: null;
this.resource = resourceId || null;
this.account = this.buildAccountInfo(this.config);
this.client = this.config ? this.createClient(this.config) : null;
}
get isAuthenticated(): boolean {
return !!this.config;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.resource;
}
signOut(): void {
this.config = null;
this.resource = null;
this.account = null;
this.client = null;
}
async initializeSync(): Promise<string | null> {
return this.withWebdavErrorContext('initialize', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavInitialize) {
const result = await bridge.cloudSyncWebdavInitialize(this.config);
this.resource = result?.resourceId || this.getSyncPath();
return this.resource;
}
const client = this.getClient();
const path = this.getSyncPath();
await client.exists(path);
this.resource = path;
return this.resource;
});
}
async upload(syncedFile: SyncedFile): Promise<string> {
return this.withWebdavErrorContext('upload', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavUpload) {
const result = await bridge.cloudSyncWebdavUpload(this.config, syncedFile);
this.resource = result?.resourceId || this.getSyncPath();
return this.resource;
}
const client = this.getClient();
const path = this.getSyncPath();
await putWebdavFileReplacing(client, path, JSON.stringify(syncedFile));
this.resource = path;
return path;
});
}
async download(): Promise<SyncedFile | null> {
return this.withWebdavErrorContext('download', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavDownload) {
const result = await bridge.cloudSyncWebdavDownload(this.config);
return (result?.syncedFile ?? null) as SyncedFile | null;
}
const client = this.getClient();
const path = this.getSyncPath();
const exists = await client.exists(path);
if (!exists) return null;
const data = await client.getFileContents(path, { format: 'text' });
if (!data) return null;
return parseSyncedFileJson(data as string);
});
}
async deleteSync(): Promise<void> {
return this.withWebdavErrorContext('delete', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavDelete) {
await bridge.cloudSyncWebdavDelete(this.config);
return;
}
const client = this.getClient();
const path = this.getSyncPath();
const exists = await client.exists(path);
if (!exists) return;
await client.deleteFile(path);
});
}
getTokens(): OAuthTokens | null {
return null;
}
private getClient(): WebDAVClient {
if (!this.config || !this.client) {
throw new Error('Missing WebDAV config');
}
return this.client;
}
private createClient(config: WebDAVConfig): WebDAVClient {
const extraOpts: Record<string, unknown> = {};
if (config.allowInsecure && typeof globalThis.process !== 'undefined') {
const https = require('https');
extraOpts.httpsAgent = new https.Agent({ rejectUnauthorized: false });
}
if (config.authType === 'token') {
return createClient(config.endpoint, {
authType: AuthType.Token,
token: {
access_token: config.token || '',
token_type: 'Bearer',
},
...extraOpts,
});
}
if (config.authType === 'digest') {
return createClient(config.endpoint, {
authType: AuthType.Digest,
username: config.username || '',
password: config.password || '',
...extraOpts,
});
}
return createClient(config.endpoint, {
authType: AuthType.Password,
username: config.username || '',
password: config.password || '',
...extraOpts,
});
}
private async withWebdavErrorContext<T>(
operation: string,
fn: () => Promise<T>,
): Promise<T> {
try {
return await fn();
} catch (error) {
throw this.buildWebdavError(operation, error);
}
}
private buildWebdavError(operation: string, error: unknown): Error {
const baseMessage = error instanceof Error ? error.message : String(error);
const details: Record<string, string | number | boolean | null | undefined> = {
operation,
};
const raw = error as {
status?: number;
statusText?: string;
url?: string;
method?: string;
code?: string;
response?: {
status?: number;
statusText?: string;
url?: string;
};
cause?: unknown;
};
if (raw?.status) details.status = raw.status;
if (raw?.statusText) details.statusText = raw.statusText;
if (raw?.url) details.url = raw.url;
if (raw?.method) details.method = raw.method;
if (raw?.code) details.code = raw.code;
if (raw?.response?.status) details.status = raw.response.status;
if (raw?.response?.statusText) details.statusText = raw.response.statusText;
if (raw?.response?.url) details.url = raw.response.url;
if (raw?.cause && typeof raw.cause === 'object') {
Object.assign(details, raw.cause as Record<string, unknown>);
details.operation = operation;
const cause = raw.cause as { code?: string };
if (cause?.code) details.causeCode = cause.code;
} else if (raw?.cause) {
details.cause = String(raw.cause);
}
const err = new Error(`WebDAV ${operation} failed: ${baseMessage}`);
(err as Error & { cause?: unknown }).cause = details;
return err;
}
private getSyncPath(): string {
return ensureLeadingSlash(SYNC_CONSTANTS.SYNC_FILE_NAME);
}
private buildAccountInfo(config: WebDAVConfig | null): ProviderAccount | null {
if (!config) return null;
try {
const url = new URL(config.endpoint);
const host = url.host;
const name = config.username ? `${config.username}@${host}` : host;
return { id: host, name };
} catch {
return { id: config.endpoint, name: config.endpoint };
}
}
}
export default WebDAVAdapter;

View File

@@ -0,0 +1,417 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { SyncedFile } from '../../../domain/sync';
import type { CloudAdapter } from './index';
import {
cloudAdapterAsEncryptedObjectStorage,
encryptedObjectStorageAsCloudAdapter,
webdavEncryptedObjectCapabilities,
} from './encryptedObjectStorageBridge';
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
import { createPluginSyncObjectStorage } from './pluginSyncObjectStorage';
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 memoryCloudAdapter(initial: SyncedFile | null = null): CloudAdapter & { store: SyncedFile | null } {
const adapter = {
store: initial,
isAuthenticated: true,
accountInfo: { id: 'webdav-host', name: 'user@host' },
resourceId: null as string | null,
signOut() {
adapter.isAuthenticated = false;
adapter.accountInfo = null;
adapter.store = null;
},
async initializeSync() {
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
return adapter.resourceId;
},
async upload(file: SyncedFile) {
adapter.store = file;
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
return adapter.resourceId;
},
async download() {
return adapter.store;
},
async deleteSync() {
adapter.store = null;
},
getTokens() {
return null;
},
};
return adapter;
}
describe('encryptedObjectStorageBridge', () => {
it('adapts WebDAV-style CloudAdapter through the encrypted-object interface', async () => {
const adapter = memoryCloudAdapter(makeSyncedFile(3, 'remote-cipher'));
const storage = cloudAdapterAsEncryptedObjectStorage(adapter, 'webdav', {
capabilities: webdavEncryptedObjectCapabilities(),
});
const caps = await storage.getCapabilities();
assert.equal(caps.atomicReplacement, true);
assert.equal(caps.revisions, false);
const connected = await storage.connect();
assert.equal(connected.account.id, 'webdav-host');
const read = await storage.readObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
assert.equal(read.found, true);
assert.ok(read.bytes);
assert.equal(read.revision, '3');
const next = makeSyncedFile(4, 'next-cipher');
const encoded = new TextEncoder().encode(JSON.stringify(next));
const written = await storage.writeObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY, encoded);
assert.equal(written.created, false);
assert.equal(written.revision, '4');
const reloaded = await adapter.download();
assert.equal(reloaded?.payload, 'next-cipher');
const deleted = await storage.deleteObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
assert.equal(deleted.deleted, true);
assert.equal(await adapter.download(), null);
});
it('adapts EncryptedObjectStorage back to CloudAdapter for manager upload/download', async () => {
const memory = new Map<string, Uint8Array>();
let backingResourceId: string | null = '/persisted/path.json';
const storage = {
providerId: 'com.example.sync',
async connect() {
backingResourceId = '/after-connect/path.json';
return { account: { id: 'plugin-acct' } };
},
async disconnect() {},
async getAccount() {
return { id: 'plugin-acct' };
},
async getCapabilities() {
return {
revisions: true,
conditionalWrites: true,
atomicReplacement: true,
maxObjectBytes: 1024,
};
},
async readObject(key: string) {
const bytes = memory.get(key) ?? null;
return bytes
? { found: true as const, key, bytes, revision: 'r1' }
: { found: false as const, key, bytes: null };
},
async writeObject(key: string, bytes: Uint8Array) {
const created = !memory.has(key);
memory.set(key, bytes);
return { created, revision: 'r2' };
},
async deleteObject(key: string) {
const deleted = memory.delete(key);
return { deleted };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
resourceId: '/persisted/path.json',
resolveResourceId: () => backingResourceId,
});
assert.equal(adapter.isAuthenticated, true);
assert.equal(adapter.resourceId, '/persisted/path.json');
await adapter.initializeSync();
assert.equal(adapter.accountInfo?.id, 'plugin-acct');
assert.equal(adapter.resourceId, '/after-connect/path.json');
const file = makeSyncedFile(9, 'plugin-cipher');
await adapter.upload(file);
const downloaded = await adapter.download();
assert.equal(downloaded?.payload, 'plugin-cipher');
assert.equal(downloaded?.meta.version, 9);
});
// note: upload() re-reads for verification; memory map above is sufficient
it('rebindSession re-issues connect after a prior session for plugin runtime replacement', async () => {
const ops: string[] = [];
const storage = {
providerId: 'com.example.sync',
async connect() {
ops.push('connect');
return { account: { id: 'a1' } };
},
async disconnect() {},
async getAccount() {
return { id: 'a1' };
},
async getCapabilities() {
return { revisions: false, conditionalWrites: false, atomicReplacement: true };
},
async readObject(key: string) {
return { found: false as const, key, bytes: null };
},
async writeObject() {
return { created: true as const };
},
async deleteObject() {
return { deleted: false as const };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
rebindSession: true,
});
await adapter.initializeSync();
await adapter.download();
await adapter.download();
assert.ok(ops.filter((op) => op === 'connect').length >= 3,
'rebindSession must call connect on each ensureConnected path');
});
it('re-issues connect after an I/O failure so replaced runtimes can recover', async () => {
const ops: string[] = [];
let failNextRead = true;
const storage = {
providerId: 'com.example.sync',
async connect() {
ops.push('connect');
return { account: { id: 'a1' } };
},
async disconnect() {},
async getAccount() {
return { id: 'a1' };
},
async getCapabilities() {
return { revisions: false, conditionalWrites: false, atomicReplacement: true };
},
async readObject(key: string) {
if (failNextRead) {
failNextRead = false;
throw new Error('runtime gone');
}
return { found: false as const, key, bytes: null };
},
async writeObject() {
return { created: true as const };
},
async deleteObject() {
return { deleted: false as const };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
});
await adapter.initializeSync();
assert.equal(ops.filter((op) => op === 'connect').length, 1);
await assert.rejects(() => adapter.download(), /runtime gone/);
await adapter.download();
assert.equal(
ops.filter((op) => op === 'connect').length,
2,
'failed I/O must stale the session so the next ensureConnected rebinds',
);
});
it('lazy-connects before first I/O and passes revisions for conditional writes', async () => {
const ops: string[] = [];
let revision: string | undefined = 'rev-1';
let stored: Uint8Array | null = new TextEncoder().encode(JSON.stringify(makeSyncedFile(1, 'c')));
const storage = {
providerId: 'com.example.sync',
async connect() {
ops.push('connect');
return { account: { id: 'a1' } };
},
async disconnect() {
ops.push('disconnect');
},
async getAccount() {
return { id: 'a1' };
},
async getCapabilities() {
return { revisions: true, conditionalWrites: true, atomicReplacement: true };
},
async readObject(key: string) {
ops.push(`read:${key}:${revision ?? ''}`);
if (!stored) return { found: false as const, key, bytes: null };
return {
found: true as const,
key,
bytes: stored,
revision,
};
},
async writeObject(key: string, bytes: Uint8Array, options?: { expectedRevision?: string | null }) {
ops.push(`write:${key}:${options?.expectedRevision === null ? 'null' : (options?.expectedRevision ?? '')}`);
stored = bytes;
revision = revision ? 'rev-2' : 'rev-new';
return { created: !revision || revision === 'rev-new', revision };
},
async deleteObject() {
return { deleted: true };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
});
// Restored session: authenticated for cache reuse, but not yet connected.
assert.equal(adapter.isAuthenticated, true);
const downloaded = await adapter.download();
assert.equal(downloaded?.payload, 'c');
assert.deepEqual(ops[0], 'connect');
assert.ok(ops.some((op) => op.startsWith('read:')));
await adapter.upload(makeSyncedFile(2, 'next'));
assert.ok(
ops.some((op) => op === 'write:netcatty-vault.json:rev-1'),
`expected conditional write with rev-1, got ${JSON.stringify(ops)}`,
);
// Confirmed absence → must-not-exist (expectedRevision null)
stored = null;
revision = undefined;
const adapter2 = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
});
assert.equal(await adapter2.download(), null);
await adapter2.upload(makeSyncedFile(3, 'fresh'));
assert.ok(
ops.some((op) => op === 'write:netcatty-vault.json:null'),
`expected must-not-exist write, got ${JSON.stringify(ops)}`,
);
});
it('plugin sync object storage only forwards encrypted bytes to the host', async () => {
const calls: string[] = [];
const host = {
async connectSync(params: { providerId: string; configuration?: unknown }) {
calls.push(`connect:${params.providerId}`);
assert.deepEqual(params.configuration, { endpoint: 'https://example.test' });
return { account: { id: 'a1', name: 'Plugin' } };
},
async disconnectSync() {
calls.push('disconnect');
return null;
},
async getSyncAccount() {
return { account: { id: 'a1' } };
},
async getSyncCapabilities() {
return {
revisions: true,
conditionalWrites: true,
atomicReplacement: false,
maxObjectBytes: 64,
};
},
async readSyncObject(params: { key: string }) {
calls.push(`read:${params.key}`);
return {
found: true,
key: params.key,
bytes: new Uint8Array([1, 2, 3]),
revision: 'rev-1',
};
},
async writeSyncObject(params: { key: string; bytes: Uint8Array; expectedRevision?: string | null }) {
calls.push(`write:${params.key}:${params.bytes.byteLength}:${params.expectedRevision ?? ''}`);
// Ensure bytes look like opaque ciphertext, not a vault JSON root.
assert.equal(params.bytes[0], 0x9b);
return { created: true, revision: 'rev-2' };
},
async deleteSyncObject(params: { key: string }) {
calls.push(`delete:${params.key}`);
return { deleted: true };
},
};
const storage = createPluginSyncObjectStorage({
providerId: 'com.example.sync',
host,
configuration: { endpoint: 'https://example.test' },
});
await storage.connect();
const caps = await storage.getCapabilities();
assert.equal(caps.conditionalWrites, true);
const read = await storage.readObject('vault');
assert.deepEqual([...read.bytes!], [1, 2, 3]);
await storage.writeObject('vault', new Uint8Array([0x9b, 0x01]), { expectedRevision: null });
await storage.deleteObject('vault');
await storage.disconnect();
assert.deepEqual(calls, [
'connect:com.example.sync',
'read:vault',
'write:vault:2:',
'delete:vault',
'disconnect',
]);
});
it('skips host re-read when assumeVerifiedWrites is set (WebDAV path)', async () => {
const ops: string[] = [];
let stored: Uint8Array | null = null;
const storage = {
providerId: 'webdav',
async connect() {
ops.push('connect');
return { account: { id: 'w1' } };
},
async disconnect() {},
async getAccount() {
return { id: 'w1' };
},
async getCapabilities() {
return webdavEncryptedObjectCapabilities();
},
async readObject(key: string) {
ops.push(`read:${key}`);
if (!stored) return { found: false as const, key, bytes: null };
return { found: true as const, key, bytes: stored, revision: '1' };
},
async writeObject(key: string, bytes: Uint8Array) {
ops.push(`write:${key}`);
stored = bytes;
return { created: true as const, revision: '1' };
},
async deleteObject() {
return { deleted: true as const };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
assumeVerifiedWrites: true,
});
await adapter.upload(makeSyncedFile(1, 'body'));
assert.deepEqual(
ops.filter((op) => op.startsWith('read:')),
[],
'assumeVerifiedWrites must not re-read after write',
);
assert.ok(ops.includes('write:netcatty-vault.json'));
});
});

View File

@@ -0,0 +1,387 @@
/**
* Bridges between the legacy single-file CloudAdapter interface and the
* shared EncryptedObjectStorage surface used by plugin sync Providers.
*/
import type {
EncryptedObjectAccount,
EncryptedObjectDeleteResult,
EncryptedObjectReadResult,
EncryptedObjectStorage,
EncryptedObjectStorageCapabilities,
EncryptedObjectWriteResult,
} from '../../../domain/encryptedObjectStorage';
// EncryptedObjectStorageCapabilities used for session capability gating.
import {
DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY,
} from '../../../domain/encryptedObjectStorage';
import type {
CloudProvider,
OAuthTokens,
ProviderAccount,
SyncedFile,
} from '../../../domain/sync';
import type { CloudAdapter } from './index';
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
function syncedFileToBytes(syncedFile: SyncedFile): Uint8Array {
return textEncoder.encode(JSON.stringify(syncedFile));
}
function bytesToSyncedFile(bytes: Uint8Array): SyncedFile {
const raw = textDecoder.decode(bytes);
// Require a complete JSON object. Do not accept a valid prefix with trailing
// garbage — that hides provider corruption from read-and-verify recovery.
return JSON.parse(raw) as SyncedFile;
}
/** Thrown when a conditional write is rejected (revision / precondition). */
export class ConditionalWriteConflictError extends Error {
readonly code = 'conditional_write_conflict';
constructor(message = 'Encrypted object conditional write was rejected', options?: { cause?: unknown }) {
super(message, options);
this.name = 'ConditionalWriteConflictError';
}
}
export function isConditionalWriteConflictError(error: unknown): boolean {
if (error instanceof ConditionalWriteConflictError) return true;
if (!error || typeof error !== 'object') return false;
const maybe = error as { name?: unknown; code?: unknown; message?: unknown; data?: { pluginCode?: unknown } };
if (maybe.name === 'ConditionalWriteConflictError') return true;
if (maybe.code === 'conditional_write_conflict' || maybe.code === -32009 || maybe.code === 'failed_precondition') {
return true;
}
if (maybe.data?.pluginCode === 'failed_precondition') return true;
return typeof maybe.message === 'string'
&& /failed[_ ]precondition|expectedRevision|revision mismatch|conditional write/i.test(maybe.message);
}
/**
* Adapt a legacy CloudAdapter into EncryptedObjectStorage.
* Uses a single default object key matching the historical vault file name.
*/
export function cloudAdapterAsEncryptedObjectStorage(
adapter: CloudAdapter,
providerId: string,
options: {
objectKey?: string;
capabilities?: EncryptedObjectStorageCapabilities;
} = {},
): EncryptedObjectStorage {
const objectKey = options.objectKey ?? DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
const capabilities: EncryptedObjectStorageCapabilities = options.capabilities ?? {
revisions: false,
conditionalWrites: false,
atomicReplacement: true,
};
return {
providerId,
async connect(): Promise<{ account: EncryptedObjectAccount }> {
if (!adapter.isAuthenticated) {
throw new Error(`Cloud provider ${providerId} is not authenticated`);
}
await adapter.initializeSync();
const account = adapter.accountInfo;
if (!account) {
return { account: { id: providerId } };
}
return { account: { ...account } };
},
async disconnect(): Promise<void> {
adapter.signOut();
},
async getAccount(): Promise<EncryptedObjectAccount | null> {
return adapter.accountInfo ? { ...adapter.accountInfo } : null;
},
async getCapabilities(): Promise<EncryptedObjectStorageCapabilities> {
return { ...capabilities };
},
async readObject(key: string): Promise<EncryptedObjectReadResult> {
if (key !== objectKey) {
return { found: false, key, bytes: null };
}
const file = await adapter.download();
if (!file) return { found: false, key, bytes: null };
const bytes = syncedFileToBytes(file);
return {
found: true,
key,
bytes,
revision: file.meta?.version != null ? String(file.meta.version) : undefined,
contentType: 'application/json',
};
},
async writeObject(key: string, bytes: Uint8Array): Promise<EncryptedObjectWriteResult> {
if (key !== objectKey) {
throw new Error(`Cloud adapter ${providerId} only supports object key ${objectKey}`);
}
const syncedFile = bytesToSyncedFile(bytes);
// Skip a pre-upload GET for `created`. The single-object vault path does not
// consume that flag, and WebDAV already pays for pad+verify inside upload.
await adapter.upload(syncedFile);
return {
created: false,
revision: syncedFile.meta?.version != null ? String(syncedFile.meta.version) : undefined,
};
},
async deleteObject(key: string): Promise<EncryptedObjectDeleteResult> {
if (key !== objectKey) {
return { deleted: false };
}
const existing = await adapter.download();
if (!existing) return { deleted: false };
await adapter.deleteSync();
return { deleted: true };
},
};
}
/**
* Adapt EncryptedObjectStorage into the legacy CloudAdapter surface so the
* existing encrypt→upload / download→decrypt manager path can drive WebDAV and
* plugin providers through one code path.
*
* Authentication and resourceId must match pre-bridge CloudAdapter semantics:
* config-backed providers (WebDAV) report authenticated as soon as credentials
* exist so getConnectedAdapter can reuse the cached instance; resourceId must
* preserve the persisted path (or the backing adapter's authoritative id) rather
* than always forcing the default object key.
*/
export function encryptedObjectStorageAsCloudAdapter(
storage: EncryptedObjectStorage,
options: {
objectKey?: string;
account?: ProviderAccount | null;
/** When true, getConnectedAdapter reuses this instance without recreating. */
initiallyAuthenticated?: boolean;
/** Seeded resource id (e.g. path restored from provider connection storage). */
resourceId?: string | null;
/**
* Prefer the backing adapter's resource id after connect/upload (WebDAV sets
* `/netcatty-vault.json` via initializeSync; plugins may use object keys).
*/
resolveResourceId?: () => string | null | undefined;
/**
* When true, re-issue connect() even if a prior session was established.
* Required for plugin providers whose in-process clients die on runtime
* restart/replace while the CloudAdapter cache stays alive.
*/
rebindSession?: boolean;
/**
* When true, skip the host full-byte re-read after writeObject. Use only when
* the backing storage already performs Netcatty-grade write verification
* (WebDAV pad+verify). Plugin providers keep host-owned verify.
*/
assumeVerifiedWrites?: boolean;
} = {},
): CloudAdapter {
const objectKey = options.objectKey ?? DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
let account: ProviderAccount | null = options.account ?? null;
let resourceId: string | null = options.resourceId ?? null;
let authenticated = options.initiallyAuthenticated === true;
/** Distinct from credential presence: plugin providers need an explicit connect. */
let sessionConnected = false;
/**
* Last observed remote revision for conditional writes.
* - string: known revision
* - null: confirmed absent (must-not-exist write)
* - undefined: unknown / unconditional
*/
let lastRevision: string | null | undefined;
const refreshResourceId = (fallback?: string | null): string | null => {
const resolved = options.resolveResourceId?.();
if (typeof resolved === 'string' && resolved.length > 0) {
resourceId = resolved;
return resourceId;
}
if (typeof fallback === 'string' && fallback.length > 0) {
resourceId = fallback;
return resourceId;
}
return resourceId;
};
let capabilities: EncryptedObjectStorageCapabilities | null = null;
const ensureConnected = async (): Promise<void> => {
if (sessionConnected && !options.rebindSession) return;
// rebindSession: always re-run connect so a replaced plugin runtime gets a
// fresh client. Connect is expected to be idempotent when still healthy.
const result = await storage.connect();
account = result.account;
capabilities = await storage.getCapabilities();
authenticated = true;
sessionConnected = true;
refreshResourceId(objectKey);
};
/** After I/O failure, force the next ensureConnected to re-issue connect(). */
const markSessionStale = (): void => {
sessionConnected = false;
};
return {
get isAuthenticated() {
return authenticated;
},
get accountInfo() {
return account;
},
get resourceId() {
return resourceId;
},
signOut() {
authenticated = false;
sessionConnected = false;
lastRevision = undefined;
capabilities = null;
account = null;
resourceId = null;
// Fire-and-forget is intentional for CloudAdapter.signOut sync API;
// disconnectProvider should call initializeSync/connect after a full await
// path when a future async signOut is added to CloudAdapter.
void Promise.resolve(storage.disconnect()).catch(() => {
// Plugin runtime may already be gone; local sign-out still succeeds.
});
},
async initializeSync(): Promise<string | null> {
try {
await ensureConnected();
return refreshResourceId(objectKey);
} catch (error) {
markSessionStale();
throw error;
}
},
async upload(syncedFile: SyncedFile, uploadOptions?: { signal?: AbortSignal }): Promise<string> {
try {
await ensureConnected();
const bytes = syncedFileToBytes(syncedFile);
if (
capabilities?.maxObjectBytes != null
&& bytes.byteLength > capabilities.maxObjectBytes
) {
throw new Error(
`Encrypted object exceeds provider maxObjectBytes (${capabilities.maxObjectBytes})`,
);
}
if (
capabilities?.maxObjects != null
&& capabilities.maxObjects < 1
) {
throw new Error(
`Encrypted object provider reports maxObjects < 1 (${capabilities.maxObjects})`,
);
}
const conditional = capabilities?.conditionalWrites === true;
let writeResult: EncryptedObjectWriteResult;
try {
writeResult = await storage.writeObject(objectKey, bytes, {
...(conditional && lastRevision !== undefined
? { expectedRevision: lastRevision }
: {}),
signal: uploadOptions?.signal,
});
} catch (writeError) {
if (conditional && isConditionalWriteConflictError(writeError)) {
throw new ConditionalWriteConflictError(
writeError instanceof Error ? writeError.message : String(writeError),
{ cause: writeError },
);
}
throw writeError;
}
if (options.assumeVerifiedWrites === true) {
// Backing adapter already verified (e.g. WebDAV pad+verify). Still honor
// maxObjectBytes above and refresh revision from the write result.
if (typeof writeResult.revision === 'string' && writeResult.revision.length > 0) {
lastRevision = writeResult.revision;
}
} else {
// Host-owned write verification: re-read and compare ciphertext bytes.
const verified = await storage.readObject(objectKey, { signal: uploadOptions?.signal });
if (!verified.found || !verified.bytes) {
throw new Error('Encrypted object write verification failed: object missing after write');
}
if (verified.bytes.byteLength !== bytes.byteLength) {
throw new Error('Encrypted object write verification failed: size mismatch');
}
for (let i = 0; i < bytes.byteLength; i += 1) {
if (verified.bytes[i] !== bytes[i]) {
throw new Error('Encrypted object write verification failed: content mismatch');
}
}
if (typeof writeResult.revision === 'string' && writeResult.revision.length > 0) {
lastRevision = writeResult.revision;
} else if (typeof verified.revision === 'string' && verified.revision.length > 0) {
lastRevision = verified.revision;
} else {
lastRevision = undefined;
}
}
authenticated = true;
return refreshResourceId(objectKey) ?? objectKey;
} catch (error) {
markSessionStale();
throw error;
}
},
async download(downloadOptions?: { signal?: AbortSignal }): Promise<SyncedFile | null> {
try {
await ensureConnected();
const result = await storage.readObject(objectKey, { signal: downloadOptions?.signal });
if (!result.found || !result.bytes) {
// Confirmed absence: next conditional write must use expectedRevision null.
lastRevision = null;
return null;
}
if (typeof result.revision === 'string' && result.revision.length > 0) {
lastRevision = result.revision;
} else {
lastRevision = undefined;
}
return bytesToSyncedFile(result.bytes);
} catch (error) {
markSessionStale();
throw error;
}
},
async deleteSync(deleteOptions?: { signal?: AbortSignal }): Promise<void> {
try {
await ensureConnected();
await storage.deleteObject(objectKey, {
...(typeof lastRevision === 'string' ? { expectedRevision: lastRevision } : {}),
signal: deleteOptions?.signal,
});
lastRevision = null;
} catch (error) {
markSessionStale();
throw error;
}
},
getTokens(): OAuthTokens | null {
return null;
},
};
}
/**
* WebDAV-specific capabilities: atomic replacement via temp+MOVE, no native revisions.
*/
export function webdavEncryptedObjectCapabilities(): EncryptedObjectStorageCapabilities {
return {
revisions: false,
conditionalWrites: false,
atomicReplacement: true,
};
}
export function isWebdavProvider(provider: CloudProvider): boolean {
return provider === 'webdav';
}

View File

@@ -0,0 +1,132 @@
/**
* Cloud Sync Adapters - Unified Export
*/
import type {
CloudProvider,
SyncedFile,
OAuthTokens,
ProviderAccount,
WebDAVConfig,
S3Config,
} from '../../../domain/sync';
import { isBuiltinCloudProvider } from '../../../domain/sync';
import type { EncryptedObjectStorage } from '../../../domain/encryptedObjectStorage';
import {
cloudAdapterAsEncryptedObjectStorage,
encryptedObjectStorageAsCloudAdapter,
webdavEncryptedObjectCapabilities,
} from './encryptedObjectStorageBridge';
/**
* Unified adapter interface
*/
export interface CloudAdapter {
readonly isAuthenticated: boolean;
readonly accountInfo: ProviderAccount | null;
readonly resourceId: string | null;
signOut(): void;
initializeSync(): Promise<string | null>;
upload(syncedFile: SyncedFile, options?: { signal?: AbortSignal }): Promise<string>;
download(options?: { signal?: AbortSignal }): Promise<SyncedFile | null>;
deleteSync(options?: { signal?: AbortSignal }): Promise<void>;
getTokens(): OAuthTokens | null;
}
export type { EncryptedObjectStorage };
/**
* Create adapter for a specific provider.
* Built-in providers keep their dedicated adapters. Namespaced plugin provider
* IDs require an EncryptedObjectStorage factory (plugin host bridge).
*/
export const createAdapter = async (
provider: CloudProvider,
tokens?: OAuthTokens,
resourceId?: string,
config?: WebDAVConfig | S3Config,
options?: {
/** Plugin sync storage factory for namespaced provider IDs. */
createPluginStorage?: (providerId: string) => EncryptedObjectStorage | Promise<EncryptedObjectStorage>;
},
): Promise<CloudAdapter> => {
switch (provider) {
case 'github': {
const { GitHubAdapter } = await import('./GitHubAdapter');
return new GitHubAdapter(tokens, resourceId);
}
case 'google': {
const { GoogleDriveAdapter } = await import('./GoogleDriveAdapter');
return new GoogleDriveAdapter(tokens, resourceId);
}
case 'onedrive': {
const { OneDriveAdapter } = await import('./OneDriveAdapter');
return new OneDriveAdapter(tokens, resourceId);
}
case 'webdav': {
const { WebDAVAdapter } = await import('./WebDAVAdapter');
// Production WebDAV path runs through EncryptedObjectStorage so plugin
// providers and WebDAV share one encrypt→write / read→decrypt surface.
const raw = new WebDAVAdapter(config as WebDAVConfig | undefined, resourceId);
const storage = cloudAdapterAsEncryptedObjectStorage(raw, 'webdav', {
capabilities: webdavEncryptedObjectCapabilities(),
});
return encryptedObjectStorageAsCloudAdapter(storage, {
account: raw.accountInfo,
// Match raw WebDAVAdapter: config present ⇒ authenticated for cache reuse.
initiallyAuthenticated: raw.isAuthenticated,
// Preserve constructor/persisted resourceId; refresh from raw after connect.
resourceId: raw.resourceId ?? resourceId ?? null,
resolveResourceId: () => raw.resourceId,
// WebDAVAdapter.upload already performs pad + strict body verify; skip the
// shared bridge's second full re-read to avoid write amplification.
assumeVerifiedWrites: true,
});
}
case 's3': {
const { S3Adapter } = await import('./S3Adapter');
return new S3Adapter(config as S3Config | undefined, resourceId);
}
default: {
if (isBuiltinCloudProvider(provider)) {
throw new Error(`Unknown provider: ${provider}`);
}
if (!options?.createPluginStorage) {
throw new Error(
`Plugin sync provider ${provider} is unavailable (missing or not registered)`,
);
}
const storage = await options.createPluginStorage(provider);
// Credentials/config already validated by getConnectedAdapter; report
// authenticated so the manager reuses this instance across sync calls.
// rebindSession: plugin runtimes can restart while availability membership
// stays unchanged, so ensureConnected must re-issue connect() with the
// saved configuration/credential instead of skipping after the first call.
return encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
resourceId: resourceId ?? null,
rebindSession: true,
});
}
}
};
/**
* View a CloudAdapter as EncryptedObjectStorage. WebDAV is the first built-in
* adapted through this shared surface for configuration, secrets, upload,
* download, verification, and recovery exercises.
*/
export const asEncryptedObjectStorage = (
provider: CloudProvider,
adapter: CloudAdapter,
): EncryptedObjectStorage =>
cloudAdapterAsEncryptedObjectStorage(adapter, provider, {
capabilities: provider === 'webdav'
? webdavEncryptedObjectCapabilities()
: {
revisions: provider === 'github',
conditionalWrites: false,
atomicReplacement: provider === 'webdav' || provider === 's3',
},
});

View File

@@ -0,0 +1,437 @@
/**
* Renderer-side host that drives plugin sync Providers over the preload IPC
* surface. Main process extensionProviderService owns activation, permission
* checks, and stream handling; this client shuttles already-encrypted object
* bytes with inline Uint8Array for small objects and pull/chunked transfers
* above the SyncLimits inline cutoff.
*/
import {
PLUGIN_SYNC_INLINE_OBJECT_BYTES,
} from '@netcatty/plugin-contract';
import type {
EncryptedObjectAccount,
EncryptedObjectStorageCapabilities,
} from '../../../domain/encryptedObjectStorage';
import type { PluginSyncProviderHost } from './pluginSyncObjectStorage';
/** Match host STREAM_WINDOW_BYTES so each IPC chunk maps 1:1 to a plugin stream write. */
const STREAM_WINDOW_BYTES = 256 * 1024;
/** Same as main INLINE_SYNC_OBJECT_SAFE_BYTES (aligned SyncLimits.inlineObjectBytes). */
const INLINE_SYNC_OBJECT_SAFE_BYTES = PLUGIN_SYNC_INLINE_OBJECT_BYTES;
type ElectronPluginSyncApi = {
cancelPluginExtensionRequest?: (requestId: string) => Promise<boolean>;
pluginSyncConnect?: (params: {
requestId: string;
providerId: string;
configuration?: unknown;
credential?: unknown;
deadlineMs?: number;
}) => Promise<{ account: EncryptedObjectAccount }>;
pluginSyncDisconnect?: (params: {
requestId: string;
providerId: string;
deadlineMs?: number;
}) => Promise<null>;
pluginSyncGetAccount?: (params: {
requestId: string;
providerId: string;
deadlineMs?: number;
}) => Promise<{ account: EncryptedObjectAccount | null }>;
pluginSyncGetCapabilities?: (params: {
requestId: string;
providerId: string;
deadlineMs?: number;
}) => Promise<EncryptedObjectStorageCapabilities>;
pluginSyncReadObject?: (params: {
requestId: string;
providerId: string;
key: string;
preferStream?: boolean;
deadlineMs?: number;
}) => Promise<{
found: boolean;
key: string;
data?: Uint8Array | null;
streamed?: boolean;
transferId?: string;
byteLength?: number;
revision?: string;
contentType?: string;
}>;
pluginSyncReadChunk?: (params: {
requestId: string;
transferId: string;
maxBytes?: number;
}) => Promise<{ chunk: Uint8Array; done: boolean }>;
pluginSyncWriteObject?: (params: {
requestId: string;
providerId: string;
key: string;
data: Uint8Array;
expectedRevision?: string | null;
preferStream?: boolean;
deadlineMs?: number;
}) => Promise<{ created: boolean; revision?: string }>;
pluginSyncWriteBegin?: (params: {
requestId: string;
providerId: string;
key: string;
byteLength: number;
expectedRevision?: string | null;
deadlineMs?: number;
}) => Promise<{ transferId: string; windowBytes: number }>;
pluginSyncWriteChunk?: (params: {
requestId: string;
transferId: string;
sequence: number;
chunk: Uint8Array;
}) => Promise<{ accepted: number }>;
pluginSyncWriteCommit?: (params: {
requestId: string;
transferId: string;
}) => Promise<{ created: boolean; revision?: string }>;
pluginSyncDeleteObject?: (params: {
requestId: string;
providerId: string;
key: string;
expectedRevision?: string;
deadlineMs?: number;
}) => Promise<{ deleted: boolean }>;
pluginSyncPutSecret?: (params: {
providerId: string;
key: string;
value: string;
}) => Promise<{ kind: 'secret'; id: string; key: string; created?: boolean }>;
pluginSyncDeleteSecrets?: (params: {
providerId: string;
keys?: string[];
}) => Promise<{ deleted: number }>;
pluginSyncRestoreSecrets?: (params: {
providerId: string;
keys: string[];
discard?: boolean;
}) => Promise<{ restored: number; discarded?: number }>;
};
function getPluginSyncApi(): ElectronPluginSyncApi | null {
if (typeof window === 'undefined') return null;
// Preload exposes the production bridge as window.netcatty only.
const bridge = (window as Window & {
netcatty?: ElectronPluginSyncApi;
electron?: ElectronPluginSyncApi;
}).netcatty
?? (window as Window & { electron?: ElectronPluginSyncApi }).electron;
return bridge ?? null;
}
function mintRequestId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `sync-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function coerceBytes(value: unknown): Uint8Array {
if (value instanceof Uint8Array) return value;
if (ArrayBuffer.isView(value)) {
const view = value as ArrayBufferView;
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}
if (value && typeof value === 'object' && (value as { type?: string }).type === 'Buffer'
&& Array.isArray((value as { data?: unknown }).data)) {
return Uint8Array.from((value as { data: number[] }).data);
}
throw new Error('Plugin sync IPC expected binary bytes');
}
async function withAbortSignal<T>(
api: ElectronPluginSyncApi,
signal: AbortSignal | undefined,
run: (requestId: string) => Promise<T>,
): Promise<T> {
const requestId = mintRequestId();
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
let onAbort: (() => void) | undefined;
if (signal) {
onAbort = () => {
void api.cancelPluginExtensionRequest?.(requestId).catch(() => false);
};
signal.addEventListener('abort', onAbort, { once: true });
}
try {
return await run(requestId);
} catch (error) {
// Release main-process transfer / abort controller on stream or RPC failure
// even when the caller did not abort (TTL alone is not enough).
try {
await api.cancelPluginExtensionRequest?.(requestId);
} catch {
/* ignore */
}
throw error;
} finally {
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
}
}
export function isPluginSyncIpcAvailable(): boolean {
const api = getPluginSyncApi();
if (
typeof api?.pluginSyncConnect !== 'function'
|| typeof api?.pluginSyncReadObject !== 'function'
|| typeof api?.pluginSyncWriteObject !== 'function'
) {
return false;
}
// Preload always exposes the IPC methods; the host is only ready when the
// main process has a live plugin host (sync sidecar service wired).
const ready = (api as { pluginHostReady?: () => boolean }).pluginHostReady;
if (typeof ready === 'function') {
try {
return ready() === true;
} catch {
return false;
}
}
return true;
}
/**
* Create a PluginSyncProviderHost bound to the renderer preload API.
* Throws when the plugin development gate / host is unavailable.
*/
export function createPluginSyncIpcHost(): PluginSyncProviderHost {
return {
async connectSync(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncConnect !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncConnect!({
...params,
requestId,
}));
},
async disconnectSync(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncDisconnect !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncDisconnect!({
...params,
requestId,
}));
},
async getSyncAccount(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncGetAccount !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncGetAccount!({
...params,
requestId,
}));
},
async getSyncCapabilities(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncGetCapabilities !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncGetCapabilities!({
...params,
requestId,
}));
},
async readSyncObject(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncReadObject !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, async (requestId) => {
const preferStream = params.preferStream === true;
const result = await api.pluginSyncReadObject!({
...params,
preferStream,
requestId,
});
if (!result.found) {
return { found: false, key: params.key, bytes: null };
}
if (result.streamed === true && typeof result.transferId === 'string') {
if (typeof api.pluginSyncReadChunk !== 'function') {
throw new Error('Plugin sync streamed read is unavailable');
}
const total = result.byteLength;
if (!Number.isSafeInteger(total) || total < 1) {
throw new Error('Plugin sync streamed read missing byteLength');
}
const parts: Uint8Array[] = [];
let received = 0;
for (;;) {
if (options?.signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const { chunk, done } = await api.pluginSyncReadChunk({
requestId,
transferId: result.transferId,
maxBytes: STREAM_WINDOW_BYTES,
});
const bytes = coerceBytes(chunk);
parts.push(bytes);
received += bytes.byteLength;
if (received > total) {
throw new Error('Plugin sync read exceeded declared byteLength');
}
if (done) break;
}
if (received !== total) {
throw new Error('Plugin sync read size does not match byteLength');
}
const merged = new Uint8Array(received);
let offset = 0;
for (const part of parts) {
merged.set(part, offset);
offset += part.byteLength;
}
return {
found: true,
key: result.key,
bytes: merged,
revision: result.revision,
contentType: result.contentType,
};
}
if (result.data == null) {
return { found: false, key: params.key, bytes: null };
}
return {
found: true,
key: result.key,
bytes: coerceBytes(result.data),
revision: result.revision,
contentType: result.contentType,
};
});
},
async writeSyncObject(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncWriteObject !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, async (requestId) => {
const bytes = params.bytes instanceof Uint8Array
? params.bytes
: new Uint8Array(params.bytes);
const useStream = bytes.byteLength > INLINE_SYNC_OBJECT_SAFE_BYTES
|| params.preferStream === true;
if (!useStream) {
return api.pluginSyncWriteObject!({
requestId,
providerId: params.providerId,
key: params.key,
data: bytes,
expectedRevision: params.expectedRevision,
preferStream: false,
deadlineMs: params.deadlineMs,
});
}
if (
typeof api.pluginSyncWriteBegin !== 'function'
|| typeof api.pluginSyncWriteChunk !== 'function'
|| typeof api.pluginSyncWriteCommit !== 'function'
) {
throw new Error('Plugin sync streamed write is unavailable');
}
const { transferId, windowBytes } = await api.pluginSyncWriteBegin({
requestId,
providerId: params.providerId,
key: params.key,
byteLength: bytes.byteLength,
expectedRevision: params.expectedRevision,
deadlineMs: params.deadlineMs,
});
const chunkSize = Number.isSafeInteger(windowBytes) && windowBytes > 0
? Math.min(windowBytes, STREAM_WINDOW_BYTES)
: STREAM_WINDOW_BYTES;
let sequence = 0;
for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
if (options?.signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const end = Math.min(bytes.byteLength, offset + chunkSize);
await api.pluginSyncWriteChunk({
requestId,
transferId,
sequence,
chunk: bytes.subarray(offset, end),
});
sequence += 1;
}
return api.pluginSyncWriteCommit({ requestId, transferId });
});
},
async deleteSyncObject(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncDeleteObject !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncDeleteObject!({
...params,
requestId,
}));
},
};
}
/** Store a sync credential in the OS-backed plugin secret store; returns an opaque SecretRef. */
export async function putPluginSyncSecret(params: {
providerId: string;
key?: string;
value: string;
}): Promise<{ kind: 'secret'; id: string; key: string; created?: boolean }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncPutSecret !== 'function') {
throw new Error('Plugin sync secret storage is unavailable');
}
return api.pluginSyncPutSecret({
providerId: params.providerId,
key: params.key ?? 'sync-credential',
value: params.value,
});
}
/** Best-effort delete of OS-backed sync secrets for a plugin provider (disconnect cleanup). */
export async function deletePluginSyncSecrets(params: {
providerId: string;
keys?: string[];
}): Promise<{ deleted: number }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncDeleteSecrets !== 'function') {
return { deleted: 0 };
}
return api.pluginSyncDeleteSecrets({
providerId: params.providerId,
...(params.keys ? { keys: [...params.keys] } : {}),
});
}
/** Restore host-stashed plaintext after a rejected overwrite during reconnect. */
export async function restorePluginSyncSecrets(params: {
providerId: string;
keys: string[];
discard?: boolean;
}): Promise<{ restored: number; discarded?: number }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncRestoreSecrets !== 'function') {
return { restored: 0, discarded: 0 };
}
return api.pluginSyncRestoreSecrets({
providerId: params.providerId,
keys: [...params.keys],
...(params.discard === true ? { discard: true } : {}),
});
}

View File

@@ -0,0 +1,138 @@
/**
* EncryptedObjectStorage implementation that drives a plugin sync Provider
* through the host extension Provider service. Plugins only ever see already
* encrypted object bytes — never the master key or plaintext vault.
*/
import type {
EncryptedObjectAccount,
EncryptedObjectDeleteResult,
EncryptedObjectReadResult,
EncryptedObjectStorage,
EncryptedObjectStorageCapabilities,
EncryptedObjectWriteResult,
} from '../../../domain/encryptedObjectStorage';
import type { PluginSyncCredentialRef as DurablePluginSyncCredentialRef } from '../../../domain/sync';
/**
* SyncConnectPayload.credential — includes one-shot leases for live connect.
* Durable reconnect persistence only keeps secret/credential refs (see domain).
*/
export type PluginSyncCredentialRef =
| DurablePluginSyncCredentialRef
| { kind: 'secret-lease'; id: string; key?: string; operationId?: string; expiresAt?: number };
export type { DurablePluginSyncCredentialRef };
export interface PluginSyncProviderHost {
connectSync(
params: {
providerId: string;
configuration?: unknown;
credential?: PluginSyncCredentialRef;
deadlineMs?: number;
},
options?: { signal?: AbortSignal },
): Promise<{ account: EncryptedObjectAccount }>;
disconnectSync(
params: { providerId: string; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<null>;
getSyncAccount(
params: { providerId: string; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<{ account: EncryptedObjectAccount | null }>;
getSyncCapabilities(
params: { providerId: string; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<EncryptedObjectStorageCapabilities>;
readSyncObject(
params: { providerId: string; key: string; preferStream?: boolean; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<{
found: boolean;
key: string;
bytes: Uint8Array | null;
revision?: string;
contentType?: string;
}>;
writeSyncObject(
params: {
providerId: string;
key: string;
bytes: Uint8Array;
expectedRevision?: string | null;
preferStream?: boolean;
deadlineMs?: number;
},
options?: { signal?: AbortSignal },
): Promise<{ created: boolean; revision?: string }>;
deleteSyncObject(
params: { providerId: string; key: string; expectedRevision?: string; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<{ deleted: boolean }>;
}
export function createPluginSyncObjectStorage(options: {
providerId: string;
host: PluginSyncProviderHost;
configuration?: unknown;
/** Canonical SyncConnectPayload.credential for host-owned secret refs. */
credential?: PluginSyncCredentialRef;
deadlineMs?: number;
}): EncryptedObjectStorage {
const { providerId, host, configuration, credential, deadlineMs } = options;
return {
providerId,
async connect(connectConfiguration, connectOptions): Promise<{ account: EncryptedObjectAccount }> {
// Only treat undefined as "use stored / default". Explicit null is a
// valid JSON configuration for schemas with type: "null".
const resolvedConfiguration = connectConfiguration !== undefined
? connectConfiguration
: (configuration !== undefined ? configuration : {});
return host.connectSync({
providerId,
configuration: resolvedConfiguration,
...(credential ? { credential } : {}),
deadlineMs,
}, { signal: connectOptions?.signal });
},
async disconnect(disconnectOptions): Promise<void> {
await host.disconnectSync({ providerId, deadlineMs }, { signal: disconnectOptions?.signal });
},
async getAccount(accountOptions): Promise<EncryptedObjectAccount | null> {
const result = await host.getSyncAccount({ providerId, deadlineMs }, { signal: accountOptions?.signal });
return result.account ?? null;
},
async getCapabilities(capOptions): Promise<EncryptedObjectStorageCapabilities> {
return host.getSyncCapabilities({ providerId, deadlineMs }, { signal: capOptions?.signal });
},
async readObject(key, readOptions): Promise<EncryptedObjectReadResult> {
return host.readSyncObject({
providerId,
key,
preferStream: readOptions?.preferStream,
deadlineMs,
}, { signal: readOptions?.signal });
},
async writeObject(key, bytes, writeOptions): Promise<EncryptedObjectWriteResult> {
return host.writeSyncObject({
providerId,
key,
bytes,
expectedRevision: writeOptions?.expectedRevision,
preferStream: writeOptions?.preferStream,
deadlineMs,
}, { signal: writeOptions?.signal });
},
async deleteObject(key, deleteOptions): Promise<EncryptedObjectDeleteResult> {
return host.deleteSyncObject({
providerId,
key,
expectedRevision: deleteOptions?.expectedRevision,
deadlineMs,
}, { signal: deleteOptions?.signal });
},
};
}

View File

@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { SyncedFile } from '../../../domain/sync';
import { createAdapter } from './index';
import {
cloudAdapterAsEncryptedObjectStorage,
webdavEncryptedObjectCapabilities,
} from './encryptedObjectStorageBridge';
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
import type { CloudAdapter } from './index';
/**
* Prove the production WebDAV factory returns a CloudAdapter that is wired
* through EncryptedObjectStorage (same surface plugins use), by exercising
* the shared bridge with a real in-memory CloudAdapter that mirrors WebDAV's
* single-file semantics and comparing with createAdapter's webdav wrap shape.
*/
function memoryWebdavAdapter(initial: SyncedFile | null = null): CloudAdapter & { store: SyncedFile | null } {
const adapter = {
store: initial,
isAuthenticated: true,
accountInfo: { id: 'webdav.example', name: 'user@webdav.example' },
resourceId: null as string | null,
signOut() {
adapter.isAuthenticated = false;
adapter.accountInfo = null;
adapter.store = null;
},
async initializeSync() {
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
return adapter.resourceId;
},
async upload(file: SyncedFile) {
adapter.store = file;
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
return adapter.resourceId;
},
async download() {
return adapter.store;
},
async deleteSync() {
adapter.store = null;
},
getTokens() {
return null;
},
};
return adapter;
}
describe('WebDAV EncryptedObjectStorage production path', () => {
it('createAdapter(webdav) wraps through EncryptedObjectStorage (not a raw WebDAVAdapter)', async () => {
const { default: WebDAVAdapter } = await import('./WebDAVAdapter');
const adapter = await createAdapter('webdav', undefined, undefined, {
endpoint: 'https://webdav.example.test',
authType: 'basic',
username: 'user',
password: 'secret',
});
assert.equal(adapter instanceof WebDAVAdapter, false, 'must not return raw WebDAVAdapter');
assert.equal(typeof adapter.upload, 'function');
assert.equal(typeof adapter.download, 'function');
assert.equal(typeof adapter.initializeSync, 'function');
assert.equal(typeof adapter.deleteSync, 'function');
assert.equal(adapter.getTokens(), null);
});
it('createAdapter(webdav) is authenticated when config exists so getConnectedAdapter can reuse', async () => {
const adapter = await createAdapter('webdav', undefined, undefined, {
endpoint: 'https://webdav.example.test',
authType: 'basic',
username: 'user',
password: 'secret',
});
// Pre-wrap WebDAVAdapter reported isAuthenticated whenever config existed.
// The bridge must match so manager cache reuse (existing?.isAuthenticated) works.
assert.equal(adapter.isAuthenticated, true);
});
it('createAdapter(webdav) preserves constructor resourceId and refreshes from backing adapter after initializeSync', async () => {
const persistedPath = '/netcatty-vault.json';
const adapter = await createAdapter(
'webdav',
undefined,
persistedPath,
{
endpoint: 'https://webdav.example.test',
authType: 'basic',
username: 'user',
password: 'secret',
},
);
assert.equal(
adapter.resourceId,
persistedPath,
'must not discard resourceId passed into createAdapter',
);
// Without network, initializeSync fails; still prove resourceId is not
// overwritten to the bare DEFAULT key before connect runs.
assert.notEqual(adapter.resourceId, 'netcatty-vault.json');
});
it('WebDAV-style adapters round-trip encrypted SyncedFile bytes through EncryptedObjectStorage', async () => {
const raw = memoryWebdavAdapter({
meta: {
version: 2,
updatedAt: 1,
deviceId: 'd',
appVersion: '0.0.0',
iv: 'iv',
salt: 'salt',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
},
payload: 'remote-cipher',
});
const storage = cloudAdapterAsEncryptedObjectStorage(raw, 'webdav', {
capabilities: webdavEncryptedObjectCapabilities(),
});
const caps = await storage.getCapabilities();
assert.equal(caps.atomicReplacement, true);
assert.equal(caps.revisions, false);
await storage.connect();
const read = await storage.readObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
assert.equal(read.found, true);
assert.ok(read.bytes);
const text = new TextDecoder().decode(read.bytes!);
assert.match(text, /remote-cipher/);
const next: SyncedFile = {
meta: {
version: 3,
updatedAt: 2,
deviceId: 'd',
appVersion: '0.0.0',
iv: 'iv',
salt: 'salt',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
},
payload: 'next-cipher',
};
await storage.writeObject(
DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY,
new TextEncoder().encode(JSON.stringify(next)),
);
assert.equal(raw.store?.payload, 'next-cipher');
});
});

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);
});

View File

@@ -0,0 +1,81 @@
/**
* Compressed Upload Service
*
* Provides compressed folder upload functionality using tar compression
*/
import { netcattyBridge } from "./netcattyBridge";
export interface CompressUploadOptions {
compressionId: string;
folderPath: string;
targetPath: string;
sftpId: string;
folderName: string;
totalBytes: number;
}
export interface CompressUploadProgress {
phase: 'compressing' | 'uploading' | 'extracting';
transferred: number;
total: number;
}
export interface CompressUploadSupport {
supported: boolean;
localTar: boolean;
remoteTar: boolean;
error?: string;
}
/**
* Start a compressed folder upload
*/
export async function startCompressedUpload(
options: CompressUploadOptions,
): Promise<{ compressionId: string; success?: boolean; error?: string }> {
const bridge = netcattyBridge.get();
if (!bridge?.startCompressedUpload) {
throw new Error("Compressed upload not available");
}
try {
return await bridge.startCompressedUpload(options);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
compressionId: options.compressionId,
success: false,
error: errorMessage
};
}
}
/**
* Cancel a compressed upload
*/
export async function cancelCompressedUpload(compressionId: string): Promise<{ success: boolean }> {
const bridge = netcattyBridge.get();
if (!bridge?.cancelCompressedUpload) {
throw new Error("Compressed upload not available");
}
return bridge.cancelCompressedUpload(compressionId);
}
/**
* Check if compressed upload is supported for a given SFTP session
*/
export async function checkCompressedUploadSupport(sftpId: string): Promise<CompressUploadSupport> {
const bridge = netcattyBridge.get();
if (!bridge?.checkCompressedUploadSupport) {
return {
supported: false,
localTar: false,
remoteTar: false,
error: "Compressed upload not available"
};
}
return bridge.checkCompressedUploadSupport(sftpId);
}

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
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),
clear: () => values.clear(),
},
});
const {
clearConvergentSyncLocalConfigAfterDowngrade,
getConvergentSyncLocalConfig,
markConvergentSyncInitialized,
pauseConvergentSync,
refreshConvergentSyncLocalConfigSnapshot,
subscribeConvergentSyncLocalConfig,
} = await import('./convergentSyncConfig.ts');
test.beforeEach(() => {
values.clear();
refreshConvergentSyncLocalConfigSnapshot();
});
test('pausing an initialized replica preserves its initialized metadata', () => {
markConvergentSyncInitialized();
assert.deepEqual(pauseConvergentSync(), { enabled: false, initialized: true });
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: true });
});
test('downgrade state cannot be cleared without explicit confirmation', () => {
markConvergentSyncInitialized();
assert.throws(
() => clearConvergentSyncLocalConfigAfterDowngrade(false),
/Explicit confirmation/,
);
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: true, initialized: true });
clearConvergentSyncLocalConfigAfterDowngrade(true);
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
});
test('config changes notify every hook instance through the shared store', () => {
const snapshots: Array<{ enabled: boolean; initialized: boolean }> = [];
const unsubscribeFirst = subscribeConvergentSyncLocalConfig(() => {
snapshots.push(getConvergentSyncLocalConfig());
});
const unsubscribeSecond = subscribeConvergentSyncLocalConfig(() => {
snapshots.push(getConvergentSyncLocalConfig());
});
markConvergentSyncInitialized();
pauseConvergentSync();
assert.deepEqual(snapshots, [
{ enabled: true, initialized: true },
{ enabled: true, initialized: true },
{ enabled: false, initialized: true },
{ enabled: false, initialized: true },
]);
unsubscribeFirst();
unsubscribeSecond();
});

View File

@@ -0,0 +1,145 @@
import { STORAGE_KEY_CONVERGENT_SYNC_CONFIG } from '../config/storageKeys';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from '../persistence/localStorageAdapter';
export interface ConvergentSyncLocalConfig {
enabled: boolean;
initialized: boolean;
}
const DEFAULT_CONFIG: ConvergentSyncLocalConfig = {
enabled: false,
initialized: false,
};
const listeners = new Set<() => void>();
let cachedConfig: ConvergentSyncLocalConfig = DEFAULT_CONFIG;
let hasCachedConfig = false;
let detachStorageListeners: (() => void) | null = null;
function readConvergentSyncLocalConfig(): ConvergentSyncLocalConfig {
if (typeof globalThis.localStorage === 'undefined') return DEFAULT_CONFIG;
const stored = localStorageAdapter.read<Partial<ConvergentSyncLocalConfig>>(
STORAGE_KEY_CONVERGENT_SYNC_CONFIG,
);
return {
enabled: stored?.enabled === true,
initialized: stored?.initialized === true,
};
}
function configsEqual(
left: ConvergentSyncLocalConfig,
right: ConvergentSyncLocalConfig,
): boolean {
return left.enabled === right.enabled && left.initialized === right.initialized;
}
function updateCachedConfig(
next: ConvergentSyncLocalConfig,
notify: boolean,
): ConvergentSyncLocalConfig {
if (hasCachedConfig && configsEqual(cachedConfig, next)) return cachedConfig;
cachedConfig = next;
hasCachedConfig = true;
if (notify) {
listeners.forEach((listener) => listener());
}
return cachedConfig;
}
export function getConvergentSyncLocalConfig(): ConvergentSyncLocalConfig {
return updateCachedConfig(readConvergentSyncLocalConfig(), true);
}
export function getConvergentSyncLocalConfigSnapshot(): ConvergentSyncLocalConfig {
if (!hasCachedConfig) {
updateCachedConfig(readConvergentSyncLocalConfig(), false);
}
return cachedConfig;
}
export function refreshConvergentSyncLocalConfigSnapshot(): ConvergentSyncLocalConfig {
return updateCachedConfig(readConvergentSyncLocalConfig(), true);
}
function installStorageListeners(): () => void {
const target = globalThis as typeof globalThis & {
addEventListener?: (type: string, listener: EventListener) => void;
removeEventListener?: (type: string, listener: EventListener) => void;
};
if (
typeof target.addEventListener !== 'function'
|| typeof target.removeEventListener !== 'function'
) {
return () => {};
}
const handleStorageChange: EventListener = (event) => {
const key = event.type === 'storage'
? (event as StorageEvent).key
: (event as CustomEvent<{ key?: string }>).detail?.key;
if (key !== null && key !== STORAGE_KEY_CONVERGENT_SYNC_CONFIG) return;
refreshConvergentSyncLocalConfigSnapshot();
};
target.addEventListener('storage', handleStorageChange);
target.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleStorageChange);
return () => {
target.removeEventListener?.('storage', handleStorageChange);
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleStorageChange);
};
}
export function subscribeConvergentSyncLocalConfig(listener: () => void): () => void {
listeners.add(listener);
if (listeners.size === 1) {
detachStorageListeners = installStorageListeners();
}
// Close the read-before-subscribe race required by useSyncExternalStore:
// storage may have changed after render read the snapshot but before the
// subscription was installed.
refreshConvergentSyncLocalConfigSnapshot();
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
detachStorageListeners?.();
detachStorageListeners = null;
}
};
}
export function setConvergentSyncLocalConfig(
config: ConvergentSyncLocalConfig,
): ConvergentSyncLocalConfig {
const normalized = {
enabled: config.enabled === true,
initialized: config.initialized === true,
};
if (!localStorageAdapter.write(STORAGE_KEY_CONVERGENT_SYNC_CONFIG, normalized)) {
throw new Error('Unable to persist convergent sync configuration');
}
return updateCachedConfig(normalized, true);
}
/** Disabling after initialization pauses v2; it never removes replica metadata. */
export function pauseConvergentSync(): ConvergentSyncLocalConfig {
const current = getConvergentSyncLocalConfig();
return setConvergentSyncLocalConfig({
enabled: false,
initialized: current.initialized,
});
}
export function markConvergentSyncInitialized(): ConvergentSyncLocalConfig {
return setConvergentSyncLocalConfig({ enabled: true, initialized: true });
}
export function clearConvergentSyncLocalConfigAfterDowngrade(
confirmed: boolean,
): ConvergentSyncLocalConfig {
if (!confirmed) throw new Error('Explicit confirmation is required to downgrade convergent sync');
return setConvergentSyncLocalConfig(DEFAULT_CONFIG);
}

View File

@@ -0,0 +1,12 @@
import { netcattyBridge } from "./netcattyBridge";
export const getCredentialProtectionAvailability = async (): Promise<boolean | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.credentialsAvailable) return null;
try {
return await bridge.credentialsAvailable();
} catch {
return null;
}
};

View File

@@ -0,0 +1,18 @@
export class BridgeUnavailableError extends Error {
constructor(message = "Netcatty bridge unavailable") {
super(message);
this.name = "BridgeUnavailableError";
}
}
export const netcattyBridge = {
get(): NetcattyBridge | undefined {
return typeof window !== 'undefined' ? window.netcatty : undefined;
},
require(): NetcattyBridge {
const bridge = window.netcatty;
if (!bridge) throw new BridgeUnavailableError();
return bridge;
},
};

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,73 @@
/**
* syncAnchorDecision — pure "has the remote changed since we last saw it?"
* logic extracted from CloudSyncManager so it can be exercised by
* `node --test` without standing up the full manager harness.
*
* Called from CloudSyncManager.inspectProviderRemoteState after the
* remote has been downloaded and its signature computed. Given the
* previous anchor and the current state, decides whether the remote
* looks different enough to warrant re-merging.
*
* Four decisions matter for data integrity:
*
* 1. Anchor missing + remote empty → not changed (first sync, nothing
* to merge from). Callers MUST still guard against pushing an empty
* local vault (see useAutoSync `hasMeaningfulSyncData`) — that guard
* is orthogonal to this decision.
* 2. Anchor missing + remote non-empty → changed (first sync, remote
* has data we've never observed → three-way merge with empty base).
* 3. Anchor present + resourceId drift → changed (provider created a
* fresh file; reuse of the old anchor would be meaningless).
* 4. Anchor present + signature mismatch → changed (same resource, new
* ciphertext — standard drift).
*
* Any other state is "unchanged", and callers short-circuit the merge.
*
* @param {{
* currentSignature: string | null,
* currentResourceId: string | null,
* anchor: { signature?: string | null, resourceId?: string | null } | null,
* hasRemoteFile: boolean,
* }} input
* @returns {{ remoteChanged: boolean, reason: string }}
*/
export function decideRemoteChanged(input) {
const { currentSignature, currentResourceId, anchor, hasRemoteFile } = input;
if (!anchor) {
// No anchor means we've never observed this provider.
if (!hasRemoteFile) {
// Remote has no file at all → nothing to merge.
return { remoteChanged: false, reason: 'no-anchor-no-remote' };
}
if (currentSignature === null) {
// hasRemoteFile=true but the signature computed to null — the
// file exists but we can't hash its meta (malformed shape, newer
// schema, partial download). Treat as CHANGED so the caller
// routes through the three-way merge / decrypt path rather than
// silently short-circuiting and letting the next upload overwrite
// an unreadable-but-extant remote file. If the payload is
// decryptable the merge will succeed; if it isn't, the decrypt
// error surfaces to the user, which is strictly safer than a
// silent stomp.
return { remoteChanged: true, reason: 'unreadable-remote' };
}
return { remoteChanged: true, reason: 'no-anchor-remote-has-data' };
}
// Resource identity drift: provider returned a different resource
// (e.g. a freshly-created gist, or the user reconnected and the
// adapter picked a new file). The previous anchor's signature is
// meaningless once the resource id changes.
const anchorResourceId = anchor.resourceId ?? null;
if (anchorResourceId !== currentResourceId) {
return { remoteChanged: true, reason: 'resource-id-changed' };
}
// Same resource, different signature → new ciphertext/meta.
if ((anchor.signature ?? null) !== currentSignature) {
return { remoteChanged: true, reason: 'signature-mismatch' };
}
return { remoteChanged: false, reason: 'anchor-matches' };
}

View File

@@ -0,0 +1,213 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decideRemoteChanged } from './syncAnchorDecision.js';
// -----------------------------------------------------------------------
// Anchor-missing branches
// -----------------------------------------------------------------------
test('no anchor + empty remote → not changed (first sync with empty cloud)', () => {
const result = decideRemoteChanged({
currentSignature: null,
currentResourceId: null,
anchor: null,
hasRemoteFile: false,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'no-anchor-no-remote');
});
test('no anchor + non-empty remote → changed (first sync with data in cloud)', () => {
// Critical: this is the "new device with existing cloud vault" path.
// Returning not-changed here would silently skip the three-way merge
// and let an empty local push clobber remote.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-remote',
currentResourceId: 'gist-1',
anchor: null,
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'no-anchor-remote-has-data');
});
test('no anchor + hasRemoteFile true but null signature → changed (unreadable remote, C3)', () => {
// Previously this returned `remoteChanged: false`, which silently
// routed callers down the "nothing to merge" short-circuit and then
// let the upload path stomp the malformed-but-extant remote file on
// the next push. Treating an unreadable remote as "changed" forces the
// three-way-merge branch — if the payload is decryptable the merge
// succeeds, and if it isn't the decrypt error surfaces to the user
// instead of being silently papered over by an overwrite.
const result = decideRemoteChanged({
currentSignature: null,
currentResourceId: 'gist-1',
anchor: null,
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'unreadable-remote');
});
// -----------------------------------------------------------------------
// Anchor-matches branches
// -----------------------------------------------------------------------
test('anchor matches signature and resourceId → not changed', () => {
const result = decideRemoteChanged({
currentSignature: 'v3:sig-A',
currentResourceId: 'gist-1',
anchor: { signature: 'v3:sig-A', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'anchor-matches');
});
// -----------------------------------------------------------------------
// Anchor-stale branches
// -----------------------------------------------------------------------
test('anchor signature mismatch → changed', () => {
const result = decideRemoteChanged({
currentSignature: 'v3:sig-NEW',
currentResourceId: 'gist-1',
anchor: { signature: 'v3:sig-OLD', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'signature-mismatch');
});
test('anchor resourceId mismatch → changed (even when signatures happen to match)', () => {
// Provider created a fresh file (gist recreated, Drive file recreated).
// The old anchor's signature is meaningless once the resource id drifts.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-SAME',
currentResourceId: 'gist-NEW',
anchor: { signature: 'v3:sig-SAME', resourceId: 'gist-OLD' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'resource-id-changed');
});
test('anchor resourceId was null, now has value → changed', () => {
// Before: user connected but first-sync had no resource yet.
// Now: provider returned a concrete id. Treat as changed so the
// follow-up re-inspects correctly.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-A',
currentResourceId: 'gist-1',
anchor: { signature: 'v3:sig-A', resourceId: null },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'resource-id-changed');
});
test('anchor resourceId had value, now null → changed', () => {
// Adapter lost the resource id somehow (disconnect, re-login). The
// old signature-based comparison is not trustworthy here.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-A',
currentResourceId: null,
anchor: { signature: 'v3:sig-A', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'resource-id-changed');
});
// -----------------------------------------------------------------------
// Defensive shapes
// -----------------------------------------------------------------------
test('anchor with undefined signature → changed unless current is also null', () => {
// `anchor.signature` missing (pre-v2 persisted record, say) and
// `currentSignature` non-null → must not treat as match.
const changed = decideRemoteChanged({
currentSignature: 'v3:sig',
currentResourceId: 'id-1',
anchor: { resourceId: 'id-1' },
hasRemoteFile: true,
});
assert.equal(changed.remoteChanged, true);
assert.equal(changed.reason, 'signature-mismatch');
});
test('anchor signature null and current signature null with same resourceId → not changed', () => {
// The legitimate "empty-on-both-sides already observed" case.
const result = decideRemoteChanged({
currentSignature: null,
currentResourceId: 'id-1',
anchor: { signature: null, resourceId: 'id-1' },
hasRemoteFile: false,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'anchor-matches');
});
// -----------------------------------------------------------------------
// Migration: stored v2 anchor → fresh v3 signature from this build
// -----------------------------------------------------------------------
test('v2 anchor persisted from older build → signature-mismatch against v3 (migration)', () => {
// A user upgrading from a build that persisted `v2:<prefix-hash>` must
// see the next startup inspection treat the remote as "changed". The
// v3 signature format is `v3:{...meta}|len=...|sha256=...`; the two
// strings can never compare equal, so the decision routes through
// three-way merge and re-observes the remote. Without this property
// a stale v2 anchor would be treated as authoritative, skipping the
// merge and letting local-only state overwrite remote — the very
// #711/#719 failure path.
const result = decideRemoteChanged({
currentSignature: 'v3:{"appVersion":"1.0.0"}|len=80|sha256=' + 'a'.repeat(64),
currentResourceId: 'gist-1',
anchor: { signature: 'v2:abcdef1234567890', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'signature-mismatch');
});
// -----------------------------------------------------------------------
// Regression: issues #711 / #719 — stale-device-overwrites-newer-remote
// -----------------------------------------------------------------------
test('stale device sees fresh remote → triggers merge, not overwrite (#711/#719)', () => {
// Scenario: Device A syncs at T0, anchor records signature sigA.
// User edits on Device B at T1 → remote signature becomes sigB.
// Device A then wakes up with a stale anchor (sigA) and the fresh
// remote (sigB). The decision MUST say "remote changed" so the
// sync path three-way merges Device A's local into remote instead
// of short-circuiting to "no change" and overwriting Device B's edit.
const sigA = 'v3:{"updatedAt":1700000000000}|len=80|sha256=' + 'a'.repeat(64);
const sigB = 'v3:{"updatedAt":1700000300000}|len=80|sha256=' + 'b'.repeat(64);
const result = decideRemoteChanged({
currentSignature: sigB,
currentResourceId: 'gist-1',
anchor: { signature: sigA, resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'signature-mismatch');
});
test('fresh device, same-signature anchor → no spurious merge (#711/#719 inverse)', () => {
// Inverse guard: a device whose anchor matches the current remote
// signature must NOT be dragged through a merge round-trip, which
// would cause the "everyone re-uploads on every startup" thrash seen
// in the pre-anchor implementation. This locks in that the anchor
// logic correctly short-circuits the common case.
const sig = 'v3:{"updatedAt":1700000000000}|len=80|sha256=' + 'a'.repeat(64);
const result = decideRemoteChanged({
currentSignature: sig,
currentResourceId: 'gist-1',
anchor: { signature: sig, resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'anchor-matches');
});

View File

@@ -0,0 +1,130 @@
/**
* syncSignature - Provider-agnostic remote snapshot fingerprint.
*
* Stable, order-independent signature of a SyncedFile used by
* CloudSyncManager to decide whether a remote has changed since we last
* observed it. Must produce the same value for semantically-identical
* remotes and a different value for any ciphertext/metadata change.
*
* Kept as a plain ESM .js file (JSDoc-typed) so it works seamlessly with
* both Vite's bundler in the renderer AND Node's `node --test` harness
* without needing a TypeScript test runner. CloudSyncManager.ts imports
* it via a normal ESM import.
*
* The previous implementation in CloudSyncManager only hashed
* `[version, updatedAt, deviceId, iv, salt]`. That meant:
* - a misbehaving adapter could replay those five fields while
* mutating algorithm/kdf/appVersion and the anchor would treat the
* remote as unchanged;
* - deviceId (a field the remote controls) was weighted as strongly
* as iv/salt;
* - ciphertext changes with metadata held constant could slip past.
*
* v3 hashes the full meta object (sorted for stability) plus the
* SHA-256 of the full payload ciphertext so any of those mutations flip
* the anchor. v2 used only a 64-char prefix of the ciphertext, which is
* easily defeated by an adversary that controls the remote and can
* tail-mutate while preserving the prefix. v3 is resistant to any
* ciphertext mutation.
*
* Version prefixes are part of the signature string itself (`v3:`) so
* an older anchor persisted from a previous build will simply never
* compare equal to a fresh signature from this build, forcing a
* single-cycle safe re-detection (treated as "remote changed" which
* triggers three-way merge) rather than a silent mismatch.
*
* INVARIANT: `meta` values must be primitives (strings, numbers,
* booleans, null/undefined). Nested objects or arrays in meta would
* serialize via JSON.stringify, which does NOT sort keys — breaking
* signature stability. All current SyncedFile meta fields satisfy this.
*/
/**
* Sentinel error for a missing WebCrypto subtle digest — see
* `sha256Hex` and `createSyncedFileSignature` for the fail-closed
* handling.
*/
class SyncSignatureUnavailableError extends Error {
constructor() {
super('WebCrypto subtle.digest is unavailable; signature cannot be computed safely.');
this.name = 'SyncSignatureUnavailableError';
}
}
/**
* Compute SHA-256 of a UTF-8 string, returning lowercase hex.
*
* Uses `globalThis.crypto.subtle` (Web Crypto API) which is available in
* both the Electron renderer and Node.js ≥ 19 (the repo's runtime targets
* both, and CI/tests run under Node). Keeping to the Web Crypto API also
* avoids pulling `node:crypto` into the renderer bundle.
*
* Throws `SyncSignatureUnavailableError` when subtle.digest is missing.
* Earlier revisions returned a length-only fallback string (`nosha-N`),
* which would produce a short, truncation-trivial pseudo-signature that
* an attacker controlling the remote could alias against a legitimate
* v3 signature of the same length. Failing loudly here lets the caller
* in `createSyncedFileSignature` return `null`, which routes through
* the "unreadable remote → treat as changed → three-way merge or
* surface decrypt error" path — strictly safer than a weak signature.
*
* @param {string} input
* @returns {Promise<string>}
*/
async function sha256Hex(input) {
const subtle = globalThis.crypto?.subtle;
if (!subtle?.digest) {
throw new SyncSignatureUnavailableError();
}
const bytes = new globalThis.TextEncoder().encode(input);
const buf = await subtle.digest('SHA-256', bytes);
const arr = new Uint8Array(buf);
let hex = '';
for (let i = 0; i < arr.length; i += 1) {
hex += arr[i].toString(16).padStart(2, '0');
}
return hex;
}
/**
* @param {import('../../domain/sync').SyncedFile | null} syncedFile
* @returns {Promise<string | null>}
*/
export async function createSyncedFileSignature(syncedFile) {
if (!syncedFile) return null;
const { meta, payload } = syncedFile;
if (!meta || typeof meta !== 'object') return null;
// Serialize meta as a canonical JSON object with keys sorted. Earlier
// versions joined `${key}=${JSON.stringify(...)}` with `|`, which left
// the `=` separator unescaped: a future meta key containing `=` in its
// name (or a string value that mimics the separator syntax) could
// alias with a different key/value pair. JSON.stringify of a sorted
// plain object is injection-proof because string values are quoted
// and escaped by the serializer.
const metaKeys = Object.keys(meta).sort();
const canonicalMeta = {};
for (const key of metaKeys) {
canonicalMeta[key] = meta[key] ?? null;
}
const metaSerialized = JSON.stringify(canonicalMeta);
const payloadStr = typeof payload === 'string' ? payload : '';
const payloadLen = payloadStr.length;
let payloadHash;
try {
payloadHash = payloadStr ? await sha256Hex(payloadStr) : 'empty';
} catch (error) {
if (error instanceof SyncSignatureUnavailableError) {
// Fail closed: no signature → decideRemoteChanged's
// `currentSignature === null` branch treats the remote as
// "unreadable" and routes through three-way merge. That is the
// safe behavior vs. a weak pseudo-signature that could silently
// alias against another payload of the same length.
return null;
}
throw error;
}
return `v3:${metaSerialized}|len=${payloadLen}|sha256=${payloadHash}`;
}

View File

@@ -0,0 +1,212 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSyncedFileSignature } from './syncSignature.js';
function makeSyncedFile(overrides = {}) {
const meta = {
version: 1,
updatedAt: 1_700_000_000_000,
deviceId: 'device-a',
deviceName: 'Device A',
appVersion: '1.0.0',
iv: 'BASE64_IV',
salt: 'BASE64_SALT',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
kdfIterations: 600000,
...(overrides.meta || {}),
};
return {
meta,
payload: overrides.payload ?? 'CIPHERTEXTxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
};
}
test('null file produces null signature', async () => {
assert.equal(await createSyncedFileSignature(null), null);
});
test('two identical files produce identical signatures', async () => {
const a = makeSyncedFile();
const b = makeSyncedFile();
assert.equal(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('signature is stable across meta key-insertion order', async () => {
const canonical = makeSyncedFile();
const shuffled = {
meta: {
kdf: 'PBKDF2',
salt: 'BASE64_SALT',
iv: 'BASE64_IV',
appVersion: '1.0.0',
deviceName: 'Device A',
deviceId: 'device-a',
updatedAt: 1_700_000_000_000,
version: 1,
algorithm: 'AES-256-GCM',
kdfIterations: 600000,
},
payload: canonical.payload,
};
assert.equal(await createSyncedFileSignature(canonical), await createSyncedFileSignature(shuffled));
});
test('changing iv flips the signature', async () => {
const a = makeSyncedFile();
const b = makeSyncedFile({ meta: { iv: 'DIFFERENT_IV' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing salt flips the signature', async () => {
const a = makeSyncedFile();
const b = makeSyncedFile({ meta: { salt: 'DIFFERENT_SALT' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing updatedAt flips the signature', async () => {
const a = makeSyncedFile();
const b = makeSyncedFile({ meta: { updatedAt: 1_700_000_000_001 } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing algorithm flips the signature (v1 regression guard)', async () => {
// The old signature only hashed version/updatedAt/deviceId/iv/salt — an
// adapter could have changed algorithm/kdf while holding those constant.
// v2+ must reject that.
const a = makeSyncedFile({ meta: { algorithm: 'AES-256-GCM' } });
const b = makeSyncedFile({ meta: { algorithm: 'ChaCha20-Poly1305' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing kdf flips the signature (v1 regression guard)', async () => {
const a = makeSyncedFile({ meta: { kdf: 'PBKDF2' } });
const b = makeSyncedFile({ meta: { kdf: 'Argon2id' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing appVersion flips the signature (v1 regression guard)', async () => {
const a = makeSyncedFile({ meta: { appVersion: '1.0.0' } });
const b = makeSyncedFile({ meta: { appVersion: '2.0.0' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing payload ciphertext flips the signature even when meta matches', async () => {
// Critical: a malicious or buggy adapter could replay meta while swapping
// the ciphertext. v2+ must treat the payload as load-bearing.
const a = makeSyncedFile({ payload: 'AAA' + 'x'.repeat(60) });
const b = makeSyncedFile({ payload: 'BBB' + 'x'.repeat(60) });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing payload length flips the signature (truncation guard)', async () => {
// v3 hashes the full ciphertext — any length difference flips the signature.
const prefix = 'x'.repeat(64);
const a = makeSyncedFile({ payload: prefix });
const b = makeSyncedFile({ payload: `${prefix}extra` });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('tail-mutation of a long ciphertext flips the signature (v2 prefix-replay guard)', async () => {
// v2 only hashed the first 64 chars of the ciphertext. An adversary with
// write access to the remote could preserve the prefix and mutate only the
// tail, producing a signature collision. v3 hashes the full ciphertext and
// must catch tail mutations even when prefix + length are preserved.
const prefix = 'x'.repeat(64);
const tailA = 'AAAAAAAAAAAAAAAA';
const tailB = 'BBBBBBBBBBBBBBBB';
const a = makeSyncedFile({ payload: `${prefix}${tailA}` });
const b = makeSyncedFile({ payload: `${prefix}${tailB}` });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('deviceId alone is not sufficient to match (metadata weighted properly)', async () => {
// Both share deviceId but differ on iv — must not alias.
const a = makeSyncedFile({ meta: { deviceId: 'same', iv: 'IV_A' } });
const b = makeSyncedFile({ meta: { deviceId: 'same', iv: 'IV_B' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('missing optional meta fields hash as null rather than throwing', async () => {
const partial = {
meta: {
version: 1,
updatedAt: 1_700_000_000_000,
deviceId: 'device',
appVersion: '1.0.0',
iv: 'IV',
salt: 'S',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
// deviceName and kdfIterations omitted intentionally
},
payload: 'short',
};
const sig = await createSyncedFileSignature(partial);
assert.equal(typeof sig, 'string');
assert.ok(sig.startsWith('v3:'));
});
test('file with non-string payload produces signature with len=0', async () => {
// Defensive: if an adapter somehow yields a non-string payload, we still
// generate a well-formed signature rather than crashing.
const weird = { meta: makeSyncedFile().meta, payload: null };
const sig = await createSyncedFileSignature(weird);
assert.ok(sig);
assert.ok(sig.includes('len=0'));
assert.ok(sig.includes('sha256='));
});
test('signature contains a 64-char hex SHA-256 segment', async () => {
// Lock in the hash algorithm choice so a future regression to prefix-hashing
// is caught by this unit test.
const file = makeSyncedFile();
const sig = await createSyncedFileSignature(file);
assert.ok(sig);
const match = sig.match(/sha256=([a-f0-9]+)/);
assert.ok(match, `expected sha256=<hex> in signature, got ${sig}`);
assert.equal(match[1].length, 64);
});
test('v2-format anchor string does not equal a v3 signature', async () => {
// Migration guard: if a user's localStorage carries a v2-prefixed anchor
// from a previous build, comparing against a fresh v3 signature must flip
// to "remote changed" so we re-observe rather than treating a stale anchor
// as authoritative.
const file = makeSyncedFile();
const v3 = await createSyncedFileSignature(file);
const v2Like = String(v3).replace(/^v3:/, 'v2:').replace(/sha256=[a-f0-9]+$/, 'head=xxxxxxxxxxxxxxxx');
assert.notEqual(v3, v2Like);
});
test('missing WebCrypto subtle → signature is null (fail-closed, no weak fallback)', async () => {
// Earlier revisions returned `nosha-<length>` when subtle.digest was
// unavailable. That fallback was length-only, so an adversary
// controlling the remote could trivially produce a payload whose
// weak pseudo-signature equals a legitimate v3 signature of the
// same length. Failing to `null` routes decideRemoteChanged into the
// "unreadable remote → treat as changed → three-way merge" path,
// which is strictly safer.
//
// `globalThis.crypto` is a read-only getter in Node, so we override
// the `subtle` property on the existing object rather than
// reassigning the whole binding.
const subtleDescriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'subtle');
Object.defineProperty(globalThis.crypto, 'subtle', {
configurable: true,
get() {
return undefined;
},
});
try {
const sig = await createSyncedFileSignature(makeSyncedFile());
assert.equal(sig, null, 'missing subtle must not produce a weak fallback string');
} finally {
if (subtleDescriptor) {
Object.defineProperty(globalThis.crypto, 'subtle', subtleDescriptor);
} else {
delete globalThis.crypto.subtle;
}
}
});

View File

@@ -0,0 +1,223 @@
/**
* Update Service
*
* Combines two update mechanisms:
* 1. GitHub API-based version comparison (used by useUpdateCheck for notification banner)
* 2. electron-updater bridge (used by SettingsSystemTab for download/install)
*/
import { netcattyBridge } from "./netcattyBridge";
// ================================
// Part 1: GitHub API Version Check
// ================================
const GITHUB_API_URL = 'https://api.github.com/repos/binaricat/Netcatty/releases/latest';
const RELEASES_PAGE_URL = 'https://github.com/binaricat/Netcatty/releases';
export interface ReleaseInfo {
version: string; // e.g. "1.0.0" (without 'v' prefix)
tagName: string; // e.g. "v1.0.0"
name: string; // Release title
body: string; // Release notes (markdown)
htmlUrl: string; // URL to the release page
publishedAt: string; // ISO date string
assets: ReleaseAsset[];
}
export interface ReleaseAsset {
name: string;
browserDownloadUrl: string;
size: number;
}
export interface UpdateCheckResult {
hasUpdate: boolean;
currentVersion: string;
latestRelease: ReleaseInfo | null;
error?: string;
}
/**
* Parse version string to comparable array
* e.g. "1.2.3" -> [1, 2, 3]
*/
function parseVersion(version: string): number[] {
// Remove 'v' prefix if present
const clean = version.replace(/^v/i, '');
return clean.split('.').map((part) => {
const num = parseInt(part, 10);
return isNaN(num) ? 0 : num;
});
}
/**
* Compare two version strings
* Returns: 1 if a > b, -1 if a < b, 0 if equal
*/
export function compareVersions(a: string, b: string): number {
const partsA = parseVersion(a);
const partsB = parseVersion(b);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const numA = partsA[i] ?? 0;
const numB = partsB[i] ?? 0;
if (numA > numB) return 1;
if (numA < numB) return -1;
}
return 0;
}
/**
* Check for updates via GitHub API (compares version strings).
* Used by useUpdateCheck for the notification banner.
*/
export async function checkForUpdates(currentVersion: string): Promise<UpdateCheckResult> {
try {
const response = await fetch(GITHUB_API_URL, {
headers: { Accept: 'application/vnd.github.v3+json' },
});
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}`);
}
const data = await response.json();
const latestVersion = (data.tag_name as string).replace(/^v/i, '');
const latestRelease: ReleaseInfo = {
version: latestVersion,
tagName: data.tag_name,
name: data.name || data.tag_name,
body: data.body || '',
htmlUrl: data.html_url,
publishedAt: data.published_at,
assets: (data.assets || []).map((a: { name: string; browser_download_url: string; size: number }) => ({
name: a.name,
browserDownloadUrl: a.browser_download_url,
size: a.size,
})),
};
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
return { hasUpdate, currentVersion, latestRelease };
} catch (error) {
return {
hasUpdate: false,
currentVersion,
latestRelease: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Get release page URL for a specific version
*/
export function getReleaseUrl(version?: string): string {
if (version) {
return `${RELEASES_PAGE_URL}/tag/v${version.replace(/^v/i, '')}`;
}
return RELEASES_PAGE_URL;
}
/**
* Get download URL for current platform
*/
export function getDownloadUrlForPlatform(
release: ReleaseInfo,
platform: string
): string | null {
const assets = release.assets;
// Platform-specific file patterns
const patterns: Record<string, RegExp[]> = {
win32: [/\.exe$/i, /win.*\.zip$/i, /windows/i],
darwin: [/\.dmg$/i, /mac.*\.zip$/i, /darwin/i],
linux: [/\.AppImage$/i, /\.deb$/i, /linux/i],
};
const platformPatterns = patterns[platform] || [];
for (const pattern of platformPatterns) {
const asset = assets.find((a) => pattern.test(a.name));
if (asset) {
return asset.browserDownloadUrl;
}
}
// Fallback to release page
return null;
}
// =============================================
// Part 2: electron-updater Bridge (IPC-based)
// =============================================
export interface ElectronUpdateCheckResult {
available: boolean;
supported?: boolean;
version?: string;
releaseNotes?: string;
releaseDate?: string | null;
error?: string;
}
export interface UpdateDownloadProgress {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number;
}
export async function checkForUpdate(): Promise<ElectronUpdateCheckResult> {
const bridge = netcattyBridge.get();
if (!bridge?.checkForUpdate) {
return { available: false, supported: false, error: "Bridge unavailable" };
}
try {
return await bridge.checkForUpdate();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return { available: false, error: message };
}
}
export async function downloadUpdate(): Promise<{ success: boolean; error?: string }> {
const bridge = netcattyBridge.get();
if (!bridge?.downloadUpdate) {
return { success: false, error: "Bridge unavailable" };
}
return bridge.downloadUpdate();
}
export function installUpdate(): void {
const bridge = netcattyBridge.get();
bridge?.installUpdate?.();
}
export function onDownloadProgress(
cb: (progress: UpdateDownloadProgress) => void,
): (() => void) | undefined {
return netcattyBridge.get()?.onUpdateDownloadProgress?.(cb);
}
export function onDownloaded(cb: () => void): (() => void) | undefined {
return netcattyBridge.get()?.onUpdateDownloaded?.(cb);
}
export function onError(
cb: (payload: { error: string }) => void,
): (() => void) | undefined {
return netcattyBridge.get()?.onUpdateError?.(cb);
}
/** Returns the GitHub Releases page URL, optionally for a specific version tag. */
export function getReleasesUrl(version?: string): string {
if (version) {
return `${RELEASES_PAGE_URL}/tag/v${version}`;
}
return `${RELEASES_PAGE_URL}/latest`;
}

View File

@@ -0,0 +1,161 @@
import assert from "node:assert/strict";
import test from "node:test";
import { importVaultHostFiles } from "./vaultImportBatch.ts";
const secureCrtFile = (
relativePath: string,
hostname: string | null,
portHex = "00000016",
protocol = "SSH2",
) => {
const file = new File([[
hostname ? `S:"Hostname"=${hostname}` : "S:\"Username\"=nobody",
'S:"Username"=operator',
`S:"Protocol Name"=${protocol}`,
`D:"[SSH2] Port"=${portHex}`,
].join("\n")], relativePath.split("/").at(-1) ?? "session.ini");
Object.defineProperty(file, "webkitRelativePath", { value: relativePath });
return file;
};
test("SecureCRT directory import reads every session and preserves folder groups", async () => {
const result = await importVaultHostFiles({
format: "securecrt",
files: [
secureCrtFile("Sessions/Production/Web.ini", "web.example.com", "000008ae"),
secureCrtFile("Sessions/Staging/DB.ini", "db.example.com"),
secureCrtFile("Sessions/Archive/Web Copy.ini", "web.example.com", "000008ae"),
secureCrtFile("Sessions/Default.ini", "should-not-import.example.com"),
secureCrtFile("Sessions/Production/__FolderData__.ini", "should-not-import.example.com"),
secureCrtFile("Sessions/Broken.ini", null),
],
});
assert.deepEqual(result.stats, {
parsed: 3,
imported: 3,
skipped: 1,
duplicates: 0,
});
assert.deepEqual(
result.hosts.map(({ label, hostname, port, group }) => ({ label, hostname, port, group })),
[
{
label: "Web",
hostname: "web.example.com",
port: 2222,
group: "Production",
},
{
label: "DB",
hostname: "db.example.com",
port: 22,
group: "Staging",
},
{
label: "Web Copy",
hostname: "web.example.com",
port: 2222,
group: "Archive",
},
],
);
assert.deepEqual(result.groups, ["Production", "Staging", "Archive"]);
assert.match(result.issues[0]?.message ?? "", /Broken\.ini/);
});
test("SecureCRT keeps separate session files that point to the same endpoint", async () => {
const session = [
'S:"Hostname"=shared.example.com',
'S:"Username"=root',
'S:"Protocol Name"=SSH2',
].join("\n");
const result = await importVaultHostFiles({
format: "securecrt",
files: [
new File([session], "web.ini"),
new File([session], "web.ini"),
],
relativePaths: [
"Sessions/Prod/web.ini",
"Sessions/Staging/web.ini",
],
});
assert.equal(result.hosts.length, 2);
assert.deepEqual(result.hosts.map((host) => host.group), ["Prod", "Staging"]);
});
test("SecureCRT destination group keeps same-endpoint session files", async () => {
const { applyVaultImportDestination } = await import("../../domain/vaultImport");
const session = [
'S:"Hostname"=shared.example.com',
'S:"Username"=root',
'S:"Protocol Name"=SSH2',
].join("\n");
const imported = await importVaultHostFiles({
format: "securecrt",
files: [
new File([session], "web.ini"),
new File([session], "web.ini"),
],
relativePaths: [
"Sessions/Prod/web.ini",
"Sessions/Staging/web.ini",
],
});
const targeted = applyVaultImportDestination(
imported,
{ mode: "group", group: "Imported/SecureCRT" },
{ collapseDuplicateEndpoints: false },
);
assert.equal(targeted.hosts.length, 2);
assert.deepEqual(targeted.hosts.map((host) => host.group), [
"Imported/SecureCRT",
"Imported/SecureCRT",
]);
});
test("SecureCRT batch import does not count an unsupported session twice", async () => {
const result = await importVaultHostFiles({
format: "securecrt",
files: [secureCrtFile("Sessions/Local.ini", "localhost", "00000016", "Local")],
});
assert.equal(result.hosts.length, 0);
assert.equal(result.stats.parsed, 1);
assert.equal(result.stats.skipped, 1);
assert.equal(result.issues.length, 1);
});
test("SecureCRT folder paths transferred alongside files survive the worker boundary", async () => {
const file = new File([
[
'S:"Hostname"=transferred.example.com',
'S:"Username"=operator',
'S:"Protocol Name"=SSH2',
].join("\n"),
], "Transferred.ini");
const result = await importVaultHostFiles({
format: "securecrt",
files: [file],
relativePaths: ["Sessions/Production/Transferred.ini"],
});
assert.equal(result.hosts[0]?.group, "Production");
assert.deepEqual(result.groups, ["Production"]);
});
test("SecureCRT keeps a real nested Sessions folder when that folder was selected", async () => {
const result = await importVaultHostFiles({
format: "securecrt",
files: [secureCrtFile("Sessions/Sessions/Nested.ini", "nested.example.com")],
});
assert.equal(result.hosts[0]?.group, "Sessions");
assert.deepEqual(result.groups, ["Sessions"]);
});

View File

@@ -0,0 +1,168 @@
import type { Host } from "../../domain/models";
import {
buildVaultHostMergeKey,
importVaultHostsFromText,
mergeVaultImportIssues,
type VaultImportFormat,
type VaultImportIssue,
type VaultImportResult,
} from "../../domain/vaultImport";
import {
readVaultImportFile,
type VaultImportFileEncoding,
} from "./vaultImportFile";
export interface VaultImportBatchProgress {
completedFiles: number;
totalFiles: number;
fileName: string;
}
interface ImportVaultHostFilesOptions {
format: VaultImportFormat;
files: File[];
relativePaths?: string[];
encoding?: VaultImportFileEncoding;
masterPassword?: string;
onProgress?: (progress: VaultImportBatchProgress) => void;
}
const SECURE_CRT_METADATA_FILES = new Set([
"__folderdata__.ini",
"default.ini",
]);
const normalizeRelativePath = (file: File, transferredRelativePath?: string): string[] => {
const relativePath = transferredRelativePath?.trim() || file.webkitRelativePath?.trim();
if (!relativePath) return [file.name];
return relativePath.split(/[\\/]+/).filter(Boolean);
};
const secureCrtGroupFromFile = (
file: File,
transferredRelativePath?: string,
): string | undefined => {
const segments = normalizeRelativePath(file, transferredRelativePath);
if (segments.length <= 1) return undefined;
segments.pop();
const selectedRoot = segments.shift();
if (
selectedRoot?.toLowerCase() !== "sessions"
&& segments[0]?.toLowerCase() === "sessions"
) {
segments.shift();
}
return segments.length > 0 ? segments.join("/") : undefined;
};
const shouldIgnoreSecureCrtFile = (file: File): boolean => (
SECURE_CRT_METADATA_FILES.has(file.name.toLowerCase())
|| !file.name.toLowerCase().endsWith(".ini")
);
export async function importVaultHostFiles({
format,
files,
relativePaths,
encoding,
masterPassword,
onProgress,
}: ImportVaultHostFilesOptions): Promise<VaultImportResult> {
const sourceFiles = files.map((file, index) => ({
file,
relativePath: relativePaths?.[index],
}));
const selectedFiles = format === "securecrt"
? sourceFiles.filter(({ file }) => !shouldIgnoreSecureCrtFile(file))
: sourceFiles.slice(0, 1);
const hosts: Host[] = [];
const issues: VaultImportIssue[] = [];
const keyPassphrases: NonNullable<VaultImportResult["keyPassphrases"]> = [];
const keyPassphraseCandidates: NonNullable<VaultImportResult["keyPassphraseCandidates"]> = [];
let parsed = 0;
let skipped = 0;
let duplicates = 0;
for (let index = 0; index < selectedFiles.length; index++) {
const { file, relativePath } = selectedFiles[index];
try {
const text = await readVaultImportFile(format, file, encoding);
const result = importVaultHostsFromText(format, text, {
fileName: file.name,
masterPassword,
});
const group = format === "securecrt"
? secureCrtGroupFromFile(file, relativePath)
: undefined;
const fileHosts = result.hosts.map((host) => (
group && !host.group ? { ...host, group } : host
));
parsed += result.stats.parsed;
skipped += result.stats.skipped;
duplicates += result.stats.duplicates;
issues.push(...result.issues.map((issue) => ({
...issue,
message: `${file.name}: ${issue.message}`,
})));
keyPassphrases.push(...(result.keyPassphrases ?? []));
keyPassphraseCandidates.push(...(result.keyPassphraseCandidates ?? []));
if (fileHosts.length === 0) {
if (result.stats.skipped === 0 && result.issues.length === 0) {
skipped++;
issues.push({
level: "warning",
message: `${file.name}: no importable SecureCRT session found.`,
});
}
} else {
hosts.push(...fileHosts);
}
} catch (error) {
if (format !== "securecrt" || selectedFiles.length <= 1) throw error;
skipped++;
issues.push({
level: "error",
message: `${file.name}: ${error instanceof Error ? error.message : "Unable to read file."}`,
});
} finally {
onProgress?.({
completedFiles: index + 1,
totalFiles: selectedFiles.length,
fileName: file.name,
});
}
}
const seen = new Set<string>();
const uniqueHosts = format === "securecrt"
? hosts
: hosts.filter((host) => {
const key = buildVaultHostMergeKey(host);
if (seen.has(key)) {
duplicates++;
return false;
}
seen.add(key);
return true;
});
const groups = Array.from(new Set(
uniqueHosts.map((host) => host.group).filter((group): group is string => Boolean(group)),
));
return {
hosts: uniqueHosts,
groups,
issues: mergeVaultImportIssues(issues),
stats: {
parsed,
imported: uniqueHosts.length,
skipped,
duplicates,
},
...(keyPassphrases.length > 0 ? { keyPassphrases } : {}),
...(keyPassphraseCandidates.length > 0 ? { keyPassphraseCandidates } : {}),
};
}

View File

@@ -0,0 +1,14 @@
import type { VaultImportFormat } from "../../domain/vaultImport";
import { readTextFile } from "../../lib/readTextFile";
export type VaultImportFileEncoding = "auto" | "utf-8" | "gb18030";
export const readVaultImportFile = (
format: VaultImportFormat,
file: File,
encoding: VaultImportFileEncoding = "auto",
): Promise<string> => {
if (format !== "mobaxterm") return readTextFile(file);
if (encoding !== "auto") return readTextFile(file, { encoding });
return readTextFile(file, { fallbackEncoding: "gb18030" });
};

View File

@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { VaultImportResult } from "../../domain/vaultImport.ts";
import {
importVaultHostsInWorker,
type VaultImportWorkerLike,
} from "./vaultImportWorkerClient.ts";
const parsedResult: VaultImportResult = {
hosts: [{
id: "host-1",
label: "Production",
hostname: "10.0.0.1",
port: 22,
username: "root",
protocol: "ssh",
tags: [],
os: "linux",
}],
groups: [],
issues: [],
stats: { parsed: 1, imported: 1, skipped: 0, duplicates: 0 },
};
class FakeVaultImportWorker implements VaultImportWorkerLike {
listeners = new Map<string, Set<(event: MessageEvent | ErrorEvent) => void>>();
postedMessage: unknown;
terminated = false;
addEventListener(type: "message" | "error", listener: (event: MessageEvent | ErrorEvent) => void) {
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: "message" | "error", listener: (event: MessageEvent | ErrorEvent) => void) {
this.listeners.get(type)?.delete(listener);
}
postMessage(message: unknown) {
this.postedMessage = message;
}
terminate() {
this.terminated = true;
}
emit(type: "message" | "error", data: unknown) {
const event = type === "message"
? ({ data } as MessageEvent)
: ({ message: String(data) } as ErrorEvent);
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
test("vault import stays pending while a worker parses and forwards real stage progress", async () => {
const worker = new FakeVaultImportWorker();
const progress: Array<{ stage: string; percent: number }> = [];
let settled = false;
const file = new File(["Label,Hostname\nProduction,10.0.0.1"], "hosts.csv");
Object.defineProperty(file, "webkitRelativePath", {
value: "Sessions/Production/hosts.csv",
});
const promise = importVaultHostsInWorker({
format: "csv",
files: [file],
createWorker: () => worker,
onProgress: (update) => progress.push(update),
}).finally(() => {
settled = true;
});
assert.deepEqual(worker.postedMessage, {
type: "import",
format: "csv",
files: [file],
relativePaths: ["Sessions/Production/hosts.csv"],
encoding: undefined,
});
assert.equal(settled, false);
worker.emit("message", {
type: "progress",
progress: { stage: "reading", percent: 10 },
});
worker.emit("message", {
type: "progress",
progress: { stage: "parsing", percent: 55 },
});
worker.emit("message", { type: "result", result: parsedResult });
assert.deepEqual(await promise, parsedResult);
assert.deepEqual(progress, [
{ stage: "reading", percent: 10 },
{ stage: "parsing", percent: 55 },
]);
assert.equal(worker.terminated, true);
});
test("vault import surfaces worker failures and always stops the worker", async () => {
const worker = new FakeVaultImportWorker();
const promise = importVaultHostsInWorker({
format: "csv",
files: [new File(["bad input"], "hosts.csv")],
createWorker: () => worker,
});
worker.emit("message", { type: "error", message: "Unable to parse CSV" });
await assert.rejects(promise, /Unable to parse CSV/);
assert.equal(worker.terminated, true);
});
test("vault import cancellation stops the worker and rejects before applying a result", async () => {
const worker = new FakeVaultImportWorker();
const controller = new AbortController();
const promise = importVaultHostsInWorker({
format: "csv",
files: [new File(["Label,Hostname\nA,a.example.com"], "hosts.csv")],
signal: controller.signal,
createWorker: () => worker,
});
controller.abort();
await assert.rejects(promise, (error: unknown) => (
error instanceof DOMException && error.name === "AbortError"
));
assert.equal(worker.terminated, true);
});

View File

@@ -0,0 +1,119 @@
import type {
VaultImportFormat,
VaultImportResult,
} from "../../domain/vaultImport";
import type { VaultImportFileEncoding } from "./vaultImportFile";
export type VaultImportWorkerStage = "reading" | "parsing";
export interface VaultImportWorkerProgress {
stage: VaultImportWorkerStage;
percent: number;
completedFiles?: number;
totalFiles?: number;
currentFileName?: string;
}
export type VaultImportWorkerRequest = {
type: "import";
format: VaultImportFormat;
files: File[];
relativePaths: string[];
encoding: VaultImportFileEncoding | undefined;
masterPassword?: string;
};
export type VaultImportWorkerResponse =
| { type: "progress"; progress: VaultImportWorkerProgress }
| { type: "result"; result: VaultImportResult }
| { type: "error"; message: string };
type VaultImportWorkerEvent = MessageEvent<VaultImportWorkerResponse> | ErrorEvent;
type VaultImportWorkerListener = (event: VaultImportWorkerEvent) => void;
export interface VaultImportWorkerLike {
addEventListener(type: "message" | "error", listener: VaultImportWorkerListener): void;
removeEventListener(type: "message" | "error", listener: VaultImportWorkerListener): void;
postMessage(message: VaultImportWorkerRequest): void;
terminate(): void;
}
interface ImportVaultHostsInWorkerOptions {
format: VaultImportFormat;
files: File[];
encoding?: VaultImportFileEncoding;
masterPassword?: string;
signal?: AbortSignal;
createWorker?: () => VaultImportWorkerLike;
onProgress?: (progress: VaultImportWorkerProgress) => void;
}
const createVaultImportWorker = (): VaultImportWorkerLike => (
new Worker(new URL("../workers/vaultImport.worker.ts", import.meta.url), {
type: "module",
})
);
export function importVaultHostsInWorker({
format,
files,
encoding,
masterPassword,
signal,
createWorker = createVaultImportWorker,
onProgress,
}: ImportVaultHostsInWorkerOptions): Promise<VaultImportResult> {
if (signal?.aborted) {
return Promise.reject(new DOMException("Vault import cancelled.", "AbortError"));
}
const worker = createWorker();
return new Promise((resolve, reject) => {
const cleanup = () => {
worker.removeEventListener("message", handleMessage);
worker.removeEventListener("error", handleWorkerError);
signal?.removeEventListener("abort", handleAbort);
worker.terminate();
};
const handleAbort = () => {
cleanup();
reject(new DOMException("Vault import cancelled.", "AbortError"));
};
const handleMessage: VaultImportWorkerListener = (event) => {
const message = (event as MessageEvent<VaultImportWorkerResponse>).data;
if (message.type === "progress") {
onProgress?.(message.progress);
return;
}
cleanup();
if (message.type === "result") {
resolve(message.result);
return;
}
reject(new Error(message.message));
};
const handleWorkerError: VaultImportWorkerListener = (event) => {
cleanup();
const message = event instanceof ErrorEvent && event.message
? event.message
: "Vault import worker failed.";
reject(new Error(message));
};
worker.addEventListener("message", handleMessage);
worker.addEventListener("error", handleWorkerError);
signal?.addEventListener("abort", handleAbort, { once: true });
worker.postMessage({
type: "import",
format,
files,
relativePaths: files.map((file) => file.webkitRelativePath),
encoding,
...(masterPassword ? { masterPassword } : {}),
});
});
}