/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/session/filesystemSessionBackend.ts
517 строк
17 KB
Robert Kuzhin
feat: add manual encrypted source control
09 авг 2026, 20:57
09 авг 2026, 20:57
3c8fa38
Код
Авторство
О чём код?
import { lstat } from "node:fs/promises"; import path from "node:path"; import { CryptoService, KeyManager, type KeyHandle } from "../crypto/index.js"; import { SystemGitAdapter } from "../git/index.js"; import { EncryptedMirror, ManifestStore, type Manifest } from "../mirror/index.js"; import type { EncryptedGitSettings } from "../settings/index.js"; import { SourceControlService, StagingStore, type DiffTarget, type PullChangesResult, type SourceControlStatus, type SourceDiff, } from "../sourceControl/index.js"; import { DeviceStateStore, LocalSyncConflictError, LocalSyncCoordinator, RemoteApplyCoordinator, resolveProspectiveLocalStatePath, SyncCoordinator, VaultRecoveryJournal, type SyncRunResult, type ConflictResolver, } from "../sync/index.js"; import { VaultEventGate, VaultReader, VaultWriter, type VaultChange } from "../vault/index.js"; import { SessionError } from "./errors.js"; import type { SessionBackend, SessionRuntime, TrackedFileInfo } from "./sessionState.js"; export interface FilesystemSessionBackendOptions { readonly vaultRoot: string; readonly getSettings: () => EncryptedGitSettings; readonly createWatcher: (gate: VaultEventGate) => VaultWatcherPort; readonly onBackgroundError?: (error: unknown) => void; readonly onConflicts?: (result: Extract<SyncRunResult, { readonly status: "conflicts" }>) => void; readonly resolveConflicts?: ConflictResolver; readonly onRepositoryChange?: () => void; } export class FilesystemSessionBackend implements SessionBackend { private readonly keyManager = new KeyManager(); private readonly crypto = new CryptoService(); public constructor(private readonly options: FilesystemSessionBackendOptions) {} public async create(password: Uint8Array): Promise<SessionRuntime> { const settings = this.options.getSettings(); await this.assertSafeFilesystemLayout(settings); const mirror = await EncryptedMirror.open(settings.mirrorPath); await assertConfigAbsent(mirror.root); const created = await this.keyManager.create(password); try { await mirror.writeConfigAtomic(created.config); const manifest: Manifest = { formatVersion: 1, vaultId: created.config.vaultId, revision: 0, files: {}, }; await new ManifestStore(mirror, this.crypto, created.config.vaultId).save( manifest, created.key, ); const components = await this.openComponents( mirror, created.key, created.config.vaultId, settings, ); await components.deviceState.initialize(manifest, null, created.key); await components.git.commit("Encrypted vault bootstrap"); return this.createRuntime(components, created.key, settings); } catch (error) { this.keyManager.destroy(created.key); throw error; } } public async unlock(password: Uint8Array): Promise<SessionRuntime> { const settings = this.options.getSettings(); await this.assertSafeFilesystemLayout(settings); const mirror = await EncryptedMirror.open(settings.mirrorPath); const config = await mirror.readConfig(); const key = await this.keyManager.unlock(password, config); try { const vaultId = readVaultId(config); const components = await this.openComponents(mirror, key, vaultId, settings); const manifest = await components.manifestStore.load(key); if ((await components.deviceState.load(key)) === undefined) { const fetched = await components.git.fetch(); await this.restoreInitialVault(manifest, components, key); await components.deviceState.initialize( manifest, fetched.localCommit ?? fetched.remoteCommit, key, ); } return this.createRuntime(components, key, settings); } catch (error) { this.keyManager.destroy(key); throw error; } } public destroyKey(key: KeyHandle): void { this.keyManager.destroy(key); } private async assertSafeFilesystemLayout(settings: EncryptedGitSettings): Promise<void> { const vault = (await VaultReader.open(this.options.vaultRoot)).root; const [mirror, state] = await Promise.all([ resolveProspectiveLocalStatePath(settings.mirrorPath), resolveProspectiveLocalStatePath(settings.statePath), ]); if (rootsOverlap(vault, mirror) || rootsOverlap(state, mirror)) { throw new SessionError( "Plaintext vault, local state, and encrypted repository must not overlap", ); } assertStatePathDoesNotEnterSyncScope(vault, state); } private async openComponents( mirror: EncryptedMirror, key: KeyHandle, vaultId: string, settings: EncryptedGitSettings, ): Promise<RuntimeComponents> { const vaultReader = await VaultReader.open(this.options.vaultRoot); const vaultWriter = await VaultWriter.open(this.options.vaultRoot); const gate = new VaultEventGate(); const git = await SystemGitAdapter.open(mirror.root, { ...(settings.remoteUrl === "" ? {} : { remoteUrl: settings.remoteUrl }), branch: settings.branch, }); await git.init(); assertStatePathDoesNotEnterSyncScope( vaultReader.root, await resolveProspectiveLocalStatePath(settings.statePath), ); const deviceState = await DeviceStateStore.open({ stateRoot: settings.statePath, encryptedRepositoryRoot: mirror.root, crypto: this.crypto, vaultId, }); assertStatePathDoesNotEnterSyncScope(vaultReader.root, deviceState.root); const recoveryJournal = await VaultRecoveryJournal.open({ stateRoot: settings.statePath, encryptedRepositoryRoot: mirror.root, crypto: this.crypto, vaultId, }); const staging = await StagingStore.open({ stateRoot: settings.statePath, encryptedRepositoryRoot: mirror.root, crypto: this.crypto, vaultId, }); const manifestStore = new ManifestStore(mirror, this.crypto, vaultId); const localSync = await LocalSyncCoordinator.open({ vault: vaultReader, mirror, crypto: this.crypto, key, vaultId, debounceMs: 1_500, ...(this.options.onBackgroundError === undefined ? {} : { onError: this.options.onBackgroundError }), }); return { vaultId, mirror, git, deviceState, recoveryJournal, staging, manifestStore, localSync, vaultWriter, vaultReader, gate, watcher: this.options.createWatcher(gate), }; } private createRuntime( components: RuntimeComponents, key: KeyHandle, settings: EncryptedGitSettings, ): SessionRuntime { return new FilesystemSessionRuntime({ ...components, key, crypto: this.crypto, settings, ...(this.options.onBackgroundError === undefined ? {} : { onBackgroundError: this.options.onBackgroundError }), ...(this.options.onConflicts === undefined ? {} : { onConflicts: this.options.onConflicts }), ...(this.options.resolveConflicts === undefined ? {} : { resolveConflicts: this.options.resolveConflicts }), ...(this.options.onRepositoryChange === undefined ? {} : { onRepositoryChange: this.options.onRepositoryChange }), }); } private async restoreInitialVault( manifest: Manifest, components: RuntimeComponents, key: KeyHandle, ): Promise<void> { const actions = Object.values(manifest.files) .filter((entry) => entry.deleted !== true) .map((entry) => ({ type: "decrypt-to-vault" as const, fileId: entry.fileId, path: entry.path, })); await new RemoteApplyCoordinator( components.mirror, this.crypto, key, components.vaultWriter, components.gate, components.recoveryJournal, ).apply(actions); } } interface RuntimeComponents { readonly vaultId: string; readonly mirror: EncryptedMirror; readonly git: SystemGitAdapter; readonly deviceState: DeviceStateStore; readonly recoveryJournal: VaultRecoveryJournal; readonly staging: StagingStore; readonly manifestStore: ManifestStore; readonly localSync: LocalSyncCoordinator; readonly vaultWriter: VaultWriter; readonly vaultReader: VaultReader; readonly gate: VaultEventGate; readonly watcher: VaultWatcherPort; } export interface VaultWatcherPort { start(): void; stop(): void; } interface RuntimeOptions extends RuntimeComponents { readonly key: KeyHandle; readonly crypto: CryptoService; readonly settings: EncryptedGitSettings; readonly onBackgroundError?: (error: unknown) => void; readonly onConflicts?: (result: Extract<SyncRunResult, { readonly status: "conflicts" }>) => void; readonly resolveConflicts?: ConflictResolver; readonly onRepositoryChange?: () => void; } class FilesystemSessionRuntime implements SessionRuntime { private fetchTimer: ReturnType<typeof setTimeout> | undefined; private started = false; private readonly sync: SyncCoordinator; private readonly sourceControl: SourceControlService; private sourceChain: Promise<void> = Promise.resolve(); public readonly key: KeyHandle; public constructor(private readonly options: RuntimeOptions) { this.key = options.key; this.sync = new SyncCoordinator({ git: options.git, mirror: options.mirror, crypto: options.crypto, key: options.key, vaultId: options.vaultId, deviceState: options.deviceState, vaultReader: options.vaultReader, vaultWriter: options.vaultWriter, watcher: options.gate, recoveryJournal: options.recoveryJournal, }); this.sourceControl = new SourceControlService({ git: options.git, mirror: options.mirror, manifestStore: options.manifestStore, crypto: options.crypto, key: options.key, vaultId: options.vaultId, localSync: options.localSync, staging: options.staging, sync: this.sync, deviceState: options.deviceState, ...(options.resolveConflicts === undefined ? {} : { resolveConflicts: options.resolveConflicts }), }); } public start(): Promise<void> { if (this.started) return Promise.resolve(); this.options.gate.start((change) => this.handleChange(change)); this.options.watcher.start(); this.started = true; this.scheduleBackgroundFetch(); return Promise.resolve(); } public getSourceControlStatus(): Promise<SourceControlStatus> { return this.runSourceOperation(() => this.sourceControl.getStatus()); } public stage(fileId: string): Promise<void> { return this.runSourceOperation(async () => { await this.sourceControl.stage(fileId); this.options.onRepositoryChange?.(); }); } public stageAll(): Promise<void> { return this.runSourceOperation(async () => { await this.sourceControl.stageAll(); this.options.onRepositoryChange?.(); }); } public unstage(fileId: string): Promise<void> { return this.runSourceOperation(async () => { await this.sourceControl.unstage(fileId); this.options.onRepositoryChange?.(); }); } public unstageAll(): Promise<void> { return this.runSourceOperation(async () => { await this.sourceControl.unstageAll(); this.options.onRepositoryChange?.(); }); } public commitSelected(message: string): Promise<string> { return this.runSourceOperation(async () => { const commit = await this.sourceControl.commitSelected(message); this.options.onRepositoryChange?.(); return commit; }); } public fetchRemote(): Promise<SourceControlStatus> { return this.runSourceOperation(async () => { const status = await this.sourceControl.fetch(); this.options.onRepositoryChange?.(); return status; }); } public pullRemote(): Promise<PullChangesResult> { return this.runSourceOperation(async () => { const result = await this.sourceControl.pull(); this.options.onRepositoryChange?.(); return result; }); } public pushRemote(): Promise<void> { return this.runSourceOperation(async () => { await this.sourceControl.push(); this.options.onRepositoryChange?.(); }); } public getDiff(fileId: string, target: DiffTarget): Promise<SourceDiff> { return this.runSourceOperation(() => this.sourceControl.getDiff(fileId, target)); } public listTrackedFiles(): Promise<readonly TrackedFileInfo[]> { return this.runSourceOperation(async () => { const manifest = await this.options.manifestStore.load(this.key); return Object.values(manifest.files) .map((entry) => ({ path: entry.path, size: entry.size, modifiedAt: entry.modifiedAt, objectVersion: entry.objectVersion, deleted: entry.deleted === true, })) .sort((left, right) => { if (left.deleted !== right.deleted) return left.deleted ? 1 : -1; return left.path.localeCompare(right.path, "en-US"); }); }); } public flush(): Promise<void> { return this.runSourceOperation(async () => { await this.options.localSync.syncNow(); }); } public changePassword(newPassword: Uint8Array): Promise<void> { return this.runSourceOperation(async () => { await this.options.localSync.syncNow(); const sourceStatus = await this.sourceControl.getStatus(); if (sourceStatus.staged.length > 0 || sourceStatus.changes.length > 0) { throw new LocalSyncConflictError("Commit local changes before changing the vault password"); } const config = await new KeyManager().rewrap(this.key, newPassword); await this.options.mirror.writeConfigAtomic(config); await this.options.git.commitReconciled("Encrypted vault key rotation", null); }); } public async stop(): Promise<void> { this.clearFetchTimer(); this.options.watcher.stop(); this.options.gate.stop(); await this.sourceChain; await this.options.localSync.stop(); this.started = false; } private handleChange(change: VaultChange): void { try { this.options.localSync.enqueue(change); this.options.onRepositoryChange?.(); } catch (error) { this.options.onBackgroundError?.(error); } } private scheduleBackgroundFetch(): void { if (!this.options.settings.backgroundFetch) return; this.clearFetchTimer(); this.fetchTimer = setTimeout(() => { this.fetchTimer = undefined; void this.runSourceOperation(() => this.sourceControl.fetch()) .then(() => this.options.onRepositoryChange?.()) .catch(() => undefined) .finally(() => this.scheduleBackgroundFetch()); }, this.options.settings.backgroundFetchIntervalMinutes * 60_000); } private clearFetchTimer(): void { if (this.fetchTimer !== undefined) clearTimeout(this.fetchTimer); this.fetchTimer = undefined; } private runSourceOperation<T>(operation: () => Promise<T>): Promise<T> { const result = this.sourceChain.then(operation, operation); this.sourceChain = result.then( () => undefined, () => undefined, ); return result; } } async function assertConfigAbsent(mirrorRoot: string): Promise<void> { const config = await lstat(path.join(mirrorRoot, "secure-vault.json")).catch( (error: unknown) => { if (isNodeError(error) && error.code === "ENOENT") return undefined; throw error; }, ); if (config !== undefined) { throw new SessionError("Encrypted vault configuration already exists at the mirror path"); } } function readVaultId(config: unknown): string { if ( typeof config !== "object" || config === null || !("vaultId" in config) || typeof config.vaultId !== "string" ) { throw new SessionError("Encrypted vault configuration has no valid vault identity"); } return config.vaultId; } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } function assertStatePathDoesNotEnterSyncScope(vaultRoot: string, stateRoot: string): void { const relative = path.relative(vaultRoot, stateRoot); const insideVault = relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); if ( insideVault && relative !== ".obsidian" && !relative.startsWith(`.obsidian${path.sep}`) ) { throw new SessionError("Local state inside the vault must be stored below .obsidian"); } } function rootsOverlap(first: string, second: string): boolean { return isInside(first, second) || isInside(second, first); } function isInside(parent: string, candidate: string): boolean { const relative = path.relative(parent, candidate); return ( relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) ); }