mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix(backups): skip symlinks in snapshots
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user