mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Isolated task workspaces are Git worktrees on the server disk, and the terminal workspace reaper keeps that disk usage bounded > - #17369 moves old workspaces off their private per-issue clones onto the project's shared clone and then deletes the private clone > - On one instance, the first live sweep moved 12 workspaces but deleted none of their clones (about 600 MB each), and 3 workspaces could not move because their restore point was "too large" > - The kept clones are not referenced by any workspace, so no other cleanup removes them > - This pull request replaces the clone check with a real reproducibility check, sweeps the orphaned clones, lifts the restore point limits for local transfers, and fixes a false `source_identity_changed` > - The benefit is that the disk space #17369 was meant to reclaim is reclaimed, without deleting unique work ## Linked Issues or Issue Description Refs #17369. Bug report described in-PR (bug report template). **What happened?** On one instance, with `PAPERCLIP_ISOLATED_SOURCE_REHOME_ENABLED=true` and dry run off, the first sweep re-homed 12 workspaces, skipped 5 with `live_or_unresolved_consumer` and 3 with `restore_point_too_large`. No clone was removed: 11 logged `cloneKeptReason: "clone_checkout_has_files"` and 1 logged `"source_identity_changed"`. Each clone's own checkout was clean (`git status --porcelain --untracked-files=all` empty). Its only history that the shared clone lacked was remote branches and release tags that the shared clone never fetched, which exist on the remote. An operator removed 11 clones by hand after verifying them. **Expected behavior** A re-homed clone whose checkout is clean and whose local history is in the shared clone or on the remote is deleted. A clone with real unique work is kept. Workspaces whose changes exceed the upload limits still move, because the transfer is local. **Steps to reproduce** 1. Use a project repository whose `.gitignore` (or the clone's `info/exclude`) contains `.paperclip/`, with a release tag on a commit that is not on any branch. 2. Re-home an isolated workspace of that repository with #17369. 3. The old worktree is removed, the empty `.paperclip/worktrees` directory stays, and `git ls-files --others --ignored --directory` lists `.paperclip/`. The clone is kept with `clone_checkout_has_files`. Without the ignore line, the local release tag keeps it with `unique_clone_history`. 4. Add a file larger than 64 MiB to a workspace. The move skips with `restore_point_too_large`. **Paperclip version or commit:** `master` ataa0037a137(rebased onee29e2850d, after #17407). **Deployment mode:** any. **Agent adapter(s) involved:** not adapter-specific (core). ## What Changed - New clone reproducibility check (`cloneDisposable` in `server/src/services/isolated-repository-rehome.ts`), used before deleting a re-homed clone: - `git worktree prune`, then only the clone's own worktree may remain (`base_has_worktrees`); - no tracked changes and no untracked files (`clone_checkout_has_files`); the only untracked exception is Paperclip's compaction trash; - ignored files only inside regenerable directories (`node_modules`, `dist`, ...) or Paperclip's compaction trash. Ignored entries are listed with `--no-empty-directory`, and a non-regenerable ignored directory is expanded file by file, so empty directories such as the old worktree's `.paperclip/worktrees` parent no longer count; - only `refs/heads`, `refs/tags` and `refs/remotes` (no stash, notes or replacement refs: `unsupported_clone_refs`); - every commit reachable from local branches, local tags, non-remote reflogs, `ORIG_HEAD` and `HEAD` (so a detached checkout counts) must be on a remote-tracking ref, present in the shared clone, or on the remote. The remote check reuses `hasCommitsMissingUpstream` (now exported), which fetches the remote's branches and tags into a temporary store, and it runs only when some commit is in neither place. A local tag is accepted when its commit is on the remote. Otherwise the clone is kept (`unique_clone_history`), or kept with `managed_checkout_upstream_unavailable` when the remote cannot be read; - existing 5,000 budgets for refs, reflog entries and unique commits (`clone_history_budget`), plus at most 1,000 ignored directories outside regenerable ones, read in one Git call (`clone_file_budget`); - the log entry now includes `cloneKeptPaths`: up to five paths, worktrees, refs or commits that kept the clone. - Cleanup also checks that the clone's `origin` still matches the workspace `repoUrl` before deleting it. - Old worktree ownership (#4): it is proven by `git rev-parse --git-common-dir`, or, when that fails, by the worktree's `.git` pointer into `<clone>/.git/worktrees/`. - Orphan sweep (`sweepOrphanedIsolatedRepositorySources`): at the first reaper rotation after server start (feature on), it walks `isolated-repository-sources/<company>/<issue>/checkout`. It holds the isolated source lock and skips clones that a workspace still needs (an open workspace with matching `isolatedRemoteSource`, a closed one that is not expired with its worktree removed, a project workspace or a running service), clones with a pending move, and clones whose re-homed workspace still has its own cleanup pending. The repository URL comes only from the database: the issue's workspaces must record exactly one URL and the clone's `origin` must equal it (`unproven_repository` or `source_identity_changed` otherwise), so a rewritten `origin` is never fetched. It then applies the same check (using the shared clone of a workspace of the same issue and repository when one exists), renames to trash and deletes. It logs `event: "execution_workspace.isolated_source_orphan_cleanup"` and removes leftover trash directories from interrupted removals. It inspects at most eight issue directories per reaper sweep and resumes from the saved position in the next sweep, so slow remotes cannot hold up other cleanup. When a full walk kept any orphan, it registers a deadline one day later in the reaper's existing deadline set. Otherwise it registers nothing until the next server start. No new timer. - Restore point limits for re-home (#3): `ISOLATED_REHOME_RESTORE_POINT_LIMITS` (500,000 files, 1 GiB per file and in total, 1.5 GiB bundle, 1,000,000 commits) replaces the upload defaults for this local transfer. Verification is unchanged (exact snapshot equality). In `git-restore-point.ts`, `applyRestorePoint` now streams `git diff --binary` into `git apply` instead of buffering it (and skips the pipe when there is no diff, so no new Git version is needed). On a failure it kills both processes and waits for both. The ignored-file read sizes its buffer from the tree instead of the default limit. - Docs: `doc/recoverable-disk-cleanup.md`. ### What I found for `source_identity_changed` (#4) In #17369, cleanup ran `git rev-parse --git-common-dir` in the old worktree and used `""` when the command failed. Then it compared that to the clone's `.git` path, so any Git failure in the old worktree was reported as `source_identity_changed`. Git fails there when the old worktree's registration in the clone (`<clone>/.git/worktrees/<name>`) is gone while the directory still exists. Its `.git` file then points to a missing directory. I reproduced this by deleting the registration after a re-home: the old code keeps the clone with `source_identity_changed`, the new code removes the old worktree and the clone. I could not inspect the instance, so the exact event that removed that one registration is not confirmed. The check was a false positive in that case, and the fix proves ownership from the `.git` pointer instead. A worktree whose pointer leads outside the clone is still kept. ## Verification - `pnpm exec vitest run src/__tests__/isolated-repository-rehome.test.ts` in `server/`: 28 passed (real Git repositories, embedded Postgres). New cases: - a clean clone whose only extra history is a remote branch and a release tag on a commit outside any branch, both absent from the shared clone, with `.paperclip/` ignored in the clone: deleted; - a clone with a local branch commit that is neither upstream nor in the shared clone: kept (`unique_clone_history`, the commit is in `cloneKeptPaths`); - a clone with a tracked change, then an ignored non-regenerable file next to an ignored `node_modules`, then an untracked `build/notes.txt`: each kept (`clone_checkout_has_files` with the path), then deleted after cleanup; - a clone with a second registered worktree: kept (`base_has_worktrees`); - a clone whose detached `HEAD` holds the only copy of a commit (reflogs expired): kept (`unique_clone_history`); - an old worktree whose registration was pruned: old worktree and clone removed; - an orphan whose `origin` was rewritten is kept with `source_identity_changed`, and with `unproven_repository` once no workspace records its repository; - 1,001 ignored directories keep a clone (`clone_file_budget`); - orphan sweep: a batch of one returns a resume position and the next batch finishes the walk; a dry run changes nothing. Then an orphan whose history is in the shared clone or on the remote is deleted, and an orphan with no workspace rows and an unpushed commit is kept, with the next deadline one day later; - a workspace with 65 MiB untracked and ignored files (default capture refuses them with `restore_point_too_large`) moves with an exact snapshot match. - After rebasing on #17407 (nested clone migration, which reuses helpers from the re-home module): `isolated-repository-rehome`, `nested-clone-migration`, `execution-workspaces-service`, `execution-workspaces-routes`, `execution-workspace-reopen`, `execution-workspace-compaction`, `execution-workspace-policy`, `execution-workspace-runtime-control-conflict`, `execution-workspace-runtime-lease-route`, `git-workspace-expiration`, `git-restore-point`, `git-restore-point-expiration`: 405 passed; typecheck passes. - Related suites in `server/` (before the rebase): `git-workspace-expiration`, `git-restore-point`, `git-restore-point-expiration`, `execution-workspace-reopen`, `execution-workspaces-service`, `execution-workspace-compaction`, `heartbeat-git-restore-point-resume`, `heartbeat-project-repositories`, `git-reproducible-managed-checkouts`, `isolated-remote-workspace-source`, with the new suite: 398 passed. - `pnpm run typecheck` in `server/` passes. - Mutation checks, each failing a test: - remote proof replaced by "always unique": the unfetched-history and orphan tests fail; - remote proof replaced by "never unique": the local-commit and orphan tests fail; - `.git` pointer fallback removed: the pruned-registration test fails; - re-home limits removed: the large-file test fails; - orphan sweep removed from the reaper: the orphan test fails; - worktree count check removed: the second-worktree test fails; - regenerable-directory exception applied to untracked files: the dirty-checkout test fails; - orphan batch limit removed: the orphan test fails; - orphan URL taken from the clone again: the rewritten-origin test fails; - ignored directory budget removed: the many-directory test fails; - `HEAD` left out of the checked commits: the detached-HEAD test fails. - Dropping only `--no-empty-directory` does not fail a test, because the per-directory expansion also accepts an empty ignored directory. The two mechanisms are redundant on purpose. - Not run: the full server suite, or the change on a live instance. ## Risks - Deleting a clone now depends on reading the remote when some local commit is neither on a remote-tracking ref nor in the shared clone. If the remote cannot be read, the clone is kept. - Commits that only remote-tracking refs hold (for example a remote branch deleted upstream after the clone fetched it) are treated as not unique, as before. They were pushed once. - The orphan sweep deletes clones that no workspace refers to. A closed workspace that is expired with its worktree removed does not count as a user, because reopening it rebuilds from the shared clone or clones again. - Re-home transfers can now use up to about 1.5 GiB of memory for a single restore point payload, so a very large workspace uses more memory during its move than before. - The orphan sweep runs once per server start and then only while it keeps an orphan, so a clone that becomes orphaned later waits for the next restart. - No migration. No schema change. ## Model Used Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code with tool use (shell, file editing, Git, test runs). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
604 lines
31 KiB
TypeScript
604 lines
31 KiB
TypeScript
import { execFile as execFileCallback, spawn } from "node:child_process";
|
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
|
import type { StoredSecretVersionMaterial } from "../secrets/types.js";
|
|
import { REGENERABLE_WORKSPACE_DIR_NAMES } from "./workspace-compaction.js";
|
|
|
|
const execFile = promisify(execFileCallback);
|
|
|
|
export const RESTORE_POINT_PAYLOAD_SCHEMA = "paperclip.git-restore-point-payload.v1";
|
|
export const RESTORE_POINT_PAYLOAD_SCHEMA_V2 = "paperclip.git-restore-point-payload.v2";
|
|
const PAYLOAD_MAGIC = Buffer.from("PCRP-PLAIN1\n");
|
|
const SEALED_MAGIC = Buffer.from("PCRP1");
|
|
const KEY_SCHEMA = "paperclip.git-restore-point-key.v1";
|
|
const SHA = /^[0-9a-f]{40}$/;
|
|
const SNAPSHOT_IDENTITY = {
|
|
GIT_AUTHOR_NAME: "Paperclip",
|
|
GIT_AUTHOR_EMAIL: "restore-point@paperclip.invalid",
|
|
GIT_AUTHOR_DATE: "@0 +0000",
|
|
GIT_COMMITTER_NAME: "Paperclip",
|
|
GIT_COMMITTER_EMAIL: "restore-point@paperclip.invalid",
|
|
GIT_COMMITTER_DATE: "@0 +0000",
|
|
};
|
|
const EXCLUDED_UNTRACKED = [":(exclude,glob)**/.env", ":(exclude,glob)**/.env.*"];
|
|
const MAX_PRESERVED_COMMITS = 50;
|
|
|
|
export type RestorePointLimits = {
|
|
maxFiles: number;
|
|
maxFileBytes: number;
|
|
maxTotalBytes: number;
|
|
maxCommits: number;
|
|
maxBundleBytes: number;
|
|
};
|
|
|
|
export const DEFAULT_RESTORE_POINT_LIMITS: RestorePointLimits = {
|
|
maxFiles: 20_000,
|
|
maxFileBytes: 64 * 1024 * 1024,
|
|
maxTotalBytes: 256 * 1024 * 1024,
|
|
maxCommits: 1_000,
|
|
maxBundleBytes: 256 * 1024 * 1024,
|
|
};
|
|
|
|
export type RestorePointManifest = {
|
|
schema: typeof RESTORE_POINT_PAYLOAD_SCHEMA | typeof RESTORE_POINT_PAYLOAD_SCHEMA_V2;
|
|
head: string;
|
|
index: string | null;
|
|
worktree: string | null;
|
|
ignored?: string;
|
|
preserved: string[];
|
|
prerequisites: string[];
|
|
modes: Array<{ path: string; mode: number }>;
|
|
};
|
|
|
|
export type RestorePointSnapshot = { head: string; index: string | null; worktree: string | null; ignored: string | null;
|
|
modes: Array<{ path: string; mode: number }> };
|
|
|
|
export function restorePointSavesIgnoredFile(file: string) {
|
|
const parts = file.split("/").filter(Boolean);
|
|
if (parts.length === 0 || /^\.env(\..*)?$/.test(parts[parts.length - 1]!)) return false;
|
|
if (parts[0] === ".paperclip-repositories" || file === ".claude/settings.local.json") return false;
|
|
return !parts.slice(0, -1).some(part => REGENERABLE_WORKSPACE_DIR_NAMES.has(part));
|
|
}
|
|
|
|
async function savedIgnoredFiles(cwd: string, signal?: AbortSignal) {
|
|
return nulList(await git(cwd, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard"], { signal }))
|
|
.filter(restorePointSavesIgnoredFile).sort();
|
|
}
|
|
|
|
export type RestorePointIdentity = { companyId: string; workspaceId: string; restorePointId: string; generation: number };
|
|
|
|
export type SealedRestorePoint = {
|
|
ciphertext: Buffer;
|
|
sha256: string;
|
|
key: StoredSecretVersionMaterial;
|
|
};
|
|
|
|
export class RestorePointSkip extends Error {
|
|
constructor(readonly reason: string) {
|
|
super(reason);
|
|
this.name = "RestorePointSkip";
|
|
}
|
|
}
|
|
|
|
export class RestorePointRestoreFailure extends Error {
|
|
constructor(readonly reason: string) {
|
|
super(`The workspace restore point could not be applied: ${reason}`);
|
|
this.name = "RestorePointRestoreFailure";
|
|
}
|
|
}
|
|
|
|
type GitOptions = { env?: Record<string, string>; input?: Buffer | string; signal?: AbortSignal; maxBuffer?: number };
|
|
|
|
function isolatedEnv(extra: Record<string, string> = {}) {
|
|
return {
|
|
...process.env,
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_CONFIG_GLOBAL: os.devNull,
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
GIT_OPTIONAL_LOCKS: "0",
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
async function git(cwd: string, args: string[], options: GitOptions = {}): Promise<Buffer> {
|
|
const child = execFile("git", gitArgs(cwd, args), {
|
|
env: isolatedEnv(options.env),
|
|
encoding: "buffer",
|
|
maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
|
|
timeout: 120_000,
|
|
signal: options.signal,
|
|
});
|
|
if (options.input !== undefined) child.child.stdin?.end(options.input);
|
|
const { stdout } = await child;
|
|
return stdout as Buffer;
|
|
}
|
|
|
|
function gitArgs(cwd: string, args: string[]) {
|
|
return ["-c", "core.fsmonitor=false", "-c", "core.autocrlf=false", "-c", "core.safecrlf=false", "-c", `core.hooksPath=${os.devNull}`, "-C", cwd, ...args];
|
|
}
|
|
|
|
async function gitPipe(cwd: string, from: string[], to: string[], signal?: AbortSignal) {
|
|
const options = { env: isolatedEnv(), signal, timeout: 15 * 60_000 };
|
|
const source = spawn("git", gitArgs(cwd, from), { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
const sink = spawn("git", gitArgs(cwd, to), { ...options, stdio: ["pipe", "ignore", "pipe"] });
|
|
source.stdout.pipe(sink.stdin);
|
|
sink.stdin.on("error", () => {});
|
|
const finished = (child: ReturnType<typeof spawn>) => new Promise<void>((resolve, reject) => {
|
|
let stderr = "";
|
|
child.stderr?.on("data", (chunk: Buffer) => { if (stderr.length < 4096) stderr += chunk.toString("utf8"); });
|
|
child.on("error", reject);
|
|
child.on("close", (code) => code === 0 ? resolve() : reject(Object.assign(new Error(`git exited with ${code}`), { code, stderr })));
|
|
});
|
|
const done = [finished(source), finished(sink)];
|
|
try { await Promise.all(done); }
|
|
catch (error) {
|
|
source.kill("SIGKILL");
|
|
sink.kill("SIGKILL");
|
|
await Promise.allSettled(done);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function gitText(cwd: string, args: string[], options: GitOptions = {}) {
|
|
return (await git(cwd, args, options)).toString("utf8").trim();
|
|
}
|
|
|
|
function nulList(output: Buffer) {
|
|
return output.toString("utf8").split("\0").filter(Boolean);
|
|
}
|
|
|
|
async function objectsDirectory(cwd: string) {
|
|
const common = await gitText(cwd, ["rev-parse", "--git-common-dir"]);
|
|
return path.resolve(cwd, common, "objects");
|
|
}
|
|
|
|
async function snapshotStore(cwd: string, temporary: string) {
|
|
const store = path.join(temporary, "store.git");
|
|
await execFile("git", ["init", "-q", "--bare", store], { env: isolatedEnv() });
|
|
const repositoryObjects = await objectsDirectory(cwd);
|
|
await fs.writeFile(path.join(store, "objects", "info", "alternates"), `${repositoryObjects}\n`);
|
|
return {
|
|
store,
|
|
env: {
|
|
GIT_OBJECT_DIRECTORY: path.join(store, "objects"),
|
|
GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects,
|
|
...SNAPSHOT_IDENTITY,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function copyIndex(cwd: string, target: string) {
|
|
const indexPath = path.resolve(cwd, await gitText(cwd, ["rev-parse", "--git-path", "index"]));
|
|
await fs.copyFile(indexPath, target).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code !== "ENOENT") throw error;
|
|
});
|
|
}
|
|
|
|
async function scanChangedPaths(cwd: string, limits: RestorePointLimits, signal?: AbortSignal) {
|
|
const changed = new Set([
|
|
...nulList(await git(cwd, ["ls-files", "-z", "--modified"], { signal })),
|
|
...nulList(await git(cwd, ["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD"], { signal })),
|
|
...nulList(await git(cwd, ["ls-files", "-z", "--others", "--exclude-standard", "--", ".", ...EXCLUDED_UNTRACKED], { signal })),
|
|
...await savedIgnoredFiles(cwd, signal),
|
|
]);
|
|
if (changed.size > limits.maxFiles) throw new RestorePointSkip("restore_point_too_large");
|
|
let totalBytes = 0;
|
|
const modes: Array<{ path: string; mode: number }> = [];
|
|
for (const relative of changed) {
|
|
if (relative.endsWith("/")) throw new RestorePointSkip("untracked_nested_repository");
|
|
const stat = await fs.lstat(path.join(cwd, relative)).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
});
|
|
if (!stat) continue;
|
|
if (stat.isDirectory()) throw new RestorePointSkip("untracked_nested_repository");
|
|
if (!stat.isFile() && !stat.isSymbolicLink()) throw new RestorePointSkip("unsupported_special_file");
|
|
if (stat.isFile()) {
|
|
if (stat.size > limits.maxFileBytes) throw new RestorePointSkip("restore_point_too_large");
|
|
totalBytes += stat.size;
|
|
const mode = stat.mode & 0o777;
|
|
if (mode !== 0o644 && mode !== 0o755) modes.push({ path: relative, mode });
|
|
}
|
|
if (totalBytes > limits.maxTotalBytes) throw new RestorePointSkip("restore_point_too_large");
|
|
}
|
|
if (changed.size > 0) {
|
|
const attributes = nulList(await git(cwd, ["check-attr", "-z", "--stdin", "filter"], { input: [...changed].join("\0"), signal }));
|
|
for (let index = 2; index < attributes.length; index += 3) {
|
|
if (attributes[index] !== "unspecified" && attributes[index] !== "unset") throw new RestorePointSkip("filtered_paths");
|
|
}
|
|
}
|
|
return { modes: modes.sort((a, b) => a.path.localeCompare(b.path)) };
|
|
}
|
|
|
|
function quoteGitPath(file: string) {
|
|
let quoted = "\"";
|
|
for (const byte of Buffer.from(file, "utf8")) {
|
|
if (byte === 0x22 || byte === 0x5c) quoted += `\\${String.fromCharCode(byte)}`;
|
|
else if (byte < 0x20 || byte >= 0x7f) quoted += `\\${byte.toString(8).padStart(3, "0")}`;
|
|
else quoted += String.fromCharCode(byte);
|
|
}
|
|
return `${quoted}"`;
|
|
}
|
|
|
|
async function rawIgnoredTree(cwd: string, env: Record<string, string>, temporary: string, files: string[], signal?: AbortSignal) {
|
|
if (files.length === 0) return null;
|
|
const entries: string[] = [];
|
|
const regular: string[] = [];
|
|
for (const file of files) {
|
|
if (file.endsWith("/")) throw new RestorePointSkip("untracked_nested_repository");
|
|
if (/[\n\r]/.test(file)) throw new RestorePointSkip("unsupported_path");
|
|
const stat = await fs.lstat(path.join(cwd, file));
|
|
if (stat.isSymbolicLink()) {
|
|
const blob = await gitText(cwd, ["hash-object", "-w", "--no-filters", "--stdin"], { env, signal,
|
|
input: await fs.readlink(path.join(cwd, file), { encoding: "buffer" }) });
|
|
entries.push(`120000 ${blob}\t${file}`);
|
|
} else if (stat.isFile()) regular.push(file);
|
|
else if (stat.isDirectory()) throw new RestorePointSkip("untracked_nested_repository");
|
|
else throw new RestorePointSkip("unsupported_special_file");
|
|
}
|
|
if (regular.length > 0) {
|
|
const blobs = (await gitText(cwd, ["hash-object", "-w", "--no-filters", "--stdin-paths"], { env, signal,
|
|
input: regular.map(quoteGitPath).join("\n") + "\n" })).split("\n");
|
|
if (blobs.length !== regular.length || blobs.some(blob => !SHA.test(blob))) throw new RestorePointSkip("unsupported_path");
|
|
for (const [index, file] of regular.entries()) {
|
|
const mode = (await fs.lstat(path.join(cwd, file))).mode & 0o111 ? "100755" : "100644";
|
|
entries.push(`${mode} ${blobs[index]}\t${file}`);
|
|
}
|
|
}
|
|
const ignoredEnv = { ...env, GIT_INDEX_FILE: path.join(temporary, "ignored-index") };
|
|
await git(cwd, ["update-index", "-z", "--index-info"], { env: ignoredEnv, signal, input: entries.join("\0") + "\0" });
|
|
return await gitText(cwd, ["write-tree"], { env: ignoredEnv, signal });
|
|
}
|
|
|
|
async function writeIgnoredFiles(cwd: string, commit: string, signal?: AbortSignal) {
|
|
const listing = nulList(await git(cwd, ["ls-tree", "-r", "-z", "--long", `${commit}^{tree}`], { signal }));
|
|
const entries = listing.map((line) => {
|
|
const match = /^(100644|100755|120000) blob ([0-9a-f]{40}) +(\d+)\t(.+)$/s.exec(line);
|
|
if (!match) throw new RestorePointRestoreFailure("invalid_payload");
|
|
const target = path.join(cwd, match[4]!);
|
|
if (path.isAbsolute(match[4]!) || match[4]!.split("/").includes("..") || !target.startsWith(`${cwd}${path.sep}`)) {
|
|
throw new RestorePointRestoreFailure("invalid_payload");
|
|
}
|
|
return { mode: match[1]!, blob: match[2]!, size: Number(match[3]), target };
|
|
});
|
|
for (const entry of entries) {
|
|
if (await fs.lstat(entry.target).then(() => true, () => false)) throw new RestorePointRestoreFailure("ignored_files_conflict");
|
|
}
|
|
const contents = await git(cwd, ["cat-file", "--batch"], { signal, input: entries.map((entry) => entry.blob).join("\n") + "\n",
|
|
maxBuffer: entries.reduce((total, entry) => total + entry.size + 128, 4096) });
|
|
const blobs: Buffer[] = [];
|
|
let offset = 0;
|
|
for (const entry of entries) {
|
|
const header = contents.subarray(offset, contents.indexOf(0x0a, offset)).toString("utf8");
|
|
const [sha, type, size] = header.split(" ");
|
|
if (sha !== entry.blob || type !== "blob" || !/^\d+$/.test(size ?? "")) throw new RestorePointRestoreFailure("invalid_payload");
|
|
const start = offset + header.length + 1;
|
|
blobs.push(contents.subarray(start, start + Number(size)));
|
|
offset = start + Number(size) + 1;
|
|
}
|
|
const written: string[] = [];
|
|
try {
|
|
for (const [index, entry] of entries.entries()) {
|
|
await fs.mkdir(path.dirname(entry.target), { recursive: true });
|
|
const parent = await fs.realpath(path.dirname(entry.target));
|
|
if (parent !== cwd && !parent.startsWith(`${cwd}${path.sep}`)) throw new RestorePointRestoreFailure("invalid_payload");
|
|
if (entry.mode === "120000") await fs.symlink(blobs[index]!, entry.target);
|
|
else await fs.writeFile(entry.target, blobs[index]!, { flag: "wx", mode: entry.mode === "100755" ? 0o755 : 0o644 });
|
|
written.push(entry.target);
|
|
}
|
|
} catch (error) {
|
|
for (const target of written.reverse()) await fs.rm(target, { force: true });
|
|
throw error instanceof RestorePointRestoreFailure ? error : new RestorePointRestoreFailure("ignored_files_conflict");
|
|
}
|
|
}
|
|
|
|
async function snapshotTrees(cwd: string, env: Record<string, string>, temporary: string, signal?: AbortSignal) {
|
|
const indexCopy = path.join(temporary, "index");
|
|
await copyIndex(cwd, indexCopy);
|
|
const indexTree = await gitText(cwd, ["write-tree"], { env: { ...env, GIT_INDEX_FILE: indexCopy }, signal })
|
|
.catch(() => { throw new RestorePointSkip("unmerged_index"); });
|
|
const worktreeIndex = path.join(temporary, "worktree-index");
|
|
const worktreeEnv = { ...env, GIT_INDEX_FILE: worktreeIndex };
|
|
await git(cwd, ["read-tree", "HEAD"], { env: worktreeEnv, signal });
|
|
await git(cwd, ["add", "-u", "--", "."], { env: worktreeEnv, signal });
|
|
await git(cwd, ["add", "-A", "--", ".", ...EXCLUDED_UNTRACKED], { env: worktreeEnv, signal });
|
|
const worktreeTree = await gitText(cwd, ["write-tree"], { env: worktreeEnv, signal });
|
|
const gitlinks = await gitText(cwd, ["ls-tree", "-r", worktreeTree], { env, signal });
|
|
if (/^160000 /m.test(gitlinks)) throw new RestorePointSkip("submodules_unsupported");
|
|
const ignoredTree = await rawIgnoredTree(cwd, env, temporary, await savedIgnoredFiles(cwd, signal), signal);
|
|
return { indexTree, worktreeTree, ignoredTree };
|
|
}
|
|
|
|
async function snapshotCommits(cwd: string, env: Record<string, string>, temporary: string, modes: RestorePointSnapshot["modes"], signal?: AbortSignal): Promise<RestorePointSnapshot> {
|
|
const head = await gitText(cwd, ["rev-parse", "--verify", "HEAD^{commit}"], { signal });
|
|
const headTree = await gitText(cwd, ["rev-parse", "HEAD^{tree}"], { signal });
|
|
const { indexTree, worktreeTree, ignoredTree } = await snapshotTrees(cwd, env, temporary, signal);
|
|
const ignored = ignoredTree
|
|
? await gitText(cwd, ["commit-tree", ignoredTree, "-p", head, "-m", "paperclip restore point ignored files"], { env, signal }) : null;
|
|
if (indexTree === headTree && worktreeTree === headTree) return { head, index: null, worktree: null, ignored, modes: ignored ? modes : [] };
|
|
const index = await gitText(cwd, ["commit-tree", indexTree, "-p", head, "-m", "paperclip restore point index"], { env, signal });
|
|
const worktree = await gitText(cwd, ["commit-tree", worktreeTree, "-p", head, "-p", index, "-m", "paperclip restore point worktree"], { env, signal });
|
|
return { head, index, worktree, ignored, modes };
|
|
}
|
|
|
|
const UNBOUNDED_LIMITS: RestorePointLimits = {
|
|
maxFiles: Number.POSITIVE_INFINITY,
|
|
maxFileBytes: Number.POSITIVE_INFINITY,
|
|
maxTotalBytes: Number.POSITIVE_INFINITY,
|
|
maxCommits: Number.POSITIVE_INFINITY,
|
|
maxBundleBytes: Number.POSITIVE_INFINITY,
|
|
};
|
|
|
|
export async function currentRestorePointSnapshot(input: string, signal?: AbortSignal): Promise<RestorePointSnapshot> {
|
|
const cwd = await fs.realpath(input);
|
|
const { modes } = await scanChangedPaths(cwd, UNBOUNDED_LIMITS, signal);
|
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-restore-point-"));
|
|
try {
|
|
const { env } = await snapshotStore(cwd, temporary);
|
|
return await snapshotCommits(cwd, env, temporary, modes, signal);
|
|
} finally {
|
|
await fs.rm(temporary, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function existingCommits(cwd: string, commits: readonly string[], signal?: AbortSignal) {
|
|
const present: string[] = [];
|
|
for (const commit of commits) {
|
|
if (!SHA.test(commit)) continue;
|
|
if (await git(cwd, ["cat-file", "-e", `${commit}^{commit}`], { signal }).then(() => true, () => false)) present.push(commit);
|
|
}
|
|
return present;
|
|
}
|
|
|
|
async function presentCommits(cwd: string, commits: readonly string[], signal?: AbortSignal) {
|
|
const unique = [...new Set(commits.filter((commit) => SHA.test(commit)))];
|
|
if (unique.length === 0) return [];
|
|
const output = await gitText(cwd, ["cat-file", "--batch-check=%(objectname) %(objecttype)"], { signal, input: unique.join("\n") + "\n" });
|
|
return output.split("\n").map((line) => line.split(" ")).filter(([sha, type]) => SHA.test(sha ?? "") && type === "commit").map(([sha]) => sha!);
|
|
}
|
|
|
|
async function unpreservedHistory(cwd: string, head: string, boundary: readonly string[], signal?: AbortSignal, extra: readonly string[] = []) {
|
|
if (new Set(extra).size > MAX_PRESERVED_COMMITS) throw new RestorePointSkip("restore_point_history_too_large");
|
|
const candidates = new Set<string>(await presentCommits(cwd, extra, signal));
|
|
const reflog = await gitText(cwd, ["reflog", "show", "--format=%H", "HEAD"], { signal }).catch(() => "");
|
|
for (const line of reflog.split("\n")) if (SHA.test(line)) candidates.add(line);
|
|
const original = await gitText(cwd, ["rev-parse", "--verify", "-q", "ORIG_HEAD^{commit}"], { signal }).catch(() => "");
|
|
if (SHA.test(original)) candidates.add(original);
|
|
candidates.delete(head);
|
|
if (candidates.size === 0) return [];
|
|
const unreachable = new Set((await gitText(cwd, ["rev-list", "--stdin"], {
|
|
signal, input: [...candidates, `^${head}`, ...boundary.map((commit) => `^${commit}`)].join("\n") + "\n",
|
|
})).split("\n").filter((line) => SHA.test(line)));
|
|
const preserved = [...candidates].filter((commit) => unreachable.has(commit));
|
|
if (preserved.length > MAX_PRESERVED_COMMITS) throw new RestorePointSkip("restore_point_history_too_large");
|
|
return preserved.sort();
|
|
}
|
|
|
|
function bundlePrerequisites(bundle: Buffer) {
|
|
const headerEnd = bundle.indexOf("\n\n");
|
|
if (headerEnd < 0) throw new RestorePointSkip("invalid_bundle");
|
|
return bundle.subarray(0, headerEnd).toString("utf8").split("\n").slice(1)
|
|
.filter((line) => line.startsWith("-")).map((line) => line.slice(1, 41)).filter((sha) => SHA.test(sha)).sort();
|
|
}
|
|
|
|
function encodePayload(manifest: RestorePointManifest, bundle: Buffer) {
|
|
const manifestBytes = Buffer.from(JSON.stringify(manifest));
|
|
const length = Buffer.alloc(4);
|
|
length.writeUInt32BE(manifestBytes.length);
|
|
return Buffer.concat([PAYLOAD_MAGIC, length, manifestBytes, bundle]);
|
|
}
|
|
|
|
export function decodeRestorePointPayload(payload: Buffer): { manifest: RestorePointManifest; bundle: Buffer } {
|
|
if (payload.length < PAYLOAD_MAGIC.length + 4 || !payload.subarray(0, PAYLOAD_MAGIC.length).equals(PAYLOAD_MAGIC)) {
|
|
throw new RestorePointRestoreFailure("invalid_payload");
|
|
}
|
|
const length = payload.readUInt32BE(PAYLOAD_MAGIC.length);
|
|
const start = PAYLOAD_MAGIC.length + 4;
|
|
if (start + length > payload.length) throw new RestorePointRestoreFailure("invalid_payload");
|
|
const manifest = JSON.parse(payload.subarray(start, start + length).toString("utf8")) as RestorePointManifest;
|
|
const shas = [manifest.head, ...(manifest.index ? [manifest.index] : []), ...(manifest.worktree ? [manifest.worktree] : []),
|
|
...(manifest.ignored !== undefined ? [manifest.ignored] : []), ...manifest.preserved, ...manifest.prerequisites];
|
|
const schemaValid = manifest.schema === RESTORE_POINT_PAYLOAD_SCHEMA ? manifest.ignored === undefined
|
|
: manifest.schema === RESTORE_POINT_PAYLOAD_SCHEMA_V2 && typeof manifest.ignored === "string";
|
|
if (!schemaValid || shas.some((sha) => typeof sha !== "string" || !SHA.test(sha))
|
|
|| (manifest.index === null) !== (manifest.worktree === null)
|
|
|| manifest.modes.some((entry) => typeof entry.path !== "string" || path.isAbsolute(entry.path)
|
|
|| entry.path.split("/").includes("..") || !Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777)) {
|
|
throw new RestorePointRestoreFailure("invalid_payload");
|
|
}
|
|
return { manifest, bundle: payload.subarray(start + length) };
|
|
}
|
|
|
|
export async function captureRestorePoint(input: {
|
|
cwd: string;
|
|
upstreamCommits: readonly string[];
|
|
preserveCommits?: readonly string[];
|
|
limits?: Partial<RestorePointLimits>;
|
|
signal?: AbortSignal;
|
|
}): Promise<{ payload: Buffer; manifest: RestorePointManifest } | null> {
|
|
const limits = { ...DEFAULT_RESTORE_POINT_LIMITS, ...input.limits };
|
|
const cwd = await fs.realpath(input.cwd);
|
|
const { modes } = await scanChangedPaths(cwd, limits, input.signal);
|
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-restore-point-"));
|
|
try {
|
|
const { store, env } = await snapshotStore(cwd, temporary);
|
|
const snapshot = await snapshotCommits(cwd, env, temporary, modes, input.signal);
|
|
const boundary = await existingCommits(cwd, input.upstreamCommits, input.signal);
|
|
const preserved = await unpreservedHistory(cwd, snapshot.head, boundary, input.signal, input.preserveCommits);
|
|
const headOnUpstream = await (async () => {
|
|
for (const tip of boundary) {
|
|
if (await git(cwd, ["merge-base", "--is-ancestor", snapshot.head, tip], { signal: input.signal }).then(() => true, () => false)) return true;
|
|
}
|
|
return false;
|
|
})();
|
|
if (headOnUpstream && snapshot.worktree === null && snapshot.ignored === null && preserved.length === 0) return null;
|
|
const refs: Array<[string, string]> = [["refs/restore/head", snapshot.head]];
|
|
if (snapshot.index && snapshot.worktree) refs.push(["refs/restore/index", snapshot.index], ["refs/restore/worktree", snapshot.worktree]);
|
|
if (snapshot.ignored) refs.push(["refs/restore/ignored", snapshot.ignored]);
|
|
preserved.forEach((commit, index) => refs.push([`refs/restore/preserved/${index}`, commit]));
|
|
for (const [ref, commit] of refs) await git(store, ["update-ref", ref, commit], { env, signal: input.signal });
|
|
const exclusions = boundary.map((commit) => `^${commit}`);
|
|
const commitCount = Number(await gitText(store, ["rev-list", "--count", ...refs.map(([ref]) => ref), ...exclusions], { env, signal: input.signal }));
|
|
if (commitCount > limits.maxCommits) throw new RestorePointSkip("restore_point_too_large");
|
|
if (commitCount === 0) return null;
|
|
const bundlePath = path.join(temporary, "restore.bundle");
|
|
await git(store, ["bundle", "create", "-q", bundlePath, "--stdin"], {
|
|
env, signal: input.signal, input: [...refs.map(([ref]) => ref), ...exclusions].join("\n") + "\n",
|
|
});
|
|
const bundle = await fs.readFile(bundlePath);
|
|
if (bundle.length > limits.maxBundleBytes) throw new RestorePointSkip("restore_point_too_large");
|
|
const prerequisites = bundlePrerequisites(bundle);
|
|
const manifest: RestorePointManifest = {
|
|
schema: snapshot.ignored ? RESTORE_POINT_PAYLOAD_SCHEMA_V2 : RESTORE_POINT_PAYLOAD_SCHEMA,
|
|
head: snapshot.head,
|
|
index: snapshot.index,
|
|
worktree: snapshot.worktree,
|
|
...(snapshot.ignored ? { ignored: snapshot.ignored } : {}),
|
|
preserved,
|
|
prerequisites,
|
|
modes: snapshot.modes,
|
|
};
|
|
return { payload: encodePayload(manifest, bundle), manifest };
|
|
} finally {
|
|
await fs.rm(temporary, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export async function importRestorePoint(input: { repositoryCwd: string; payload: Buffer; signal?: AbortSignal }) {
|
|
const { manifest, bundle } = decodeRestorePointPayload(input.payload);
|
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-restore-point-"));
|
|
try {
|
|
const bundlePath = path.join(temporary, "restore.bundle");
|
|
await fs.writeFile(bundlePath, bundle, { mode: 0o600 });
|
|
await git(input.repositoryCwd, ["bundle", "verify", "-q", bundlePath], { signal: input.signal })
|
|
.catch(() => { throw new RestorePointRestoreFailure("bundle_prerequisites_missing"); });
|
|
const heads = (await gitText(input.repositoryCwd, ["bundle", "list-heads", bundlePath], { signal: input.signal }))
|
|
.split("\n").filter(Boolean).map((line) => line.split(" "));
|
|
const expected = new Map<string, string>([["refs/restore/head", manifest.head]]);
|
|
if (manifest.index && manifest.worktree) {
|
|
expected.set("refs/restore/index", manifest.index);
|
|
expected.set("refs/restore/worktree", manifest.worktree);
|
|
}
|
|
if (manifest.ignored) expected.set("refs/restore/ignored", manifest.ignored);
|
|
manifest.preserved.forEach((commit, index) => expected.set(`refs/restore/preserved/${index}`, commit));
|
|
if (heads.some(([sha, ref]) => expected.get(ref ?? "") !== sha)) throw new RestorePointRestoreFailure("bundle_manifest_mismatch");
|
|
const listed = new Set(heads.map(([, ref]) => ref));
|
|
for (const [ref, commit] of expected) {
|
|
if (listed.has(ref)) continue;
|
|
const present = await git(input.repositoryCwd, ["cat-file", "-e", `${commit}^{commit}`], { signal: input.signal }).then(() => true, () => false);
|
|
if (!present) throw new RestorePointRestoreFailure("bundle_manifest_mismatch");
|
|
}
|
|
if (listed.size > 0) {
|
|
await git(input.repositoryCwd, ["fetch", "-q", "--no-tags", "--no-write-fetch-head", bundlePath,
|
|
...[...listed].map((ref) => `${ref}:refs/paperclip-restore-import/${ref!.slice("refs/restore/".length)}`)], { signal: input.signal });
|
|
}
|
|
await git(input.repositoryCwd, ["for-each-ref", "--format=delete %(refname)", "refs/paperclip-restore-import/"], { signal: input.signal })
|
|
.then((output) => git(input.repositoryCwd, ["update-ref", "--stdin"], { input: output, signal: input.signal }));
|
|
return manifest;
|
|
} finally {
|
|
await fs.rm(temporary, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export async function applyRestorePoint(input: { cwd: string; manifest: RestorePointManifest; signal?: AbortSignal }) {
|
|
const { manifest } = input;
|
|
const cwd = await fs.realpath(input.cwd);
|
|
const head = await gitText(cwd, ["rev-parse", "--verify", "HEAD^{commit}"], { signal: input.signal });
|
|
if (head !== manifest.head) throw new RestorePointRestoreFailure("head_mismatch");
|
|
if (manifest.index && manifest.worktree) {
|
|
const changed = await git(cwd, ["diff", "--quiet", "--no-ext-diff", "--no-textconv", "HEAD", manifest.worktree], { signal: input.signal }).then(() => false, (error: { code?: unknown }) => {
|
|
if (error.code === 1) return true;
|
|
throw error;
|
|
});
|
|
if (changed) await gitPipe(cwd, ["diff", "--binary", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "HEAD", manifest.worktree],
|
|
["apply", "--binary", "--whitespace=nowarn", "-"], input.signal);
|
|
await git(cwd, ["read-tree", `${manifest.index}^{tree}`], { signal: input.signal });
|
|
}
|
|
if (manifest.ignored) await writeIgnoredFiles(cwd, manifest.ignored, input.signal);
|
|
for (const entry of manifest.modes) {
|
|
const target = path.join(cwd, entry.path);
|
|
if (path.relative(cwd, target).startsWith("..")) throw new RestorePointRestoreFailure("invalid_payload");
|
|
const stat = await fs.lstat(target).catch(() => null);
|
|
if (stat?.isFile()) await fs.chmod(target, entry.mode);
|
|
}
|
|
const restored = await currentRestorePointSnapshot(cwd, input.signal);
|
|
if (restored.head !== manifest.head || restored.index !== manifest.index || restored.worktree !== manifest.worktree
|
|
|| restored.ignored !== (manifest.ignored ?? null) || JSON.stringify(restored.modes) !== JSON.stringify(manifest.modes)) {
|
|
throw new RestorePointRestoreFailure("restored_contents_mismatch");
|
|
}
|
|
}
|
|
|
|
function additionalData(identity: RestorePointIdentity) {
|
|
return Buffer.from(JSON.stringify([identity.companyId, identity.workspaceId, identity.restorePointId, identity.generation]));
|
|
}
|
|
|
|
export async function sealRestorePoint(payload: Buffer, identity: RestorePointIdentity): Promise<SealedRestorePoint> {
|
|
const dataKey = randomBytes(32);
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", dataKey, iv);
|
|
cipher.setAAD(additionalData(identity));
|
|
const ciphertext = Buffer.concat([SEALED_MAGIC, iv, cipher.update(payload), cipher.final(), cipher.getAuthTag()]);
|
|
const { material } = await localEncryptedProvider.createVersion({
|
|
value: JSON.stringify({
|
|
schema: KEY_SCHEMA,
|
|
...identity,
|
|
dataKey: dataKey.toString("base64"),
|
|
plaintextSha256: createHash("sha256").update(payload).digest("hex"),
|
|
plaintextBytes: payload.length,
|
|
}),
|
|
} as Parameters<typeof localEncryptedProvider.createVersion>[0]);
|
|
return { ciphertext, sha256: createHash("sha256").update(ciphertext).digest("hex"), key: material };
|
|
}
|
|
|
|
export async function openRestorePoint(input: {
|
|
ciphertext: Buffer;
|
|
sha256: string;
|
|
key: StoredSecretVersionMaterial;
|
|
identity: RestorePointIdentity;
|
|
}): Promise<Buffer> {
|
|
if (createHash("sha256").update(input.ciphertext).digest("hex") !== input.sha256) {
|
|
throw new RestorePointRestoreFailure("ciphertext_hash_mismatch");
|
|
}
|
|
const minimum = SEALED_MAGIC.length + 12 + 16;
|
|
if (input.ciphertext.length < minimum || !input.ciphertext.subarray(0, SEALED_MAGIC.length).equals(SEALED_MAGIC)) {
|
|
throw new RestorePointRestoreFailure("invalid_ciphertext");
|
|
}
|
|
const unwrapped = JSON.parse(await localEncryptedProvider.resolveVersion({ material: input.key, externalRef: null } as Parameters<typeof localEncryptedProvider.resolveVersion>[0]));
|
|
const { identity } = input;
|
|
if (unwrapped.schema !== KEY_SCHEMA || unwrapped.companyId !== identity.companyId || unwrapped.workspaceId !== identity.workspaceId
|
|
|| unwrapped.restorePointId !== identity.restorePointId || unwrapped.generation !== identity.generation) {
|
|
throw new RestorePointRestoreFailure("key_identity_mismatch");
|
|
}
|
|
const iv = input.ciphertext.subarray(SEALED_MAGIC.length, SEALED_MAGIC.length + 12);
|
|
const tag = input.ciphertext.subarray(input.ciphertext.length - 16);
|
|
const body = input.ciphertext.subarray(SEALED_MAGIC.length + 12, input.ciphertext.length - 16);
|
|
const decipher = createDecipheriv("aes-256-gcm", Buffer.from(unwrapped.dataKey, "base64"), iv);
|
|
decipher.setAAD(additionalData(identity));
|
|
decipher.setAuthTag(tag);
|
|
let payload: Buffer;
|
|
try {
|
|
payload = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
} catch {
|
|
throw new RestorePointRestoreFailure("ciphertext_authentication_failed");
|
|
}
|
|
if (payload.length !== unwrapped.plaintextBytes || createHash("sha256").update(payload).digest("hex") !== unwrapped.plaintextSha256) {
|
|
throw new RestorePointRestoreFailure("plaintext_hash_mismatch");
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
export async function restorePointHistoryDigest(input: string, signal?: AbortSignal): Promise<string> {
|
|
const cwd = await fs.realpath(input);
|
|
const reflog = await gitText(cwd, ["reflog", "show", "--format=%H", "HEAD"], { signal }).catch(() => "");
|
|
const original = await gitText(cwd, ["rev-parse", "--verify", "-q", "ORIG_HEAD^{commit}"], { signal }).catch(() => "");
|
|
const remotes = await gitText(cwd, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/remotes"], { signal }).catch(() => "");
|
|
const remoteReflogs = (await gitText(cwd, ["reflog", "show", "--all", "--format=%H %gD"], { signal }).catch(() => ""))
|
|
.split("\n").filter((line) => / refs\/remotes\//.test(line));
|
|
return createHash("sha256").update(JSON.stringify([reflog.split("\n").filter((line) => SHA.test(line)), SHA.test(original) ? original : null,
|
|
remotes.split("\n").filter(Boolean), remoteReflogs])).digest("hex");
|
|
}
|