fix(backups): prevent recursion in path that is backed up

This commit is contained in:
jubnl
2026-06-29 12:19:24 +02:00
parent 0631e34a79
commit 23b9be64de
2 changed files with 46 additions and 2 deletions
+13 -1
View File
@@ -184,9 +184,21 @@ export async function createBackup(): Promise<BackupInfo> {
// Exclude the place-photo and trek-memory caches: both are re-derivable
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate
// backup size. Restores self-heal — the cache dirs are recreated at startup.
//
// Also exclude backups/ and restore-*/: these live under data/, not uploads/,
// but when an install maps data and uploads to the SAME directory (a
// misconfiguration, but a catastrophic one) the glob would otherwise sweep
// every prior backup zip into the new archive — each run embedding all
// previous runs, so size compounds without bound (see issue #1358). Ignoring
// them keeps the backup bounded regardless of how the volumes are mounted.
archive.glob(
'**/*',
{ cwd: uploadsDir, ignore: ['photos/google/**', 'photos/trek/**'], nodir: true, dot: true },
{
cwd: uploadsDir,
ignore: ['photos/google/**', 'photos/trek/**', 'backups/**', 'restore-*/**'],
nodir: true,
dot: true,
},
{ prefix: 'uploads' },
);
}
@@ -475,7 +475,7 @@ describe('BACKUP-036 createBackup', () => {
'**/*',
expect.objectContaining({
cwd: expect.stringContaining('uploads'),
ignore: ['photos/google/**', 'photos/trek/**'],
ignore: ['photos/google/**', 'photos/trek/**', 'backups/**', 'restore-*/**'],
}),
{ prefix: 'uploads' },
);
@@ -483,6 +483,38 @@ describe('BACKUP-036 createBackup', () => {
expect(archiverInstanceMock.directory).not.toHaveBeenCalled();
});
it('BACKUP-036h — never sweeps backups/ or restore-* into the archive (issue #1358)', async () => {
// Regression guard: when data and uploads map to the same directory, the
// uploads glob would otherwise pick up the backups/ dir and recursively
// embed every prior backup zip, compounding size without bound.
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('uploads'));
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
await createBackup();
const globCall = archiverInstanceMock.glob.mock.calls.at(-1);
const ignore: string[] = globCall?.[1]?.ignore ?? [];
expect(ignore).toContain('backups/**');
expect(ignore).toContain('restore-*/**');
});
it('BACKUP-036f — bundles .encryption_key when present and ENCRYPTION_KEY env is unset', async () => {
const prevEnvKey = process.env.ENCRYPTION_KEY;
delete process.env.ENCRYPTION_KEY;