diff --git a/server/src/services/backupService.ts b/server/src/services/backupService.ts index de3731c2..79a6befa 100644 --- a/server/src/services/backupService.ts +++ b/server/src/services/backupService.ts @@ -184,9 +184,21 @@ export async function createBackup(): Promise { // 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' }, ); } diff --git a/server/tests/unit/services/backupService.test.ts b/server/tests/unit/services/backupService.test.ts index 9fdca6f8..a23c9cf3 100644 --- a/server/tests/unit/services/backupService.test.ts +++ b/server/tests/unit/services/backupService.test.ts @@ -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 = {}; + 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;