From 3a49a147488a30a5cb08b1c1ab52b863f2937188 Mon Sep 17 00:00:00 2001 From: han <2992336417@qq.com> Date: Sun, 24 May 2026 19:59:25 +0800 Subject: [PATCH 1/2] fix: handle cross-device config writes --- src/config/loader.ts | 28 +++++++++++++- test/config/loader.test.ts | 76 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/config/loader.ts b/src/config/loader.ts index b3d7cdfc..13ec5e21 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, renameSync, existsSync } from 'fs'; +import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'; import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from './schema'; import { ensureConfigDir, getConfigPath } from './paths'; import { detectOutputFormat, type OutputFormat } from '../output/formatter'; @@ -6,6 +6,30 @@ import { CLIError } from '../errors/base'; import { ExitCode } from '../errors/codes'; import type { GlobalFlags } from '../types/flags'; +interface RenameWithFallbackOps { + rename: (from: string, to: string) => void; + copy: (from: string, to: string) => void; + unlink: (path: string) => void; +} + +export function renameWithCrossDeviceFallback( + from: string, + to: string, + ops: RenameWithFallbackOps = { + rename: renameSync, + copy: copyFileSync, + unlink: unlinkSync, + }, +): void { + try { + ops.rename(from, to); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err; + ops.copy(from, to); + ops.unlink(from); + } +} + export function readConfigFile(): ConfigFile { const path = getConfigPath(); if (!existsSync(path)) return {}; @@ -25,7 +49,7 @@ export async function writeConfigFile(data: Record): Promise { expect(config.baseUrl).toBe('https://api.minimaxi.com'); }); }); + +describe('writeConfigFile', () => { + const testDir = join(tmpdir(), `mmx-config-write-test-${Date.now()}`); + const originalConfigDir = process.env.MMX_CONFIG_DIR; + + beforeEach(() => { + process.env.MMX_CONFIG_DIR = testDir; + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (originalConfigDir === undefined) delete process.env.MMX_CONFIG_DIR; + else process.env.MMX_CONFIG_DIR = originalConfigDir; + rmSync(testDir, { recursive: true, force: true }); + mock.restore(); + }); + + it('falls back to copy and unlink when rename crosses devices', async () => { + const calls: string[] = []; + const renameMock = mock((from: string, _to: string) => { + calls.push(`rename:${from}`); + const err = new Error('cross-device link not permitted') as NodeJS.ErrnoException; + err.code = 'EXDEV'; + err.path = from; + throw err; + }); + const copyMock = mock((from: string, _to: string) => { + calls.push(`copy:${from}`); + }); + const unlinkMock = mock((path: string) => { + calls.push(`unlink:${path}`); + }); + + renameWithCrossDeviceFallback('config.json.tmp', 'config.json', { + rename: renameMock, + copy: copyMock, + unlink: unlinkMock, + }); + + expect(renameMock).toHaveBeenCalledTimes(1); + expect(copyMock).toHaveBeenCalledTimes(1); + expect(unlinkMock).toHaveBeenCalledTimes(1); + expect(calls).toEqual([ + 'rename:config.json.tmp', + 'copy:config.json.tmp', + 'unlink:config.json.tmp', + ]); + }); + + it('rethrows non-EXDEV rename errors', () => { + const renameMock = mock(() => { + const err = new Error('permission denied') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + }); + + expect(() => renameWithCrossDeviceFallback('config.json.tmp', 'config.json', { + rename: renameMock, + copy: mock(() => {}), + unlink: mock(() => {}), + })).toThrow('permission denied'); + }); + + it('uses atomic rename on the normal path', async () => { + await writeConfigFile({ output: 'json' }); + + const configPath = join(testDir, 'config.json'); + expect(JSON.parse(readFileSync(configPath, 'utf-8')).output).toBe('json'); + }); +}); From 8de250248dc7aa59c87611c7452c53704c60b71a Mon Sep 17 00:00:00 2001 From: han <2992336417@qq.com> Date: Tue, 2 Jun 2026 19:58:12 +0800 Subject: [PATCH 2/2] test: cover EXDEV config write fallback --- src/config/loader.ts | 15 ++++++++++++--- test/config/loader.test.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/config/loader.ts b/src/config/loader.ts index 13ec5e21..77680441 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -12,6 +12,12 @@ interface RenameWithFallbackOps { unlink: (path: string) => void; } +function isCrossDeviceError(err: unknown): err is Error & { code: 'EXDEV' } { + return err instanceof Error + && 'code' in err + && (err as { code?: string }).code === 'EXDEV'; +} + export function renameWithCrossDeviceFallback( from: string, to: string, @@ -24,7 +30,7 @@ export function renameWithCrossDeviceFallback( try { ops.rename(from, to); } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err; + if (!isCrossDeviceError(err)) throw err; ops.copy(from, to); ops.unlink(from); } @@ -44,12 +50,15 @@ export function readConfigFile(): ConfigFile { } } -export async function writeConfigFile(data: Record): Promise { +export async function writeConfigFile( + data: Record, + renameOps?: RenameWithFallbackOps, +): Promise { await ensureConfigDir(); const path = getConfigPath(); const tmp = path + '.tmp'; writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); - renameWithCrossDeviceFallback(tmp, path); + renameWithCrossDeviceFallback(tmp, path, renameOps); } export function loadConfig(flags: GlobalFlags): Config { diff --git a/test/config/loader.test.ts b/test/config/loader.test.ts index fbc8969e..1fcd8158 100644 --- a/test/config/loader.test.ts +++ b/test/config/loader.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; -import { mkdirSync, readFileSync, rmSync } from 'fs'; +import { copyFileSync, mkdirSync, readFileSync, rmSync, unlinkSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { loadConfig, renameWithCrossDeviceFallback, writeConfigFile } from '../../src/config/loader'; @@ -106,6 +106,35 @@ describe('writeConfigFile', () => { ]); }); + it('writes the config file when the final rename crosses devices', async () => { + const renameMock = mock((from: string, _to: string) => { + const err = new Error('cross-device link not permitted') as Error & { + code?: string; + path?: string; + }; + err.code = 'EXDEV'; + err.path = from; + throw err; + }); + const copyMock = mock((from: string, to: string) => copyFileSync(from, to)); + const unlinkMock = mock((path: string) => unlinkSync(path)); + + await writeConfigFile({ region: 'cn', output: 'json' }, { + rename: renameMock, + copy: copyMock, + unlink: unlinkMock, + }); + + const configPath = join(testDir, 'config.json'); + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')); + + expect(renameMock).toHaveBeenCalledTimes(1); + expect(copyMock).toHaveBeenCalledTimes(1); + expect(unlinkMock).toHaveBeenCalledTimes(1); + expect(parsed.region).toBe('cn'); + expect(parsed.output).toBe('json'); + }); + it('rethrows non-EXDEV rename errors', () => { const renameMock = mock(() => { const err = new Error('permission denied') as NodeJS.ErrnoException;