From e001e2a8bdca63eb034b1357457bf8035ae6b63a Mon Sep 17 00:00:00 2001 From: aiweko <32951430+aiweiguo@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:24:21 +0800 Subject: [PATCH] fix: fallback when config backup is not writable (#1708) --- .../server/src/services/safe-file-store.ts | 12 +++- .../safe-file-store-backup-fallback.test.ts | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/server/safe-file-store-backup-fallback.test.ts diff --git a/packages/server/src/services/safe-file-store.ts b/packages/server/src/services/safe-file-store.ts index 825e8087..4ff74ad6 100644 --- a/packages/server/src/services/safe-file-store.ts +++ b/packages/server/src/services/safe-file-store.ts @@ -24,6 +24,10 @@ function isYamlUpdateResult(value: unknown): value is YamlUpdateResult { return !!value && typeof value === 'object' && Object.hasOwn(value as Record, 'data') } +function shouldUseFallbackBackup(err: any): boolean { + return ['EACCES', 'EPERM', 'EEXIST'].includes(err?.code) +} + export class SafeFileStore { private queues = new Map>() @@ -127,7 +131,13 @@ export class SafeFileStore { try { await copyFile(target, options.backupPath || `${target}.bak`) } catch (err: any) { - if (err?.code !== 'ENOENT') throw err + if (err?.code === 'ENOENT') { + // Continue without a backup when the target does not exist yet. + } else if (!options.backupPath && shouldUseFallbackBackup(err)) { + await copyFile(target, `${target}.bak.${Date.now()}.${randomUUID()}`) + } else { + throw err + } } } diff --git a/tests/server/safe-file-store-backup-fallback.test.ts b/tests/server/safe-file-store-backup-fallback.test.ts new file mode 100644 index 00000000..030885de --- /dev/null +++ b/tests/server/safe-file-store-backup-fallback.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { + mockCopyFile, + mockMkdir, + mockReadFile, + mockRename, + mockRm, + mockWriteFile, +} = vi.hoisted(() => ({ + mockCopyFile: vi.fn(), + mockMkdir: vi.fn(), + mockReadFile: vi.fn(), + mockRename: vi.fn(), + mockRm: vi.fn(), + mockWriteFile: vi.fn(), +})) + +vi.mock('fs/promises', () => ({ + copyFile: mockCopyFile, + mkdir: mockMkdir, + readFile: mockReadFile, + rename: mockRename, + rm: mockRm, + writeFile: mockWriteFile, +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('SafeFileStore backup fallback', () => { + it('uses a timestamped backup when the default backup cannot be overwritten', async () => { + mockCopyFile + .mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code: 'EACCES' })) + .mockResolvedValueOnce(undefined) + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + + const { SafeFileStore } = await import('../../packages/server/src/services/safe-file-store') + const store = new SafeFileStore() + + await store.writeText('/tmp/config.yaml', 'model:\n default: new\n', { backup: true }) + + expect(mockCopyFile).toHaveBeenCalledTimes(2) + expect(mockCopyFile).toHaveBeenNthCalledWith(1, '/tmp/config.yaml', '/tmp/config.yaml.bak') + expect(mockCopyFile.mock.calls[1][0]).toBe('/tmp/config.yaml') + expect(mockCopyFile.mock.calls[1][1]).toMatch(/^\/tmp\/config\.yaml\.bak\.\d+\./) + expect(mockWriteFile).toHaveBeenCalledWith(expect.stringContaining('/tmp/config.yaml.tmp.'), 'model:\n default: new\n', 'utf-8') + expect(mockRename).toHaveBeenCalledWith(expect.stringContaining('/tmp/config.yaml.tmp.'), '/tmp/config.yaml') + }) + + it('preserves explicit backup path failures', async () => { + mockCopyFile.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code: 'EACCES' })) + mockMkdir.mockResolvedValue(undefined) + + const { SafeFileStore } = await import('../../packages/server/src/services/safe-file-store') + const store = new SafeFileStore() + + await expect(store.writeText('/tmp/config.yaml', 'new', { backup: true, backupPath: '/tmp/custom.bak' })).rejects.toThrow('permission denied') + expect(mockCopyFile).toHaveBeenCalledTimes(1) + expect(mockWriteFile).not.toHaveBeenCalled() + }) +})