fix(backups): skip symlinks in snapshots

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-14 07:20:06 +00:00
1 parent b2e9f2e44b
commit a17a1281cc
2 files changed
+51 -7

No files matched your search

@@ -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<string, Buffer>();
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);
+19 -7
View File
@@ -86,30 +86,42 @@ async function expandPattern(pattern: string): Promise<string[]> {
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<void> {
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 = {