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
|
|
@ -168,6 +168,7 @@ export async function writeFileContent(filePath: string, content: string): Promi
|
|||
// Security: Use atomic rename to prevent race conditions where symlinks
|
||||
// could be created between validation and write. Rename operations
|
||||
// replace the target file atomically and don't follow symlinks.
|
||||
const origStats = await fs.stat(filePath);
|
||||
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, content, 'utf-8');
|
||||
|
|
@ -178,6 +179,13 @@ export async function writeFileContent(filePath: string, content: string): Promi
|
|||
} catch {}
|
||||
throw renameError;
|
||||
}
|
||||
// Restore original permission bits since the atomic rename replaces the
|
||||
// inode and the temp file has default (0644) permissions. Mask off the
|
||||
// file-type bits; POSIX leaves them unspecified for chmod. A chmod
|
||||
// failure must not fail the write, which has already succeeded.
|
||||
try {
|
||||
await fs.chmod(filePath, origStats.mode & 0o777);
|
||||
} catch {}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -266,6 +274,7 @@ export async function applyFileEdits(
|
|||
// Security: Use atomic rename to prevent race conditions where symlinks
|
||||
// could be created between validation and write. Rename operations
|
||||
// replace the target file atomically and don't follow symlinks.
|
||||
const origStats = await fs.stat(filePath);
|
||||
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, modifiedContent, 'utf-8');
|
||||
|
|
@ -276,6 +285,13 @@ export async function applyFileEdits(
|
|||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
// Restore original permission bits since the atomic rename replaces the
|
||||
// inode and the temp file has default (0644) permissions. Mask off the
|
||||
// file-type bits; POSIX leaves them unspecified for chmod. A chmod
|
||||
// failure must not fail the write, which has already succeeded.
|
||||
try {
|
||||
await fs.chmod(filePath, origStats.mode & 0o777);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return formattedDiff;
|
||||
|
|
|
|||
Loading…
Reference in a new issue