Skip to content
Closed
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
29 changes: 29 additions & 0 deletions __tests__/symlink-security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
35 changes: 27 additions & 8 deletions src/utils/safe-write-file.ts
Original file line number Diff line number Diff line change
@@ -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 {}
}
}