/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/sync/vaultRecoveryJournal.ts
220 строк
8 KB
Robert Kuzhin
feat: add Obsidian session and desktop UX
09 авг 2026, 13:07
09 авг 2026, 13:07
14fe125
Код
Авторство
О чём код?
import type { CryptoService } from "../crypto/cryptoService.js"; import { assertUuid } from "../crypto/encoding.js"; import type { KeyHandle } from "../crypto/keyHandle.js"; import { assertVaultRelativePath } from "../mirror/pathSafety.js"; import type { VaultEventGate } from "../vault/vaultEventGate.js"; import type { VaultWriterPort } from "../vault/vaultWriter.js"; import { SyncStateValidationError, VaultRecoveryError } from "./errors.js"; import { deleteLocalStateFile, openLocalStateRoot, readLocalStateFile, writeLocalStateFile, } from "./localStateFile.js"; const JOURNAL_FILENAME = "pending-vault-apply.enc"; const MAX_JOURNAL_BYTES = 256 * 1024 * 1024; const MAX_ENTRIES = 100_000; const decoder = new TextDecoder("utf-8", { fatal: true }); export interface VaultPathBackup { readonly path: string; readonly plaintext: Uint8Array | undefined; } interface RecoveryPayload { readonly formatVersion: 1; readonly vaultId: string; readonly transactionId: string; readonly entries: readonly VaultPathBackup[]; } export class VaultRecoveryJournal { private constructor( public readonly root: string, private readonly crypto: CryptoService, private readonly vaultId: string, ) {} public static async open(options: { readonly stateRoot: string; readonly encryptedRepositoryRoot: string; readonly crypto: CryptoService; readonly vaultId: string; }): Promise<VaultRecoveryJournal> { assertUuid(options.vaultId, "vaultId"); const root = await openLocalStateRoot(options.stateRoot, options.encryptedRepositoryRoot); return new VaultRecoveryJournal(root, options.crypto, options.vaultId); } public async prepare(entries: readonly VaultPathBackup[], key: KeyHandle): Promise<string> { const existing = await readLocalStateFile(this.root, JOURNAL_FILENAME, MAX_JOURNAL_BYTES); if (existing !== undefined) { existing.fill(0); throw new VaultRecoveryError("A pending vault recovery journal must be recovered first"); } const transactionId = globalThis.crypto.randomUUID(); const plaintext = serializePayload(this.vaultId, transactionId, entries); try { if (plaintext.length > MAX_JOURNAL_BYTES - 64) { throw new VaultRecoveryError("Vault recovery journal exceeds its size limit"); } const envelope = await this.crypto.encryptManifest({ key, plaintext }); try { if (envelope.length > MAX_JOURNAL_BYTES) { throw new VaultRecoveryError("Vault recovery journal exceeds its size limit"); } await writeLocalStateFile(this.root, JOURNAL_FILENAME, envelope); } finally { envelope.fill(0); } } finally { plaintext.fill(0); } return transactionId; } public async recover( writer: VaultWriterPort, watcher: VaultEventGate, key: KeyHandle, ): Promise<boolean> { const payload = await this.load(key); if (payload === undefined) return false; try { await watcher.suspendPaths(payload.entries.map((entry) => entry.path), async () => { for (const entry of payload.entries) { if (entry.plaintext === undefined) { await writer.delete(entry.path); } else { await writer.write(entry.path, entry.plaintext); } } }); await this.clear(); return true; } catch (error) { throw new VaultRecoveryError("Unable to restore the pending vault apply journal", { cause: error, }); } finally { zeroEntries(payload.entries); } } public clear(): Promise<void> { return deleteLocalStateFile(this.root, JOURNAL_FILENAME); } private async load(key: KeyHandle): Promise<RecoveryPayload | undefined> { const envelope = await readLocalStateFile(this.root, JOURNAL_FILENAME, MAX_JOURNAL_BYTES); if (envelope === undefined) return undefined; const plaintext = await this.crypto.decryptManifest({ key, envelope }); try { let parsed: unknown; try { parsed = JSON.parse(decoder.decode(plaintext)) as unknown; } catch (error) { throw new SyncStateValidationError("Vault recovery journal is not valid UTF-8 JSON", { cause: error, }); } return validatePayload(parsed, this.vaultId); } finally { plaintext.fill(0); envelope.fill(0); } } } function serializePayload( vaultId: string, transactionId: string, entries: readonly VaultPathBackup[], ): Uint8Array { if (entries.length === 0 || entries.length > MAX_ENTRIES) { throw new VaultRecoveryError("Vault recovery journal entry count is invalid"); } const paths = new Set<string>(); const serializedEntries = entries.map((entry) => { const safePath = assertVaultRelativePath(entry.path); if (paths.has(safePath)) throw new VaultRecoveryError("Vault recovery paths must be unique"); paths.add(safePath); return { path: safePath, plaintext: entry.plaintext === undefined ? null : Buffer.from(entry.plaintext).toString("base64"), }; }); return new TextEncoder().encode( JSON.stringify({ formatVersion: 1, vaultId, transactionId, entries: serializedEntries, }), ); } function validatePayload(input: unknown, expectedVaultId: string): RecoveryPayload { try { if (!isRecord(input)) throw new TypeError("Vault recovery journal must be an object"); assertExactKeys(input, ["formatVersion", "vaultId", "transactionId", "entries"]); if (input.formatVersion !== 1 || input.vaultId !== expectedVaultId) { throw new TypeError("Vault recovery journal identity does not match"); } if (typeof input.transactionId !== "string") { throw new TypeError("Vault recovery transaction ID must be a string"); } assertUuid(input.transactionId, "transactionId"); if (!Array.isArray(input.entries) || input.entries.length === 0 || input.entries.length > MAX_ENTRIES) { throw new TypeError("Vault recovery journal entry count is invalid"); } const paths = new Set<string>(); const entries = input.entries.map((value) => { if (!isRecord(value)) throw new TypeError("Vault recovery entry must be an object"); assertExactKeys(value, ["path", "plaintext"]); if (typeof value.path !== "string") throw new TypeError("Recovery path must be a string"); const safePath = assertVaultRelativePath(value.path); if (paths.has(safePath)) throw new TypeError("Vault recovery paths must be unique"); paths.add(safePath); if (value.plaintext === null) return { path: safePath, plaintext: undefined }; if (typeof value.plaintext !== "string") { throw new TypeError("Vault recovery plaintext must be base64 or null"); } const bytes = Buffer.from(value.plaintext, "base64"); if (bytes.toString("base64") !== value.plaintext) { throw new TypeError("Vault recovery plaintext is not canonical base64"); } return { path: safePath, plaintext: new Uint8Array(bytes) }; }); return { formatVersion: 1, vaultId: expectedVaultId, transactionId: input.transactionId, entries, }; } catch (error) { if (error instanceof SyncStateValidationError) throw error; throw new SyncStateValidationError("Vault recovery journal validation failed", { cause: error }); } } function zeroEntries(entries: readonly VaultPathBackup[]): void { for (const entry of entries) entry.plaintext?.fill(0); } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } function assertExactKeys( record: Readonly<Record<string, unknown>>, expected: readonly string[], ): void { const actual = Object.keys(record).sort(); const wanted = [...expected].sort(); if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { throw new TypeError("Vault recovery journal contains missing or unknown fields"); } }