/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/sourceControl/stagingStore.ts
236 строк
9 KB
Robert Kuzhin
fix: clear staging after manual revert
10 авг 2026, 07:35
10 авг 2026, 07:35
1de1382
Код
Авторство
О чём код?
import { lstat, mkdir, readdir, realpath, unlink } from "node:fs/promises"; import path from "node:path"; import type { CryptoService } from "../crypto/cryptoService.js"; import { assertUuid } from "../crypto/encoding.js"; import type { KeyHandle } from "../crypto/keyHandle.js"; import { MAX_ENCRYPTED_PAYLOAD_BYTES } from "../mirror/limits.js"; import type { ManifestEntry } from "../mirror/manifestTypes.js"; import { validateManifest } from "../mirror/manifestValidation.js"; import { SyncStateValidationError } from "../sync/errors.js"; import { deleteLocalStateFile, openLocalStateRoot, readLocalStateFile, writeLocalStateFile, } from "../sync/localStateFile.js"; import type { StagingState } from "./sourceControlTypes.js"; const METADATA_FILENAME = "staging-state.enc"; const OBJECTS_DIRECTORY = "staging-objects"; const MAX_METADATA_BYTES = 64 * 1024 * 1024; const COMMIT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; const OBJECT_FILENAME_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.enc$/u; const decoder = new TextDecoder("utf-8", { fatal: true }); export class StagingStore { private constructor( public readonly root: string, private readonly objectsRoot: 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<StagingStore> { assertUuid(options.vaultId, "vaultId"); const root = await openLocalStateRoot(options.stateRoot, options.encryptedRepositoryRoot); const candidate = path.join(root, OBJECTS_DIRECTORY); const existing = await lstat(candidate).catch((error: unknown) => { if (isNodeError(error) && error.code === "ENOENT") return undefined; throw error; }); if (existing === undefined) await mkdir(candidate, { mode: 0o700 }); else if (!existing.isDirectory() || existing.isSymbolicLink()) { throw new SyncStateValidationError("Staging objects path must be a regular directory"); } const objectsRoot = await realpath(candidate); if (path.dirname(objectsRoot) !== root) { throw new SyncStateValidationError("Staging objects path escapes local state"); } return new StagingStore(root, objectsRoot, options.crypto, options.vaultId); } public async load(key: KeyHandle): Promise<StagingState> { const envelope = await readLocalStateFile(this.root, METADATA_FILENAME, MAX_METADATA_BYTES); if (envelope === undefined) return emptyState(this.vaultId); 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("Staging state is not valid UTF-8 JSON", { cause: error }); } return validateStagingState(parsed, this.vaultId); } finally { plaintext.fill(0); envelope.fill(0); } } public async stage( baseCommit: string, entry: ManifestEntry, objectEnvelope: Uint8Array | undefined, key: KeyHandle, ): Promise<void> { const current = await this.load(key); if (current.baseCommit !== null && current.baseCommit !== baseCommit) { throw new SyncStateValidationError("Git HEAD changed after files were staged"); } if (entry.deleted === true) { if (objectEnvelope !== undefined) { throw new SyncStateValidationError("Deleted staged entry must not contain object bytes"); } } else { if (objectEnvelope === undefined) { throw new SyncStateValidationError("Active staged entry is missing encrypted object bytes"); } await writeLocalStateFile(this.objectsRoot, objectFilename(entry.fileId), objectEnvelope); } await this.save( { formatVersion: 1, vaultId: this.vaultId, baseCommit, entries: { ...current.entries, [entry.fileId]: entry }, }, key, ); if (entry.deleted === true) { await deleteLocalStateFile(this.objectsRoot, objectFilename(entry.fileId)); } } public async unstage(fileId: string, key: KeyHandle): Promise<void> { await this.unstageMany([fileId], key); } public async unstageMany(fileIds: readonly string[], key: KeyHandle): Promise<void> { const targets = new Set(fileIds); for (const fileId of targets) assertUuid(fileId, "fileId"); const current = await this.load(key); const entries = { ...current.entries }; const removed: string[] = []; for (const fileId of targets) { if (entries[fileId] === undefined) continue; delete entries[fileId]; removed.push(fileId); } if (removed.length === 0) return; if (Object.keys(entries).length === 0) { await deleteLocalStateFile(this.root, METADATA_FILENAME); } else { await this.save({ ...current, entries }, key); } for (const fileId of removed) { await deleteLocalStateFile(this.objectsRoot, objectFilename(fileId)); } } public async readObject(fileId: string): Promise<Uint8Array> { assertUuid(fileId, "fileId"); const envelope = await readLocalStateFile( this.objectsRoot, objectFilename(fileId), MAX_ENCRYPTED_PAYLOAD_BYTES, ); if (envelope === undefined) { throw new SyncStateValidationError("Staged encrypted object is missing"); } return envelope; } public async clear(): Promise<void> { await deleteLocalStateFile(this.root, METADATA_FILENAME); for (const entry of await readdir(this.objectsRoot, { withFileTypes: true })) { if (!entry.isFile() || entry.isSymbolicLink() || !OBJECT_FILENAME_PATTERN.test(entry.name)) { throw new SyncStateValidationError("Staging objects directory contains an unsafe entry"); } await unlink(path.join(this.objectsRoot, entry.name)); } } private async save(state: StagingState, key: KeyHandle): Promise<void> { const validated = validateStagingState(state, this.vaultId); const plaintext = new TextEncoder().encode(JSON.stringify(validated)); try { const envelope = await this.crypto.encryptManifest({ key, plaintext }); try { if (envelope.length > MAX_METADATA_BYTES) { throw new SyncStateValidationError("Staging metadata exceeds its size limit"); } await writeLocalStateFile(this.root, METADATA_FILENAME, envelope); } finally { envelope.fill(0); } } finally { plaintext.fill(0); } } } function validateStagingState(input: unknown, expectedVaultId: string): StagingState { try { if (!isRecord(input)) throw new TypeError("Staging state must be an object"); assertExactKeys(input, ["formatVersion", "vaultId", "baseCommit", "entries"]); if (input.formatVersion !== 1 || input.vaultId !== expectedVaultId) { throw new TypeError("Staging state version or vault identity does not match"); } if ( input.baseCommit !== null && (typeof input.baseCommit !== "string" || !COMMIT_PATTERN.test(input.baseCommit)) ) { throw new TypeError("Staging base commit is invalid"); } const manifest = validateManifest( { formatVersion: 1, vaultId: expectedVaultId, revision: 0, files: input.entries }, expectedVaultId, ); if (Object.keys(manifest.files).length === 0 && input.baseCommit !== null) { throw new TypeError("Empty staging state must not retain a base commit"); } if (Object.keys(manifest.files).length > 0 && input.baseCommit === null) { throw new TypeError("Non-empty staging state requires a base commit"); } return { formatVersion: 1, vaultId: expectedVaultId, baseCommit: input.baseCommit, entries: manifest.files, }; } catch (error) { if (error instanceof SyncStateValidationError) throw error; throw new SyncStateValidationError("Staging state validation failed", { cause: error }); } } function emptyState(vaultId: string): StagingState { return { formatVersion: 1, vaultId, baseCommit: null, entries: {} }; } function objectFilename(fileId: string): string { assertUuid(fileId, "fileId"); return `${fileId}.enc`; } 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((value, index) => value !== wanted[index])) { throw new TypeError("Staging state contains missing or unknown fields"); } } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; }