fix(io): respect mode option when copying files with Bun.write() (#25906)

## Summary
- Fixes #25903 - `Bun.write()` mode option ignored when copying from
`Bun.file()`
- The destination file now correctly uses the specified `mode` option
instead of default permissions
- Works on Linux (via open flags), macOS (chmod after clonefile), and
Windows (chmod after copyfile)

## Test plan
- [x] Added regression test in `test/regression/issue/25903.test.ts`
- [x] Test passes with `bun bd test test/regression/issue/25903.test.ts`
- [x] Test fails with `USE_SYSTEM_BUN=1 bun test
test/regression/issue/25903.test.ts` (verifies the bug exists)

## Changes
- `src/bun.js/webcore/Blob.zig`: Add `mode` field to `WriteFileOptions`
and parse from options
- `src/bun.js/webcore/blob/copy_file.zig`: Use `destination_mode` in
`CopyFile` struct and `doOpenFile`
- `packages/bun-types/bun.d.ts`: Add `mode` option to BunFile copy
overloads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
robobun
2026-01-08 17:51:42 -08:00
committed by GitHub
parent c90c0e69cb
commit 50daf5df27
4 changed files with 292 additions and 3 deletions

View File

@@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test";
import { isWindows, tempDir } from "harness";
import { stat } from "node:fs/promises";
// Tests for issue #25903: Bun.write() mode option when copying files using Bun.file()
// The mode option is respected when copying files via Bun.file() as the source.
// These tests are skipped on Windows where Unix-style file permissions don't apply.
describe.skipIf(isWindows)("Bun.write() mode option", () => {
test("Bun.write() respects mode option when copying files via Bun.file()", async () => {
using dir = tempDir("issue-25903", {});
const sourcePath = `${dir}/source.txt`;
const destPath = `${dir}/dest.txt`;
// Create source file (mode is determined by the write path's default behavior)
await Bun.write(sourcePath, "hello world");
// Get source file's actual permissions to verify they differ from what we'll set
const sourceStat = await stat(sourcePath);
const sourceMode = sourceStat.mode & 0o777;
// Copy using Bun.file() with specific 0o600 permissions (more restrictive)
// The mode option is honored for Bun.file() copy operations
await Bun.write(destPath, Bun.file(sourcePath), { mode: 0o600 });
// Verify destination file has the specified permissions, not inherited from source
const destStat = await stat(destPath);
expect(destStat.mode & 0o777).toBe(0o600);
// Also verify it's different from source (unless source happened to be 0o600)
if (sourceMode !== 0o600) {
expect(destStat.mode & 0o777).not.toBe(sourceMode);
}
});
test("Bun.write() respects mode option with createPath when copying via Bun.file()", async () => {
using dir = tempDir("issue-25903-createPath", {});
const sourcePath = `${dir}/source.txt`;
const destPath = `${dir}/subdir/dest.txt`;
// Create source file
await Bun.write(sourcePath, "hello world");
// Copy using Bun.file() to a path that requires directory creation, with specific permissions
await Bun.write(destPath, Bun.file(sourcePath), { mode: 0o755, createPath: true });
// Verify destination file has the specified permissions
const destStat = await stat(destPath);
expect(destStat.mode & 0o777).toBe(0o755);
});
test("Bun.write() uses default permissions when mode is not specified for Bun.file() copy", async () => {
using dir = tempDir("issue-25903-default", {});
const sourcePath = `${dir}/source.txt`;
const destPath = `${dir}/dest.txt`;
const baselinePath = `${dir}/baseline.txt`;
// Create source file
await Bun.write(sourcePath, "hello world");
// Create a baseline file using default permissions (to determine what the default is)
await Bun.write(baselinePath, "baseline");
const baselineStat = await stat(baselinePath);
const defaultMode = baselineStat.mode & 0o777;
// Copy using Bun.file() without specifying mode - should use default permissions
await Bun.write(destPath, Bun.file(sourcePath));
// When mode is not specified, the default permission is used (same as creating a new file)
// This test verifies that the destination doesn't inherit source permissions incorrectly
const destStat = await stat(destPath);
expect(destStat.mode & 0o777).toBe(defaultMode);
});
test("Bun.write() respects mode when writing to PathLike from BunFile", async () => {
using dir = tempDir("issue-25903-pathlike", {});
const sourcePath = `${dir}/source.txt`;
const destPath = `${dir}/dest.txt`;
// Create source file
await Bun.write(sourcePath, "test content");
// Write with specific mode using path string as destination and Bun.file() as source
await Bun.write(destPath, Bun.file(sourcePath), { mode: 0o700 });
const destStat = await stat(destPath);
expect(destStat.mode & 0o777).toBe(0o700);
});
test("Bun.write() respects mode when both destination and source are BunFile", async () => {
using dir = tempDir("issue-25903-bunfile-dest", {});
const sourcePath = `${dir}/source.txt`;
const destPath = `${dir}/dest.txt`;
// Create source file
await Bun.write(sourcePath, "test content for bunfile dest");
// Write with specific mode using Bun.file() as both destination and source
await Bun.write(Bun.file(destPath), Bun.file(sourcePath), { mode: 0o700 });
const destStat = await stat(destPath);
expect(destStat.mode & 0o777).toBe(0o700);
});
test("Bun.write() respects mode when overwriting an existing file", async () => {
using dir = tempDir("issue-25903-overwrite", {});
const sourcePath = `${dir}/source.txt`;
const destPath = `${dir}/dest.txt`;
// Create source file
await Bun.write(sourcePath, "source content");
// Create destination file with default permissions
await Bun.write(destPath, "original content");
const originalStat = await stat(destPath);
const originalMode = originalStat.mode & 0o777;
// Overwrite destination with different mode - should update permissions even for existing file
await Bun.write(destPath, Bun.file(sourcePath), { mode: 0o600 });
const destStat = await stat(destPath);
expect(destStat.mode & 0o777).toBe(0o600);
// Verify the mode actually changed (unless original happened to be 0o600)
if (originalMode !== 0o600) {
expect(destStat.mode & 0o777).not.toBe(originalMode);
}
});
test("Bun.write() accepts mode: 0 (no permissions)", async () => {
using dir = tempDir("issue-25903-mode-zero", {});
const sourcePath = `${dir}/source.txt`;
const destPath = `${dir}/dest.txt`;
// Create source file
await Bun.write(sourcePath, "test content");
// Write with mode 0 (no permissions) - this should be accepted, not treated as "not specified"
await Bun.write(destPath, Bun.file(sourcePath), { mode: 0o000 });
const destStat = await stat(destPath);
expect(destStat.mode & 0o777).toBe(0o000);
});
}); // end describe.skipIf(isWindows)
// Note: The mode option is fully respected for Bun.file() copy operations (the fix for #25903).
// For direct string/buffer writes, mode support depends on the write path used internally.
// The empty file creation path respects mode, but other direct write paths may use
// the default permission (0o664). The tests above validate the Bun.file() copy path.