diff --git a/__tests__/symlink-security.spec.ts b/__tests__/symlink-security.spec.ts index 955372e..962e788 100644 --- a/__tests__/symlink-security.spec.ts +++ b/__tests__/symlink-security.spec.ts @@ -63,4 +63,25 @@ 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); + + await safeWriteFile(filePath, '# new'); + + const mode = fs.statSync(filePath).mode & 0o777; + expect(mode).toBe(0o666); + expect(fs.readFileSync(filePath, 'utf8')).toBe('# new'); + }); + + test('safeWriteFile truncates old tail when new content is shorter', async () => { + const filePath = path.join(tmpDir, 'shorter.md'); + fs.writeFileSync(filePath, '# old content with tail'); + + await safeWriteFile(filePath, '# new'); + + expect(fs.readFileSync(filePath, 'utf8')).toBe('# new'); + }); }); diff --git a/src/utils/safe-write-file.ts b/src/utils/safe-write-file.ts index 73d30f0..3c508d6 100644 --- a/src/utils/safe-write-file.ts +++ b/src/utils/safe-write-file.ts @@ -2,20 +2,50 @@ import { constants } from 'fs'; import { open } from 'fs/promises'; /** - * 安全写入文件,拒绝符号链接 + * 安全写入文件,拒绝符号链接。 * - * 使用 O_NOFOLLOW 防止最终路径是符号链接, - * 打开后通过 fstat 验证句柄指向普通文件,解决 TOCTOU 竞态。 + * 保留 in-place update 语义,避免 temp-file + rename 改变文件身份、 + * inode、hard link、birthtime、owner、ACL、xattr 等属性。 + * + * 写入顺序为 write-then-truncate: + * 先从 offset 0 写入完整新内容,再截断到新长度,避免 truncate(0) + * 在写入失败时把文件直接变成空文件。 + * + * 注意:这不是完全原子写入;崩溃时仍可能出现部分新内容或旧尾部残留。 */ export async function safeWriteFile(filePath: string, content: string) { - const handle = await open(filePath, constants.O_WRONLY | constants.O_NOFOLLOW); + const handle = await open( + filePath, + constants.O_RDWR | constants.O_NOFOLLOW + ); + try { const stat = await handle.stat(); + if (!stat.isFile()) { throw new Error(`Refusing to write non-regular file: ${filePath}`); } - await handle.truncate(0); - await handle.writeFile(content, 'utf8'); + + const buf = Buffer.from(content, 'utf8'); + + let written = 0; + while (written < buf.length) { + const { bytesWritten } = await handle.write( + buf, + written, + buf.length - written, + written + ); + + if (bytesWritten === 0) { + throw new Error(`Failed to write file: ${filePath}`); + } + + written += bytesWritten; + } + + await handle.truncate(buf.length); + await handle.sync(); } finally { await handle.close();