mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix(backups): reject symlinked snapshot ancestors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a17a1281cc
commit
37dcfe48a4
2 files changed
+45
-1
No files matched your search
@@ -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<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, 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);
|
||||
|
||||
@@ -61,6 +61,14 @@ async function exists(target: string): Promise<boolean> {
|
||||
try { await fs.access(target); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function isNonSymlink(target: string): Promise<boolean> {
|
||||
try {
|
||||
return !(await fs.lstat(target)).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function expandPattern(pattern: string): Promise<string[]> {
|
||||
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<string[]> {
|
||||
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[] = [];
|
||||
|
||||
Reference in new issue
Block a user