diff --git a/server/src/__tests__/instance-state-snapshot.test.ts b/server/src/__tests__/instance-state-snapshot.test.ts index 29c8e70d06f..ffe42c8b745 100644 --- a/server/src/__tests__/instance-state-snapshot.test.ts +++ b/server/src/__tests__/instance-state-snapshot.test.ts @@ -81,6 +81,38 @@ describe("instance state snapshots", () => { await expect(fs.lstat(path.join(managedDir, "linked-secret.txt"))).rejects.toMatchObject({ code: "ENOENT" }); }); + it("skips manifest paths with symlinked ancestors", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-symlink-ancestor-snapshot-test-")); + tempDirs.push(homeDir); + const managedDir = path.join(homeDir, "instances", "test", "managed"); + const externalDir = path.join(homeDir, "external"); + const linkedDir = path.join(managedDir, "linked"); + await fs.mkdir(managedDir, { recursive: true }); + await fs.mkdir(externalDir, { recursive: true }); + await fs.writeFile(path.join(externalDir, "secret.txt"), "secret"); + await fs.symlink(externalDir, linkedDir); + 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, 12)), + context: { homeDir, instanceId: "test" }, + manifest: [{ id: "managed", resolve: () => [path.join(linkedDir, "secret.txt")], 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); + + 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 5d67836d90c..0e3f08bcf27 100644 --- a/server/src/services/instance-state-snapshot.ts +++ b/server/src/services/instance-state-snapshot.ts @@ -61,6 +61,14 @@ async function exists(target: string): Promise { try { await fs.access(target); return true; } catch { return false; } } +async function isNonSymlink(target: string): Promise { + try { + return !(await fs.lstat(target)).isSymbolicLink(); + } catch { + return false; + } +} + async function expandPattern(pattern: string): Promise { const parsed = path.parse(pattern); const segments = pattern.slice(parsed.root.length).split(path.sep); @@ -80,7 +88,11 @@ async function expandPattern(pattern: string): Promise { continue; } if (!segment.includes("*")) { - current = current.map((base) => path.join(base, segment)); + const next = current.map((base) => path.join(base, segment)); + current = []; + for (const candidate of next) { + if (await isNonSymlink(candidate)) current.push(candidate); + } continue; } const next: string[] = [];