Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions __tests__/symlink-security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
42 changes: 36 additions & 6 deletions src/utils/safe-write-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down