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
28 changes: 28 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,20 @@ declare module "bun" {
destination: BunFile,
input: BunFile,
options?: {
/**
* Set the file permissions of the destination when it is created or overwritten.
*
* Must be a valid Unix permission mode (0 to 0o777 / 511 in decimal).
* If omitted, defaults to the system default based on umask (typically 0o644).
*
* @throws {RangeError} If the mode is outside the valid range (0 to 0o777).
*
* @example
* ```ts
* await Bun.write(Bun.file("./secret.txt"), Bun.file("./source.txt"), { mode: 0o600 });
* ```
*/
mode?: number;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* If `true`, create the parent directory if it doesn't exist. By default, this is `true`.
*
Expand Down Expand Up @@ -848,6 +862,20 @@ declare module "bun" {
destinationPath: PathLike,
input: BunFile,
options?: {
/**
* Set the file permissions of the destination when it is created or overwritten.
*
* Must be a valid Unix permission mode (0 to 0o777 / 511 in decimal).
* If omitted, defaults to the system default based on umask (typically 0o644).
*
* @throws {RangeError} If the mode is outside the valid range (0 to 0o777).
*
* @example
* ```ts
* await Bun.write("./secret.txt", Bun.file("./source.txt"), { mode: 0o600 });
* ```
*/
mode?: number;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* If `true`, create the parent directory if it doesn't exist. By default, this is `true`.
*
Expand Down
20 changes: 18 additions & 2 deletions src/bun.js/webcore/Blob.zig
Original file line number Diff line number Diff line change
Expand Up @@ -902,8 +902,7 @@ fn writeFileWithEmptySourceToDestination(ctx: *jsc.JSGlobalObject, destination_b
// SAFETY: we check if `file.pathlike` is an fd or
// not above, returning if it is.
var buf: bun.PathBuffer = undefined;
// TODO: respect `options.mode`
const mode: bun.Mode = jsc.Node.fs.default_permission;
const mode: bun.Mode = options.mode orelse jsc.Node.fs.default_permission;
while (true) {
const open_res = bun.sys.open(file.pathlike.path.sliceZ(&buf), bun.O.CREAT | bun.O.TRUNC, mode);
switch (open_res) {
Expand Down Expand Up @@ -1054,6 +1053,7 @@ pub fn writeFileWithSourceDestination(ctx: *jsc.JSGlobalObject, source_blob: *Bl
ctx.bunVM().eventLoop(),
options.mkdirp_if_not_exists orelse true,
destination_blob.size,
options.mode,
);
}
var file_copier = copy_file.CopyFile.create(
Expand All @@ -1064,6 +1064,7 @@ pub fn writeFileWithSourceDestination(ctx: *jsc.JSGlobalObject, source_blob: *Bl
destination_blob.size,
ctx,
options.mkdirp_if_not_exists orelse true,
options.mode,
);
file_copier.schedule();
return file_copier.promise.value();
Expand Down Expand Up @@ -1204,6 +1205,7 @@ pub fn writeFileWithSourceDestination(ctx: *jsc.JSGlobalObject, source_blob: *Bl
const WriteFileOptions = struct {
mkdirp_if_not_exists: ?bool = null,
extra_options: ?JSValue = null,
mode: ?bun.Mode = null,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// ## Errors
Expand Down Expand Up @@ -1530,6 +1532,7 @@ pub fn writeFile(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun
return globalThis.throwInvalidArguments("Bun.write(pathOrFdOrBlob, blob) expects a Blob-y thing to write", .{});
};
var mkdirp_if_not_exists: ?bool = null;
var mode: ?bun.Mode = null;
const options = args.nextEat();
if (options) |options_object| {
if (options_object.isObject()) {
Expand All @@ -1539,13 +1542,26 @@ pub fn writeFile(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun
}
mkdirp_if_not_exists = create_directory.toBoolean();
}
if (try options_object.get(globalThis, "mode")) |mode_value| {
if (!mode_value.isEmptyOrUndefinedOrNull()) {
if (!mode_value.isNumber()) {
return globalThis.throwInvalidArgumentType("write", "options.mode", "number");
}
const mode_int = mode_value.toInt64();
if (mode_int < 0 or mode_int > 0o777) {
return globalThis.throwRangeError(mode_int, .{ .field_name = "mode", .min = 0, .max = 0o777 });
}
mode = @intCast(mode_int);
}
}
} else if (!options_object.isEmptyOrUndefinedOrNull()) {
return globalThis.throwInvalidArgumentType("write", "options", "object");
}
}
return writeFileInternal(globalThis, &path_or_blob, data, .{
.mkdirp_if_not_exists = mkdirp_if_not_exists,
.extra_options = options,
.mode = mode,
});
}

Expand Down
100 changes: 99 additions & 1 deletion src/bun.js/webcore/blob/copy_file.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub const CopyFile = struct {
globalThis: *JSGlobalObject,

mkdirp_if_not_exists: bool = false,
destination_mode: ?bun.Mode = null,

pub const ResultType = anyerror!SizeType;

Expand All @@ -31,6 +32,7 @@ pub const CopyFile = struct {
max_len: SizeType,
globalThis: *JSGlobalObject,
mkdirp_if_not_exists: bool,
destination_mode: ?bun.Mode,
) *CopyFilePromiseTask {
const read_file = bun.new(CopyFile, CopyFile{
.store = store,
Expand All @@ -41,6 +43,7 @@ pub const CopyFile = struct {
.destination_file_store = store.data.file,
.source_file_store = source_store.data.file,
.mkdirp_if_not_exists = mkdirp_if_not_exists,
.destination_mode = destination_mode,
});
store.ref();
source_store.ref();
Expand Down Expand Up @@ -98,6 +101,24 @@ pub const CopyFile = struct {
const close_input = this.destination_file_store.pathlike != .fd and this.destination_fd != bun.invalid_fd;
const close_output = this.source_file_store.pathlike != .fd and this.source_fd != bun.invalid_fd;

// Apply destination mode using fchmod before closing (for POSIX platforms)
// This ensures mode is applied even when overwriting existing files, since
// open()'s mode argument only affects newly created files.
// On macOS clonefile path, chmod is called separately after clonefile.
// On Windows, this is handled via async uv_fs_chmod.
if (comptime !Environment.isWindows) {
if (this.destination_mode) |mode| {
if (this.destination_fd != bun.invalid_fd and this.system_error == null) {
switch (bun.sys.fchmod(this.destination_fd, mode)) {
.err => |err| {
this.system_error = err.toSystemError();
},
.result => {},
}
}
}
}

if (close_input and close_output) {
this.doCloseFile(.both);
} else if (close_input) {
Expand Down Expand Up @@ -155,10 +176,11 @@ pub const CopyFile = struct {
if (which == .both or which == .destination) {
while (true) {
const dest = this.destination_file_store.pathlike.path.sliceZ(&path_buf1);
const mode = this.destination_mode orelse jsc.Node.fs.default_permission;
this.destination_fd = switch (bun.sys.open(
dest,
open_destination_flags,
jsc.Node.fs.default_permission,
mode,
)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.result => |result| switch (result.makeLibUVOwnedForSyscall(.open, .close_on_fail)) {
.result => |result_fd| result_fd,
Expand Down Expand Up @@ -446,6 +468,16 @@ pub const CopyFile = struct {
} else {
this.read_len = @as(SizeType, @intCast(stat_.?.size));
}
// Apply destination mode if specified (clonefile copies source permissions)
if (this.destination_mode) |mode| {
switch (bun.sys.chmod(this.destination_file_store.pathlike.path.sliceZ(&path_buf), mode)) {
.err => |err| {
this.system_error = err.toSystemError();
return;
},
.result => {},
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return;
} else |_| {

Expand Down Expand Up @@ -578,10 +610,14 @@ pub const CopyFileWindows = struct {
io_request: libuv.fs_t = std.mem.zeroes(libuv.fs_t),
promise: jsc.JSPromise.Strong = .{},
mkdirp_if_not_exists: bool = false,
destination_mode: ?bun.Mode = null,
event_loop: *jsc.EventLoop,

size: Blob.SizeType = Blob.max_size,

/// Bytes written, stored for use after async chmod completes
written_bytes: usize = 0,

/// For mkdirp
err: ?bun.sys.Error = null,

Expand Down Expand Up @@ -791,6 +827,7 @@ pub const CopyFileWindows = struct {
event_loop: *jsc.EventLoop,
mkdirp_if_not_exists: bool,
size_: Blob.SizeType,
destination_mode: ?bun.Mode,
) jsc.JSValue {
destination_file_store.ref();
source_file_store.ref();
Expand All @@ -801,6 +838,7 @@ pub const CopyFileWindows = struct {
.io_request = std.mem.zeroes(libuv.fs_t),
.event_loop = event_loop,
.mkdirp_if_not_exists = mkdirp_if_not_exists,
.destination_mode = destination_mode,
.size = size_,
});
const promise = result.promise.value();
Expand Down Expand Up @@ -1055,6 +1093,66 @@ pub const CopyFileWindows = struct {
this.truncate();
written = @intCast(this.size);
}

// Apply destination mode if specified (async)
if (this.destination_mode) |mode| {
if (this.destination_file_store.data.file.pathlike == .path) {
this.written_bytes = written;
var pathbuf: bun.PathBuffer = undefined;
const path = this.destination_file_store.data.file.pathlike.path.sliceZ(&pathbuf);
const loop = this.event_loop.virtual_machine.event_loop_handle.?;
this.io_request.deinit();
this.io_request = std.mem.zeroes(libuv.fs_t);
this.io_request.data = @ptrCast(this);

const rc = libuv.uv_fs_chmod(
loop,
&this.io_request,
path,
@intCast(mode),
&onChmod,
);

if (rc.errno()) |errno| {
// chmod failed to start - reject the promise to report the error
var err = bun.sys.Error.fromCode(@enumFromInt(errno), .chmod);
const destination = &this.destination_file_store.data.file;
if (destination.pathlike == .path) {
err = err.withPath(destination.pathlike.path.slice());
}
this.throw(err);
return;
}
this.event_loop.refConcurrently();
return;
}
}

this.resolvePromise(written);
}

fn onChmod(req: *libuv.fs_t) callconv(.c) void {
var this: *CopyFileWindows = @fieldParentPtr("io_request", req);
bun.assert(req.data == @as(?*anyopaque, @ptrCast(this)));

var event_loop = this.event_loop;
event_loop.unrefConcurrently();

const rc = req.result;
if (rc.errEnum()) |errno| {
var err = bun.sys.Error.fromCode(errno, .chmod);
const destination = &this.destination_file_store.data.file;
if (destination.pathlike == .path) {
err = err.withPath(destination.pathlike.path.slice());
}
this.throw(err);
return;
}

this.resolvePromise(this.written_bytes);
}

fn resolvePromise(this: *CopyFileWindows, written: usize) void {
const globalThis = this.event_loop.global;
const promise = this.promise.swap();
var event_loop = this.event_loop;
Expand Down
Loading