/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/sync/syncCoordinator.ts
234 строки
8 KB
Robert Kuzhin
feat: add manual encrypted source control
09 авг 2026, 20:57
09 авг 2026, 20:57
3c8fa38
Код
Авторство
О чём код?
import type { CryptoService } from "../crypto/cryptoService.js"; import type { KeyHandle } from "../crypto/keyHandle.js"; import { GitRevisionMirror } from "../git/gitRevisionMirror.js"; import type { FetchResult, GitAdapter } from "../git/gitTypes.js"; import type { EncryptedMirror } from "../mirror/encryptedMirror.js"; import { ManifestStore } from "../mirror/manifestStore.js"; import type { Manifest } from "../mirror/manifestTypes.js"; import { serializeManifest } from "../mirror/manifestValidation.js"; import type { VaultEventGate } from "../vault/vaultEventGate.js"; import type { VaultReader } from "../vault/vaultReader.js"; import type { VaultWriterPort } from "../vault/vaultWriter.js"; import { planReconciliation } from "./changePlanner.js"; import { resolveReconciliation } from "./conflictResolutionPlanner.js"; import type { DeviceStateStore } from "./deviceStateStore.js"; import { SyncStateValidationError } from "./errors.js"; import { RemoteApplyCoordinator } from "./remoteApplyCoordinator.js"; import type { ConflictStrategy, SyncConflict } from "./syncTypes.js"; import type { VaultRecoveryJournal } from "./vaultRecoveryJournal.js"; const COMMIT_MESSAGE = "Encrypted vault sync"; export interface SyncCoordinatorOptions { readonly git: GitAdapter; readonly mirror: EncryptedMirror; readonly crypto: CryptoService; readonly key: KeyHandle; readonly vaultId: string; readonly deviceState: DeviceStateStore; readonly vaultReader: VaultReader; readonly vaultWriter: VaultWriterPort; readonly watcher: VaultEventGate; readonly recoveryJournal: VaultRecoveryJournal; } export type SyncRunResult = | { readonly status: "conflicts"; readonly fetch: FetchResult; readonly conflicts: readonly SyncConflict[]; } | { readonly status: "synced"; readonly fetch: FetchResult; readonly commit: string | null; readonly baseCommit: string | null; }; export type ConflictResolver = ( conflicts: readonly SyncConflict[], ) => Promise<readonly ConflictStrategy[]>; export interface SyncRunOptions { readonly push?: boolean; } export class SyncCoordinator { private readonly manifestStore: ManifestStore; private chain: Promise<void> = Promise.resolve(); public constructor(private readonly options: SyncCoordinatorOptions) { this.manifestStore = new ManifestStore( options.mirror, options.crypto, options.vaultId, ); } public syncNow( resolveConflicts?: ConflictResolver, runOptions: SyncRunOptions = {}, ): Promise<SyncRunResult> { const result = this.chain.then( () => this.performSync(resolveConflicts, runOptions), () => this.performSync(resolveConflicts, runOptions), ); this.chain = result.then( () => undefined, () => undefined, ); return result; } private async performSync( resolveConflicts: ConflictResolver | undefined, runOptions: SyncRunOptions, ): Promise<SyncRunResult> { await this.options.recoveryJournal.recover( this.options.vaultWriter, this.options.watcher, this.options.key, ); const deviceState = await this.options.deviceState.load(this.options.key); if (deviceState === undefined) { throw new SyncStateValidationError("Device state must be initialized before synchronization"); } const localManifest = await this.manifestStore.load(this.options.key); const fetch = await this.options.git.fetch(); const remoteMirror = fetch.remoteCommit === null ? this.options.mirror : new GitRevisionMirror(this.options.git, fetch.remoteCommit); const remoteManifest = fetch.remoteCommit === null ? deviceState.baseManifest : await new ManifestStore( remoteMirror, this.options.crypto, this.options.vaultId, ).load(this.options.key); const unresolved = planReconciliation( deviceState.baseManifest, localManifest, remoteManifest, ); const plan = unresolved.mergedManifest === undefined && resolveConflicts !== undefined ? resolveReconciliation( deviceState.baseManifest, localManifest, remoteManifest, await resolveConflicts(unresolved.conflicts), ) : unresolved; if (plan.mergedManifest === undefined) { return { status: "conflicts", fetch, conflicts: plan.conflicts }; } const apply = new RemoteApplyCoordinator( remoteMirror, this.options.crypto, this.options.key, this.options.vaultWriter, this.options.watcher, this.options.recoveryJournal, fetch.remoteCommit === null ? undefined : this.options.mirror, ); const applied = await apply.apply(plan.actions); await this.prepareLocalObjects(applied.deferredLocalActions, plan.mergedManifest); if (!manifestsEqual(localManifest, plan.mergedManifest)) { await this.manifestStore.save(plan.mergedManifest, this.options.key); } for (const action of applied.deferredLocalActions) { if (action.type === "delete-from-mirror") { await this.options.mirror.deleteObject(action.fileId); } } const commit = await this.options.git.commitReconciled(COMMIT_MESSAGE, fetch.remoteCommit); const push = runOptions.push !== false; if (push && fetch.status !== "no-remote") await this.options.git.push(); const baseCommit = push ? commit ?? fetch.localCommit ?? fetch.remoteCommit : fetch.remoteCommit ?? fetch.localCommit; await this.options.deviceState.save( { ...deviceState, baseCommit, baseManifest: push || fetch.remoteCommit === null ? plan.mergedManifest : remoteManifest, }, this.options.key, ); return { status: "synced", fetch, commit, baseCommit }; } private async prepareLocalObjects( actions: readonly ( | { readonly type: "encrypt-from-vault"; readonly fileId: string; readonly path: string } | { readonly type: "delete-from-mirror"; readonly fileId: string } )[], manifest: Manifest, ): Promise<void> { for (const action of actions) { if (action.type !== "encrypt-from-vault") continue; const envelope = await this.options.mirror.readObject(action.fileId).catch( (error: unknown) => { if (isNodeError(error) && error.code === "ENOENT") return undefined; throw error; }, ); if (envelope !== undefined) { envelope.fill(0); continue; } const entry = manifest.files[action.fileId]; if (entry === undefined || entry.deleted === true || entry.path !== action.path) { throw new SyncStateValidationError("Resolved local object has no matching manifest entry"); } const snapshot = await this.options.vaultReader.read(action.path); try { const plaintextHash = await sha256(snapshot.bytes); if (plaintextHash !== entry.plaintextHash || snapshot.size !== entry.size) { throw new SyncStateValidationError("Resolved local object does not match its manifest"); } const encrypted = await this.options.crypto.encryptObject({ key: this.options.key, fileId: action.fileId, plaintext: snapshot.bytes, }); try { await this.options.mirror.writeObjectAtomic(action.fileId, encrypted); } finally { encrypted.fill(0); } } finally { snapshot.bytes.fill(0); } } } } function manifestsEqual(left: Manifest, right: Manifest): boolean { const leftBytes = serializeManifest(left); const rightBytes = serializeManifest(right); try { return Buffer.from(leftBytes).equals(Buffer.from(rightBytes)); } finally { leftBytes.fill(0); rightBytes.fill(0); } } async function sha256(data: Uint8Array): Promise<`sha256:${string}`> { const digest = new Uint8Array( await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(data)), ); return `sha256:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; }