fix(filesystem): preserve file permissions during write and edit operations (#4115)
* fix(filesystem): preserve file permissions during write and edit operations The atomic write pattern (write temp file + rename) replaces the original inode, causing the new file to have default 0644 permissions regardless of what the original file had. This breaks executable scripts and other files with non-default permissions. Fix: capture stat.mode before writing and restore it with chmod after rename. Fixes both writeFileContent() and applyFileEdits(). * fix(filesystem): mask chmod mode and keep chmod failure from failing the write Pass origStats.mode & 0o777 to chmod instead of the full st_mode. Move the chmod out of the try whose catch unlinks the temp file and rethrows, since rename has already succeeded by then. Add a test that an EPERM from chmod does not reject the write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Wo28CHPXyM3DHoLWvKnCK --------- Co-authored-by: Dustin Rudiger <drudiger@apple.com> Co-authored-by: olaservo <olahungerford@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
71e3cfbd1e
commit
562feeb281
1 changed files with 69 additions and 0 deletions
|
|
@ -310,6 +310,33 @@ describe('Lib Functions', () => {
|
|||
|
||||
expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' });
|
||||
});
|
||||
|
||||
it('preserves file permissions when overwriting existing file', async () => {
|
||||
// First writeFile call with 'wx' flag fails because file exists
|
||||
mockFs.writeFile.mockRejectedValueOnce(Object.assign(new Error('EEXIST'), { code: 'EEXIST' }));
|
||||
// stat returns executable permissions
|
||||
mockFs.stat.mockResolvedValueOnce({ mode: 0o100755 });
|
||||
// Second writeFile (to temp) succeeds
|
||||
mockFs.writeFile.mockResolvedValueOnce(undefined);
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
mockFs.chmod.mockResolvedValueOnce(undefined);
|
||||
|
||||
await writeFileContent('/test/script.sh', 'new content');
|
||||
|
||||
expect(mockFs.stat).toHaveBeenCalledWith('/test/script.sh');
|
||||
expect(mockFs.chmod).toHaveBeenCalledWith('/test/script.sh', 0o755);
|
||||
});
|
||||
|
||||
it('does not fail the write when chmod fails', async () => {
|
||||
mockFs.writeFile.mockRejectedValueOnce(Object.assign(new Error('EEXIST'), { code: 'EEXIST' }));
|
||||
mockFs.stat.mockResolvedValueOnce({ mode: 0o100755 });
|
||||
mockFs.writeFile.mockResolvedValueOnce(undefined);
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
mockFs.chmod.mockRejectedValueOnce(Object.assign(new Error('EPERM'), { code: 'EPERM' }));
|
||||
|
||||
await expect(writeFileContent('/test/script.sh', 'new content')).resolves.toBeUndefined();
|
||||
expect(mockFs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -418,6 +445,8 @@ describe('Lib Functions', () => {
|
|||
beforeEach(() => {
|
||||
mockFs.readFile.mockResolvedValue('line1\nline2\nline3\n');
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.stat.mockResolvedValue({ mode: 0o100644 });
|
||||
mockFs.chmod.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('applies simple text replacement', async () => {
|
||||
|
|
@ -512,6 +541,30 @@ describe('Lib Functions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('preserves file permissions after applying edits', async () => {
|
||||
mockFs.stat.mockResolvedValue({ mode: 0o100755 });
|
||||
const edits = [
|
||||
{ oldText: 'line2', newText: 'modified line2' }
|
||||
];
|
||||
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
|
||||
await applyFileEdits('/test/script.sh', edits, false);
|
||||
|
||||
expect(mockFs.stat).toHaveBeenCalledWith('/test/script.sh');
|
||||
expect(mockFs.chmod).toHaveBeenCalledWith('/test/script.sh', 0o755);
|
||||
});
|
||||
|
||||
it('does not restore permissions in dry run mode', async () => {
|
||||
const edits = [
|
||||
{ oldText: 'line2', newText: 'modified line2' }
|
||||
];
|
||||
|
||||
await applyFileEdits('/test/file.txt', edits, true);
|
||||
|
||||
expect(mockFs.chmod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws error for non-matching edits', async () => {
|
||||
const edits = [
|
||||
{ oldText: 'nonexistent line', newText: 'replacement' }
|
||||
|
|
|
|||
Loading…
Reference in a new issue