From a17a1281cc94fabf0c6c19e6ba9357744a1375ac Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 27 Jul 2026 15:48:47 +0000 Subject: [PATCH] fix(backups): skip symlinks in snapshots Co-Authored-By: Paperclip --- .../__tests__/instance-state-snapshot.test.ts | 32 +++++++++++++++++++ .../src/services/instance-state-snapshot.ts | 26 +++++++++++---- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/server/src/__tests__/instance-state-snapshot.test.ts b/server/src/__tests__/instance-state-snapshot.test.ts index 5326dd7688d..29c8e70d06f 100644 --- a/server/src/__tests__/instance-state-snapshot.test.ts +++ b/server/src/__tests__/instance-state-snapshot.test.ts @@ -49,6 +49,38 @@ describe("instance state snapshots", () => { expect(JSON.parse(await fs.readFile(path.join(homeDir, "markers", "state-snapshot.success.json"), "utf8"))).toMatchObject({ objectKey: result.objectKey }); }); + it("skips symlinks instead of snapshotting files outside managed roots", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-symlink-snapshot-test-")); + tempDirs.push(homeDir); + const managedDir = path.join(homeDir, "instances", "test", "managed"); + const externalFile = path.join(homeDir, "external-secret.txt"); + await fs.mkdir(managedDir, { recursive: true }); + await fs.writeFile(path.join(managedDir, "safe.txt"), "safe"); + await fs.writeFile(externalFile, "secret"); + await fs.symlink(externalFile, path.join(managedDir, "linked-secret.txt")); + const objects = new Map(); + const provider: StorageProvider = { + id: "local_disk", + async putObject(input) { const chunks: Buffer[] = []; if (Buffer.isBuffer(input.body)) chunks.push(input.body); else for await (const chunk of input.body) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); objects.set(input.objectKey, Buffer.concat(chunks)); }, + async getObject(input) { const body = objects.get(input.objectKey); if (!body) throw new Error("missing"); return { stream: Readable.from(body), contentLength: body.length }; }, + async headObject(input) { const body = objects.get(input.objectKey); return { exists: Boolean(body), contentLength: body?.length }; }, + async deleteObject(input) { objects.delete(input.objectKey); }, + }; + const service = createInstanceStateSnapshotService({ + storageProvider: provider, + encryptionProvider: createAesStateSnapshotEncryptionProvider(Buffer.alloc(32, 11)), + context: { homeDir, instanceId: "test" }, + manifest: [{ id: "managed", resolve: () => [managedDir], disposition: "s3_secret", redact: "forbid", consistency: "plain" }], + }); + + const result = await service.runSnapshot(); + await fs.rm(managedDir, { recursive: true, force: true }); + await service.restoreSnapshot(result.objectKey); + + expect(await fs.readFile(path.join(managedDir, "safe.txt"), "utf8")).toBe("safe"); + await expect(fs.lstat(path.join(managedDir, "linked-secret.txt"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("stores nested transcript glob matches in a retention-specific object", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-retention-test-")); tempDirs.push(homeDir); diff --git a/server/src/services/instance-state-snapshot.ts b/server/src/services/instance-state-snapshot.ts index ae3579cc3e6..5d67836d90c 100644 --- a/server/src/services/instance-state-snapshot.ts +++ b/server/src/services/instance-state-snapshot.ts @@ -86,30 +86,42 @@ async function expandPattern(pattern: string): Promise { const next: string[] = []; for (const base of current) { if (!(await exists(base))) continue; - const names = await fs.readdir(base); + const entries = await fs.readdir(base, { withFileTypes: true }); const regex = new RegExp(`^${segment.replace(/\./g, "\\.").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*")}$`); - for (const name of names) if (regex.test(name)) next.push(path.join(base, name)); + for (const entry of entries) { + if (!entry.isSymbolicLink() && regex.test(entry.name)) next.push(path.join(base, entry.name)); + } } current = next; } const found: string[] = []; - for (const candidate of current) if (await exists(candidate)) found.push(candidate); + for (const candidate of current) { + try { + if (!(await fs.lstat(candidate)).isSymbolicLink()) found.push(candidate); + } catch { + // Pattern candidates may disappear between expansion and collection. + } + } return found; } async function copyEntry(entry: StateClassEntry, source: string, destination: string): Promise { - const stat = await fs.stat(source); + const stat = await fs.lstat(source); + if (stat.isSymbolicLink()) return; await fs.mkdir(path.dirname(destination), { recursive: true }); - if (entry.consistency === "sqlite_backup" && stat.isDirectory()) { + if (stat.isDirectory()) { await fs.mkdir(destination, { recursive: true }); - for (const child of await fs.readdir(source)) await copyEntry(entry, path.join(source, child), path.join(destination, child)); + for (const child of await fs.readdir(source)) { + await copyEntry(entry, path.join(source, child), path.join(destination, child)); + } return; } if (entry.consistency === "sqlite_backup" && stat.isFile() && /\.(sqlite|sqlite3|db)$/i.test(source)) { await execFileAsync("sqlite3", [source, `.backup '${destination.replace(/'/g, "''")}'`]); return; } - await fs.cp(source, destination, { recursive: true, preserveTimestamps: true }); + await fs.copyFile(source, destination); + await fs.utimes(destination, stat.atime, stat.mtime); } export type InstanceStateSnapshotResult = {