/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/sync/deviceStateStore.ts
150 строк
5 KB
Robert Kuzhin
feat: orchestrate recoverable two-device sync
09 авг 2026, 12:16
09 авг 2026, 12:16
7b0e3b9
Код
Авторство
О чём код?
import type { CryptoService } from "../crypto/cryptoService.js"; import { assertUuid } from "../crypto/encoding.js"; import type { KeyHandle } from "../crypto/keyHandle.js"; import type { Manifest } from "../mirror/manifestTypes.js"; import { validateManifest } from "../mirror/manifestValidation.js"; import { SyncStateValidationError } from "./errors.js"; import { openLocalStateRoot, readLocalStateFile, writeLocalStateFile, } from "./localStateFile.js"; const STATE_FILENAME = "device-state.enc"; const MAX_STATE_BYTES = 64 * 1024 * 1024; const COMMIT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; const decoder = new TextDecoder("utf-8", { fatal: true }); export interface DeviceState { readonly formatVersion: 1; readonly vaultId: string; readonly deviceId: string; readonly baseCommit: string | null; readonly baseManifest: Manifest; } export class DeviceStateStore { 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<DeviceStateStore> { assertUuid(options.vaultId, "vaultId"); const root = await openLocalStateRoot(options.stateRoot, options.encryptedRepositoryRoot); return new DeviceStateStore(root, options.crypto, options.vaultId); } public async load(key: KeyHandle): Promise<DeviceState | undefined> { const envelope = await readLocalStateFile(this.root, STATE_FILENAME, MAX_STATE_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("Device state is not valid UTF-8 JSON", { cause: error, }); } return validateDeviceState(parsed, this.vaultId); } finally { plaintext.fill(0); envelope.fill(0); } } public async save(state: DeviceState, key: KeyHandle): Promise<void> { const validated = validateDeviceState(state, this.vaultId); const plaintext = new TextEncoder().encode(JSON.stringify(validated)); try { if (plaintext.length > MAX_STATE_BYTES - 64) { throw new SyncStateValidationError("Device state exceeds its size limit"); } const envelope = await this.crypto.encryptManifest({ key, plaintext }); try { await writeLocalStateFile(this.root, STATE_FILENAME, envelope); } finally { envelope.fill(0); } } finally { plaintext.fill(0); } } public async initialize( baseManifest: Manifest, baseCommit: string | null, key: KeyHandle, deviceId = globalThis.crypto.randomUUID(), ): Promise<DeviceState> { const state = validateDeviceState( { formatVersion: 1, vaultId: this.vaultId, deviceId, baseCommit, baseManifest, }, this.vaultId, ); await this.save(state, key); return state; } } function validateDeviceState(input: unknown, expectedVaultId: string): DeviceState { try { if (!isRecord(input)) throw new TypeError("Device state must be an object"); assertExactKeys( input, ["formatVersion", "vaultId", "deviceId", "baseCommit", "baseManifest"], "device state", ); if (input.formatVersion !== 1 || input.vaultId !== expectedVaultId) { throw new TypeError("Device state version or vault identity does not match"); } if (typeof input.deviceId !== "string") throw new TypeError("Device ID must be a string"); assertUuid(input.deviceId, "deviceId"); if ( input.baseCommit !== null && (typeof input.baseCommit !== "string" || !COMMIT_PATTERN.test(input.baseCommit)) ) { throw new TypeError("Base commit must be a lowercase Git object ID or null"); } const baseManifest = validateManifest(input.baseManifest, expectedVaultId); return { formatVersion: 1, vaultId: expectedVaultId, deviceId: input.deviceId, baseCommit: input.baseCommit, baseManifest, }; } catch (error) { if (error instanceof SyncStateValidationError) throw error; throw new SyncStateValidationError("Device state validation failed", { cause: error }); } } 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[], name: 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(`${name} contains missing or unknown fields`); } }