/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
tests/git/systemGitAdapter.integration.test.ts
331 строка
13 KB
Robert Kuzhin
feat: harden encrypted sync transactions
09 авг 2026, 13:46
09 авг 2026, 13:46
cf0c0dc
Код
Авторство
О чём код?
import { execFile } from "node:child_process"; import { mkdtemp, mkdir, readFile, readdir, rm, truncate, unlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { CryptoService, KeyManager, type KeyHandle } from "../../src/crypto/index.js"; import { GitConfigurationError, GitSafetyError, SystemGitAdapter, } from "../../src/git/index.js"; import { EncryptedMirror, ManifestStore } from "../../src/mirror/index.js"; import { LocalSyncCoordinator } from "../../src/sync/index.js"; import { VaultReader } from "../../src/vault/index.js"; const execFileAsync = promisify(execFile); const encoder = new TextEncoder(); const manager = new KeyManager(); const cryptoService = new CryptoService(); const sentinel = "PLAINTEXT_SENTINEL_git_objects_f9c27e"; const secretPath = "Work/Sentinel.md"; let root: string; let vaultRoot: string; let mirror: EncryptedMirror; let remoteRoot: string; let key: KeyHandle; let vaultId: string; let coordinator: LocalSyncCoordinator; let adapter: SystemGitAdapter; let firstCommit: string; describe.sequential("SystemGitAdapter encrypted repository integration", () => { beforeAll(async () => { root = await mkdtemp(path.join(tmpdir(), "obsidian-encrypted-git-git-integration-")); vaultRoot = path.join(root, "vault"); const mirrorRoot = path.join(root, "encrypted-repository"); remoteRoot = path.join(root, "remote.git"); await Promise.all([ mkdir(path.join(vaultRoot, "Work"), { recursive: true }), mkdir(mirrorRoot), mkdir(remoteRoot), ]); await git(root, ["init", "--bare", remoteRoot]); mirror = await EncryptedMirror.open(mirrorRoot); const created = await manager.create(encoder.encode("git integration password")); key = created.key; vaultId = created.config.vaultId; await mirror.writeConfigAtomic(created.config); coordinator = await LocalSyncCoordinator.open({ vault: await VaultReader.open(vaultRoot), mirror, crypto: cryptoService, key, vaultId, debounceMs: 60_000, }); await writeFile(path.join(vaultRoot, "Work", "Sentinel.md"), `# Private\n\n${sentinel}`); coordinator.enqueue({ type: "create", path: secretPath }); await coordinator.syncNow(); adapter = await SystemGitAdapter.open(mirror.root, { remoteUrl: remoteRoot, branch: "main", retryDelaysMs: [], }); await adapter.init(); await git(mirror.root, ["config", "user.name", "Encrypted Vault Test"]); await git(mirror.root, ["config", "user.email", "encrypted-vault@example.invalid"]); }, 30_000); afterAll(async () => { await coordinator.stop(); manager.destroy(key); await rm(root, { recursive: true, force: true }); }); it("reports only encrypted mirror files before the initial commit", async () => { const status = await adapter.status(); expect(status.clean).toBe(false); const paths = status.entries.map((entry) => entry.path).sort(); expect(paths).toHaveLength(3); expect(paths[0]).toBe("manifest.enc"); expect(paths[1]).toMatch(/^objects\/[0-9a-f-]{36}\.enc$/u); expect(paths[2]).toBe("secure-vault.json"); }); it("commits, returns null for no-op, and pushes the encrypted tree", async () => { firstCommit = (await adapter.commit("Encrypted vault revision 1"))!; expect(firstCommit).toMatch(/^[0-9a-f]{40,64}$/u); await expect(adapter.commit("Encrypted vault no-op")).resolves.toBeNull(); await expect(adapter.push()).resolves.toBeUndefined(); await expect(adapter.status()).resolves.toEqual({ entries: [], clean: true }); }); it("changes only one opaque object and manifest for a note modify", async () => { await writeFile( path.join(vaultRoot, "Work", "Sentinel.md"), `# Private updated\n\n${sentinel}`, ); coordinator.enqueue({ type: "modify", path: secretPath }); await coordinator.syncNow(); const secondCommit = await adapter.commit("Encrypted vault revision 2"); expect(secondCommit).not.toBeNull(); const changedPaths = (await git(mirror.root, ["diff", "--name-only", `${firstCommit}..${secondCommit!}`])) .stdout.trim() .split("\n") .filter(Boolean) .sort(); expect(changedPaths).toHaveLength(2); expect(changedPaths[0]).toBe("manifest.enc"); expect(changedPaths[1]).toMatch(/^objects\/[0-9a-f-]{36}\.enc$/u); await adapter.push(); }); it("contains no plaintext marker or real path in index, trees, blobs, reflogs, packs, or clone", async () => { await git(mirror.root, ["gc", "--prune=now"]); await assertNoMarkersInFiles(mirror.root, [sentinel, secretPath]); const treePaths = (await git(mirror.root, ["ls-tree", "-r", "--name-only", "HEAD"])) .stdout.trim() .split("\n") .filter(Boolean); expect(treePaths.every(isAllowedTreePath)).toBe(true); expect(treePaths.join("\n")).not.toContain(secretPath); const objectLines = ( await git(mirror.root, [ "cat-file", "--batch-all-objects", "--batch-check=%(objectname) %(objecttype)", ]) ).stdout.trim().split("\n").filter(Boolean); for (const line of objectLines) { const [objectId, objectType] = line.split(" "); if (objectType !== "blob") continue; const blob = await gitBuffer(mirror.root, ["cat-file", "blob", objectId!]); for (const marker of [sentinel, secretPath]) { expect(blob.includes(Buffer.from(marker))).toBe(false); } } const reflogs = ( await git(mirror.root, ["reflog", "show", "--all", "--format=%H%x00%gs"]) ).stdout; expect(reflogs).not.toContain(sentinel); expect(reflogs).not.toContain(secretPath); const cloneRoot = path.join(root, "exported-clone"); await git(root, ["clone", "--branch", "main", remoteRoot, cloneRoot]); await assertNoMarkersInFiles(cloneRoot, [sentinel, secretPath]); const clonedMirror = await EncryptedMirror.open(cloneRoot); const config = await clonedMirror.readConfig(); const clonedKey = await manager.unlock(encoder.encode("git integration password"), config); try { const manifest = await new ManifestStore( clonedMirror, cryptoService, vaultId, ).load(clonedKey); const entry = Object.values(manifest.files).find( (candidate) => candidate.path === secretPath && candidate.deleted !== true, ); expect(entry).toBeDefined(); const plaintext = await cryptoService.decryptObject({ key: clonedKey, fileId: entry!.fileId, envelope: await clonedMirror.readObject(entry!.fileId), }); expect(new TextDecoder().decode(plaintext)).toBe(`# Private updated\n\n${sentinel}`); } finally { manager.destroy(clonedKey); } }, 30_000); it("pulls an encrypted branch into a new empty mirror", async () => { const pulledRoot = path.join(root, "pulled-repository"); await mkdir(pulledRoot); const pulledMirror = await EncryptedMirror.open(pulledRoot); const pullingAdapter = await SystemGitAdapter.open(pulledRoot, { remoteUrl: remoteRoot, branch: "main", retryDelaysMs: [], }); await pullingAdapter.init(); const result = await pullingAdapter.pull(); expect(result.status).toBe("pulled"); expect(result.beforeCommit).toBeNull(); expect(result.afterCommit).not.toBeNull(); expect(await pulledMirror.readConfig()).toEqual(expect.objectContaining({ vaultId })); }, 30_000); it("rejects snapshot reads outside the encrypted path allowlist", async () => { await expect(adapter.readFileAt(firstCommit, "Notes.md")).rejects.toBeInstanceOf( GitSafetyError, ); }); }); describe("SystemGitAdapter safety failures", () => { let safetyRoot: string; let safetyMirror: EncryptedMirror; let safetyKey: KeyHandle; let safetyAdapter: SystemGitAdapter; beforeAll(async () => { safetyRoot = await mkdtemp(path.join(tmpdir(), "obsidian-encrypted-git-git-safety-")); safetyMirror = await EncryptedMirror.open(safetyRoot); const created = await manager.create(encoder.encode("git safety password")); safetyKey = created.key; await safetyMirror.writeConfigAtomic(created.config); await new ManifestStore(safetyMirror, cryptoService, created.config.vaultId).save( { formatVersion: 1, vaultId: created.config.vaultId, revision: 0, files: {} }, safetyKey, ); safetyAdapter = await SystemGitAdapter.open(safetyRoot, { branch: "main", retryDelaysMs: [] }); await safetyAdapter.init(); await git(safetyRoot, ["config", "user.name", "Encrypted Vault Test"]); await git(safetyRoot, ["config", "user.email", "encrypted-vault@example.invalid"]); }, 30_000); afterAll(async () => { manager.destroy(safetyKey); await rm(safetyRoot, { recursive: true, force: true }); }); it("rejects an unexpected plaintext working-tree file", async () => { await writeFile(path.join(safetyRoot, "Notes.md"), "plaintext must not be committed"); await expect(safetyAdapter.commit("Must reject plaintext")).rejects.toBeInstanceOf(GitSafetyError); await unlink(path.join(safetyRoot, "Notes.md")); }); it("rejects plaintext that was manually staged before adapter commit", async () => { await writeFile(path.join(safetyRoot, "Notes.md"), "manually staged plaintext"); await git(safetyRoot, ["add", "Notes.md"]); await expect(safetyAdapter.commit("Must reject staged plaintext")).rejects.toBeInstanceOf(GitSafetyError); await git(safetyRoot, ["rm", "--cached", "Notes.md"]); await unlink(path.join(safetyRoot, "Notes.md")); }); it("keeps a local commit pending when push has no remote", async () => { const commit = await safetyAdapter.commit("Encrypted vault pending push"); expect(commit).not.toBeNull(); await expect(safetyAdapter.push()).rejects.toBeInstanceOf(GitConfigurationError); expect((await git(safetyRoot, ["rev-parse", "HEAD"])).stdout.trim()).toBe(commit); }); it("refuses to commit while a local mirror transaction is pending", async () => { await safetyMirror.writePendingLocalTransactionAtomic(new Uint8Array([1, 2, 3])); await expect(safetyAdapter.commit("Must reject pending transaction")).rejects.toBeInstanceOf( GitSafetyError, ); await safetyMirror.deletePendingLocalTransaction(); }); it("rejects oversized encrypted payloads before Git reads or stages them", async () => { const oversizedId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const oversizedPath = path.join(safetyRoot, "objects", `${oversizedId}.enc`); await writeFile(oversizedPath, new Uint8Array([0x4f, 0x45, 0x47, 0x43, 1])); await truncate(oversizedPath, 64 * 1024 * 1024 + 1); await expect(safetyAdapter.commit("Must reject oversized payload")).rejects.toBeInstanceOf( GitSafetyError, ); await unlink(oversizedPath); }); it("refuses an existing repository whose reachable history contains plaintext paths", async () => { await writeFile(path.join(safetyRoot, "Legacy-Plaintext.md"), "legacy plaintext history"); await git(safetyRoot, ["add", "Legacy-Plaintext.md"]); await git(safetyRoot, ["commit", "--no-gpg-sign", "-m", "legacy plaintext commit"]); await unlink(path.join(safetyRoot, "Legacy-Plaintext.md")); await git(safetyRoot, ["add", "--all"]); await git(safetyRoot, ["commit", "--no-gpg-sign", "-m", "remove legacy plaintext"]); await expect(safetyAdapter.commit("Encrypted vault after unsafe history")).rejects.toBeInstanceOf( GitSafetyError, ); }); }); async function git(cwd: string, args: readonly string[]): Promise<{ stdout: string; stderr: string }> { return execFileAsync("git", [...args], { cwd, encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" }, }); } async function gitBuffer(cwd: string, args: readonly string[]): Promise<Buffer> { const result = await execFileAsync("git", [...args], { cwd, encoding: "buffer", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" }, }); return result.stdout; } async function assertNoMarkersInFiles(searchRoot: string, markers: readonly string[]): Promise<void> { for (const entry of await readdir(searchRoot, { recursive: true, withFileTypes: true })) { if (!entry.isFile()) continue; const bytes = await readFile(path.join(entry.parentPath, entry.name)); for (const marker of markers) { expect(bytes.includes(Buffer.from(marker))).toBe(false); } } } function isAllowedTreePath(candidate: string): boolean { return ( candidate === "secure-vault.json" || candidate === "manifest.enc" || /^objects\/[0-9a-f-]{36}\.enc$/u.test(candidate) ); }