/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/git/systemGitAdapter.ts
587 строк
21 KB
Robert Kuzhin
feat: add manual encrypted source control
09 авг 2026, 20:57
09 авг 2026, 20:57
3c8fa38
Код
Авторство
О чём код?
import { lstat, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { canonicalDirectory } from "../mirror/pathSafety.js"; import { GitCommandError, GitConfigurationError, GitDirtyWorkTreeError, GitSafetyError, } from "./errors.js"; import { runGitProcess, type GitProcessResult } from "./gitProcess.js"; import type { AheadBehind, EncryptedSnapshotChange, FetchResult, GitAdapter, GitStatus, GitStatusEntry, PullResult, } from "./gitTypes.js"; import { assertEncryptedRepositorySafety, isAllowedEncryptedPath } from "./repositorySafety.js"; const DEFAULT_RETRY_DELAYS_MS = [250, 1_000, 3_000] as const; const COMMIT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; const INCOMING_REF = "refs/obsidian-encrypted-git/incoming"; export interface SystemGitAdapterOptions { readonly remoteName?: string; readonly remoteUrl?: string; readonly branch?: string; readonly retryDelaysMs?: readonly number[]; readonly commandTimeoutMs?: number; } export class SystemGitAdapter implements GitAdapter { private constructor( public readonly root: string, private readonly rootDevice: number, private readonly rootInode: number, private readonly remoteName: string, private readonly remoteUrl: string | undefined, private readonly branch: string, private readonly retryDelaysMs: readonly number[], private readonly commandTimeoutMs: number | undefined, ) {} public static async open( encryptedRoot: string, options: SystemGitAdapterOptions = {}, ): Promise<SystemGitAdapter> { const root = await canonicalDirectory(encryptedRoot); const metadata = await stat(root); const remoteName = options.remoteName ?? "origin"; const branch = options.branch ?? "main"; validateRemoteName(remoteName); validateBranch(branch); if (options.remoteUrl !== undefined) validateRemoteUrl(options.remoteUrl); const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS; if (retryDelaysMs.some((delay) => !Number.isSafeInteger(delay) || delay < 0 || delay > 60_000)) { throw new GitConfigurationError("Git retry delays are outside the supported range"); } if ( options.commandTimeoutMs !== undefined && (!Number.isSafeInteger(options.commandTimeoutMs) || options.commandTimeoutMs < 1_000) ) { throw new GitConfigurationError("Git command timeout is outside the supported range"); } return new SystemGitAdapter( root, metadata.dev, metadata.ino, remoteName, options.remoteUrl, branch, [...retryDelaysMs], options.commandTimeoutMs, ); } public async init(): Promise<void> { await this.assertRootIdentity(); const gitPath = path.join(this.root, ".git"); const gitMetadata = await lstat(gitPath).catch((error: unknown) => { if (isNodeError(error) && error.code === "ENOENT") return undefined; throw error; }); if (gitMetadata === undefined) { await this.runChecked(["init", "-b", this.branch], "init"); } else if (!gitMetadata.isDirectory() || gitMetadata.isSymbolicLink()) { throw new GitSafetyError("Git metadata must be a regular directory inside encrypted root"); } await this.assertRepositoryRoot(); await this.ensureCommitIdentity(); if (this.remoteUrl !== undefined) await this.configureRemote(this.remoteUrl); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), false, true); } public async status(): Promise<GitStatus> { await this.assertReady(); const result = await this.runChecked( ["status", "--porcelain=v1", "-z", "--untracked-files=all"], "status", ); const entries = parsePorcelainStatus(result.stdout); return { entries, clean: entries.length === 0 }; } public async fetch(): Promise<FetchResult> { await this.assertReady(); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), false); const localCommit = await this.currentCommit(); if (!(await this.hasRemote())) { return { status: "no-remote", localCommit, remoteCommit: null }; } const remoteBranch = await this.run([ "ls-remote", "--exit-code", "--heads", this.remoteName, `refs/heads/${this.branch}`, ]); if (remoteBranch.exitCode === 2) { return { status: "no-remote-branch", localCommit, remoteCommit: null }; } if (remoteBranch.exitCode !== 0) throw new GitCommandError("ls-remote", remoteBranch.exitCode); await this.runChecked( [ "fetch", "--no-tags", "--force", this.remoteName, `refs/heads/${this.branch}:${INCOMING_REF}`, ], "fetch", ); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); const remoteCommit = ( await this.runChecked(["rev-parse", "--verify", `${INCOMING_REF}^{commit}`], "rev-parse") ).stdout.toString("utf8").trim(); assertCommitId(remoteCommit); return { status: "fetched", localCommit, remoteCommit }; } public async readFileAt(revision: string, encryptedPath: string): Promise<Uint8Array> { await this.assertReady(); assertCommitId(revision); if (!isAllowedEncryptedPath(encryptedPath)) { throw new GitSafetyError("Git snapshot path is outside the encrypted allowlist"); } const verified = await this.runChecked( ["rev-parse", "--verify", `${revision}^{commit}`], "rev-parse", ); if (verified.stdout.toString("utf8").trim() !== revision) { throw new GitSafetyError("Git snapshot revision did not resolve to the requested commit"); } const result = await this.runChecked( ["cat-file", "blob", `${revision}:${encryptedPath}`], "cat-file", ); return new Uint8Array(result.stdout); } public async getHeadCommit(): Promise<string | null> { await this.assertReady(); return this.currentCommit(); } public async aheadBehind( localCommit: string, remoteCommit: string, ): Promise<AheadBehind> { assertCommitId(localCommit); assertCommitId(remoteCommit); await this.assertReady(); const result = await this.runChecked( ["rev-list", "--left-right", "--count", `${localCommit}...${remoteCommit}`], "rev-list", ); const match = /^(\d+)\s+(\d+)\s*$/u.exec(result.stdout.toString("utf8")); if (match === null) throw new GitSafetyError("Git returned invalid ahead/behind counts"); return { ahead: Number(match[1]), behind: Number(match[2]) }; } public async commitSnapshot( message: string, expectedHead: string, manifestEnvelope: Uint8Array, changes: readonly EncryptedSnapshotChange[], ): Promise<string> { validateCommitMessage(message); assertCommitId(expectedHead); validateEnvelopeBytes(manifestEnvelope); const seen = new Set<string>(); for (const change of changes) { const objectPath = encryptedObjectPath(change.fileId); if (seen.has(objectPath)) throw new GitSafetyError("Encrypted snapshot contains duplicate objects"); seen.add(objectPath); if (change.deleted) { if (change.envelope !== undefined) { throw new GitSafetyError("Deleted encrypted snapshot object must not contain payload bytes"); } } else { if (change.envelope === undefined) { throw new GitSafetyError("Active encrypted snapshot object is missing payload bytes"); } validateEnvelopeBytes(change.envelope); } } await this.assertReady(); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); if ((await this.currentCommit()) !== expectedHead) { throw new GitDirtyWorkTreeError("Git HEAD changed after files were staged"); } await this.runChecked(["read-tree", expectedHead], "read-tree"); let committed = false; try { const manifestBlob = await this.hashBlob(manifestEnvelope); await this.runChecked( ["update-index", "--add", "--cacheinfo", `100644,${manifestBlob},manifest.enc`], "update-index", ); for (const change of changes) { const objectPath = encryptedObjectPath(change.fileId); if (change.deleted) { const removed = await this.run(["update-index", "--force-remove", "--", objectPath]); if (removed.exitCode !== 0) throw new GitCommandError("update-index", removed.exitCode); continue; } const blob = await this.hashBlob(change.envelope!); await this.runChecked( ["update-index", "--add", "--cacheinfo", `100644,${blob},${objectPath}`], "update-index", ); } await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); const tree = (await this.runChecked(["write-tree"], "write-tree")).stdout .toString("utf8") .trim(); assertCommitId(tree); const headTree = (await this.runChecked(["rev-parse", `${expectedHead}^{tree}`], "rev-parse")) .stdout.toString("utf8").trim(); if (tree === headTree) throw new GitDirtyWorkTreeError("Selected files contain no commit changes"); const commitId = ( await this.runChecked( ["commit-tree", tree, "--no-gpg-sign", "-p", expectedHead, "-m", message], "commit-tree", ) ).stdout.toString("utf8").trim(); assertCommitId(commitId); await this.runChecked(["reset", "--mixed", commitId], "reset"); committed = true; await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); return commitId; } finally { if (!committed) { await this.run(["read-tree", expectedHead]).catch(() => undefined); } } } public async commit(message: string): Promise<string | null> { validateCommitMessage(message); await this.assertReady(); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); await this.runChecked( ["add", "--all", "--", "secure-vault.json", "manifest.enc", "objects"], "add", ); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); const diff = await this.run(["diff", "--cached", "--quiet"]); if (diff.exitCode === 0) return null; if (diff.exitCode !== 1) throw new GitCommandError("diff", diff.exitCode); await this.runChecked( ["commit", "--no-verify", "--no-gpg-sign", "-m", message], "commit", ); const revision = await this.runChecked(["rev-parse", "HEAD"], "rev-parse"); return revision.stdout.toString("utf8").trim(); } public async commitReconciled( message: string, remoteCommit: string | null, ): Promise<string | null> { validateCommitMessage(message); if (remoteCommit !== null) assertCommitId(remoteCommit); await this.assertReady(); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); await this.runChecked( ["add", "--all", "--", "secure-vault.json", "manifest.enc", "objects"], "add", ); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); const localCommit = await this.currentCommit(); const diff = await this.run(["diff", "--cached", "--quiet"]); if (diff.exitCode !== 0 && diff.exitCode !== 1) { throw new GitCommandError("diff", diff.exitCode); } const treeChanged = diff.exitCode === 1; const needsRemoteParent = await this.needsRemoteParent(remoteCommit, localCommit); if (!treeChanged && !needsRemoteParent) return null; const tree = ( await this.runChecked(["write-tree"], "write-tree") ).stdout.toString("utf8").trim(); assertCommitId(tree); const args = ["commit-tree", tree, "--no-gpg-sign", "-m", message]; if (localCommit !== null) args.push("-p", localCommit); if (needsRemoteParent && remoteCommit !== null) args.push("-p", remoteCommit); const committed = await this.runChecked(args, "commit-tree"); const commitId = committed.stdout.toString("utf8").trim(); assertCommitId(commitId); await this.runChecked(["reset", "--soft", commitId], "reset"); await assertEncryptedRepositorySafety(this.root, (runArgs) => this.run(runArgs), true); return commitId; } public async pull(): Promise<PullResult> { await this.assertReady(); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), false); const currentStatus = await this.status(); if (!currentStatus.clean) { throw new GitDirtyWorkTreeError("Encrypted repository must be clean before pull"); } if (!(await this.hasRemote())) { return { status: "no-remote", beforeCommit: await this.currentCommit(), afterCommit: await this.currentCommit() }; } const beforeCommit = await this.currentCommit(); const remoteBranch = await this.run([ "ls-remote", "--exit-code", "--heads", this.remoteName, `refs/heads/${this.branch}`, ]); if (remoteBranch.exitCode === 2) { return { status: "no-remote-branch", beforeCommit, afterCommit: beforeCommit }; } if (remoteBranch.exitCode !== 0) throw new GitCommandError("ls-remote", remoteBranch.exitCode); await this.runChecked( ["pull", "--ff-only", "--no-rebase", this.remoteName, this.branch], "pull", ); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); const afterCommit = await this.currentCommit(); return { status: beforeCommit === afterCommit ? "up-to-date" : "pulled", beforeCommit, afterCommit, }; } public async push(): Promise<void> { await this.assertReady(); await assertEncryptedRepositorySafety(this.root, (args) => this.run(args), true); if (!(await this.hasRemote())) throw new GitConfigurationError("Encrypted repository has no configured remote"); let lastResult: GitProcessResult | undefined; for (let attempt = 0; attempt <= this.retryDelaysMs.length; attempt += 1) { const result = await this.run([ "push", "--set-upstream", this.remoteName, `HEAD:refs/heads/${this.branch}`, ]); if (result.exitCode === 0) return; lastResult = result; if (attempt >= this.retryDelaysMs.length || !isTransientGitError(result.stderr.toString("utf8"))) { break; } await delay(this.retryDelaysMs[attempt]!); } throw new GitCommandError("push", lastResult?.exitCode ?? null); } private async run(args: readonly string[], stdin?: Uint8Array): Promise<GitProcessResult> { await this.assertRootIdentity(); return runGitProcess(this.root, args, this.commandTimeoutMs, stdin); } private async runChecked(args: readonly string[], command: string): Promise<GitProcessResult> { const result = await this.run(args); if (result.exitCode !== 0) throw new GitCommandError(command, result.exitCode); return result; } private async hashBlob(bytes: Uint8Array): Promise<string> { const result = await this.run(["hash-object", "-w", "--stdin"], bytes); if (result.exitCode !== 0) throw new GitCommandError("hash-object", result.exitCode); const objectId = result.stdout.toString("utf8").trim(); assertCommitId(objectId); return objectId; } private async assertReady(): Promise<void> { await this.assertRootIdentity(); await this.assertRepositoryRoot(); } private async assertRootIdentity(): Promise<void> { const metadata = await lstat(this.root); if ( !metadata.isDirectory() || metadata.isSymbolicLink() || metadata.dev !== this.rootDevice || metadata.ino !== this.rootInode || (await realpath(this.root)) !== this.root ) { throw new GitSafetyError("Encrypted repository root identity changed"); } } private async assertRepositoryRoot(): Promise<void> { const result = await this.runChecked(["rev-parse", "--show-toplevel"], "rev-parse"); const reportedRoot = await realpath(result.stdout.toString("utf8").trim()); if (reportedRoot !== this.root) { throw new GitSafetyError("Git top-level directory does not match encrypted repository root"); } } private async configureRemote(remoteUrl: string): Promise<void> { const existing = await this.run(["remote", "get-url", this.remoteName]); if (existing.exitCode === 0) { if (existing.stdout.toString("utf8").trim() !== remoteUrl) { throw new GitConfigurationError("Configured Git remote does not match plugin settings"); } return; } await this.runChecked(["remote", "add", this.remoteName, remoteUrl], "remote add"); } private async ensureCommitIdentity(): Promise<void> { const name = await this.run(["config", "--local", "--get", "user.name"]); if (name.exitCode !== 0) { await this.runChecked( ["config", "--local", "user.name", "Obsidian Encrypted Git"], "config user.name", ); } const email = await this.run(["config", "--local", "--get", "user.email"]); if (email.exitCode !== 0) { await this.runChecked( ["config", "--local", "user.email", "encrypted-vault@localhost"], "config user.email", ); } } private async hasRemote(): Promise<boolean> { const result = await this.run(["remote", "get-url", this.remoteName]); if (result.exitCode === 0) return true; if (result.exitCode === 2) return false; return false; } private async currentCommit(): Promise<string | null> { const result = await this.run(["rev-parse", "--verify", "HEAD"]); if (result.exitCode !== 0) return null; return result.stdout.toString("utf8").trim(); } private async needsRemoteParent( remoteCommit: string | null, localCommit: string | null, ): Promise<boolean> { if (remoteCommit === null || remoteCommit === localCommit) return false; if (localCommit === null) return true; const ancestor = await this.run(["merge-base", "--is-ancestor", remoteCommit, localCommit]); if (ancestor.exitCode === 0) return false; if (ancestor.exitCode === 1) return true; throw new GitCommandError("merge-base", ancestor.exitCode); } } export function isTransientGitError(stderr: string): boolean { return /(?:connection (?:reset|timed out)|could not resolve host|temporary failure|remote end hung up|http (?:500|502|503|504)|the requested url returned error: (?:500|502|503|504))/iu.test( stderr, ); } function parsePorcelainStatus(output: Buffer): GitStatusEntry[] { const records = output.toString("utf8").split("\0"); const entries: GitStatusEntry[] = []; for (let index = 0; index < records.length; index += 1) { const record = records[index]!; if (record.length === 0) continue; if (record.length < 4 || record[2] !== " ") { throw new GitSafetyError("Git returned an unsupported status record"); } const indexStatus = record[0]!; const workTreeStatus = record[1]!; const entry: GitStatusEntry = { indexStatus, workTreeStatus, path: record.slice(3) }; if (indexStatus === "R" || indexStatus === "C") { const originalPath = records[index + 1]; if (originalPath === undefined || originalPath.length === 0) { throw new GitSafetyError("Git returned a truncated rename status record"); } entries.push({ ...entry, originalPath }); index += 1; } else { entries.push(entry); } } return entries; } function validateRemoteName(value: string): void { if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(value)) { throw new GitConfigurationError("Git remote name is invalid"); } } function validateBranch(value: string): void { if ( !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/u.test(value) || value.includes("..") || value.includes("//") || value.includes("@{") || value.endsWith("/") || value.endsWith(".") || value.endsWith(".lock") ) { throw new GitConfigurationError("Git branch name is invalid"); } } function validateRemoteUrl(value: string): void { if (value.length === 0 || value.length > 8_192 || value.startsWith("-") || /[\0\r\n]/u.test(value)) { throw new GitConfigurationError("Git remote URL is invalid"); } if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(value)) { try { const parsed = new URL(value); if (parsed.password.length > 0 || parsed.search.length > 0) { throw new GitConfigurationError("Git remote URL must not embed credentials or query tokens"); } } catch (error) { if (error instanceof GitConfigurationError) throw error; throw new GitConfigurationError("Git remote URL is invalid", { cause: error }); } } } function validateCommitMessage(value: string): void { if (value.length === 0 || value.length > 200 || /[\0\r\n]/u.test(value)) { throw new GitConfigurationError("Git commit message is invalid"); } } function encryptedObjectPath(fileId: string): string { const candidate = `objects/${fileId}.enc`; if (!isAllowedEncryptedPath(candidate)) { throw new GitSafetyError("Encrypted snapshot file identity is invalid"); } return candidate; } function validateEnvelopeBytes(value: Uint8Array): void { if (value.length < 45 || value.length > 64 * 1024 * 1024) { throw new GitSafetyError("Encrypted snapshot payload is outside its size limit"); } } function assertCommitId(value: string): void { if (!COMMIT_PATTERN.test(value)) { throw new GitSafetyError("Git commit identifier is invalid"); } } function delay(milliseconds: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; }