diff --git a/__tests__/symlink-security.spec.ts b/__tests__/symlink-security.spec.ts index 955372e..7c42ca0 100644 --- a/__tests__/symlink-security.spec.ts +++ b/__tests__/symlink-security.spec.ts @@ -63,4 +63,33 @@ describe('symlink security', () => { await expect(safeWriteFile(filePath, '# hacked')) .rejects.toThrow(/ELOOP|ENOENT/); }); + + test('safeWriteFile preserves original file mode', async () => { + const filePath = path.join(tmpDir, 'mode-test.md'); + fs.writeFileSync(filePath, '# old'); + fs.chmodSync(filePath, 0o666); + + const oldUmask = process.umask(0o077); + try { + await safeWriteFile(filePath, '# new'); + } + finally { + process.umask(oldUmask); + } + + const mode = fs.statSync(filePath).mode & 0o777; + expect(mode).toBe(0o666); + expect(fs.readFileSync(filePath, 'utf8')).toBe('# new'); + }); + + test('safeWriteFile leaves no temp file residue on success', async () => { + const filePath = path.join(tmpDir, 'residue.md'); + fs.writeFileSync(filePath, '# old'); + + await safeWriteFile(filePath, '# new'); + + const entries = fs.readdirSync(tmpDir); + const tmpFiles = entries.filter(e => e.startsWith('.lint-md-') && e.endsWith('.tmp')); + expect(tmpFiles).toHaveLength(0); + }); }); diff --git a/src/utils/safe-write-file.ts b/src/utils/safe-write-file.ts index 73d30f0..fbbfacd 100644 --- a/src/utils/safe-write-file.ts +++ b/src/utils/safe-write-file.ts @@ -1,23 +1,42 @@ import { constants } from 'fs'; -import { open } from 'fs/promises'; +import { chmod, open, rename, unlink, writeFile } from 'fs/promises'; +import { randomBytes } from 'crypto'; +import { dirname, join } from 'path'; /** * 安全写入文件,拒绝符号链接 * - * 使用 O_NOFOLLOW 防止最终路径是符号链接, - * 打开后通过 fstat 验证句柄指向普通文件,解决 TOCTOU 竞态。 + * 使用 O_NOFOLLOW 验证目标不是符号链接, + * 再通过临时文件 + rename 实现原子替换。 */ export async function safeWriteFile(filePath: string, content: string) { - const handle = await open(filePath, constants.O_WRONLY | constants.O_NOFOLLOW); + const tempPath = join( + dirname(filePath), + `.lint-md-${process.pid}-${randomBytes(8).toString('hex')}.tmp` + ); + + const target = await open( + filePath, + constants.O_WRONLY | constants.O_NOFOLLOW + ); + try { - const stat = await handle.stat(); + const stat = await target.stat(); if (!stat.isFile()) { throw new Error(`Refusing to write non-regular file: ${filePath}`); } - await handle.truncate(0); - await handle.writeFile(content, 'utf8'); + + const mode = stat.mode & 0o777; + + await writeFile(tempPath, content, { flag: 'wx', mode }); + await chmod(tempPath, mode); + await rename(tempPath, filePath); } finally { - await handle.close(); + await target.close(); + try { + await unlink(tempPath); + } + catch {} } }