From 41cea90493adf285bc7a9b67eab8ab8099c0a173 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:23:00 +0000 Subject: [PATCH 1/2] Bun.write: apply `mode` for string, buffer, Blob and stream sources on POSIX `Bun.write(path, data, { mode })` applied the mode only for a `Bun.file()` source. A string, a buffer, a Blob and a buffered body ignored it. A stream and an empty Blob passed it to `open(2)`, so the umask cut it and a file that existed kept its mode. `BunFile.prototype.write` did not read the option. Both entry points now check the number in `write_mode`. A `mode` that is not an integer is rejected. NaN and a fraction used to convert to 0, and the `Bun.file()` copy then wrote mode 000. On POSIX, each engine that opens the destination by path calls `apply_mode` on the descriptor before it writes: - one `fstat`. A destination that is not a regular file is left alone. - `fchmod` to the bits that the old mode and the new mode share. A chmod that the OS refuses fails here, before any old byte is discarded. - The old contents go. The thread pool, stream and empty-source engines truncate here. The synchronous path trims after its write. - `fchmod` to the mode when it adds a bit. A file that already has the mode gets no chmod. The mode is for a file that Bun opens by path: a descriptor that the caller passed keeps its mode, for a `Bun.file()` source too. macOS and FreeBSD open `/dev/fd/N` as a duplicate of descriptor N. A duplicate that is in append mode or past offset 0 gets the mode and keeps its contents, as an `O_TRUNC` open leaves them. On POSIX an empty Blob or Response now opens the destination with `O_CREAT`, with or without a mode. It used `truncate(2)`, which rejected a bare filename and `createPath: false` for a file that did not exist. The Windows engines do not change. On Windows `BunFile.prototype.write` checks `mode` and does not apply it. An S3 file does not read it on any platform. --- src/runtime/webcore/Blob.bind.ts | 5 + src/runtime/webcore/Blob.rs | 264 +++++++++-- src/runtime/webcore/FileSink.rs | 19 +- src/runtime/webcore/blob/write_file.rs | 28 ++ test/js/bun/io/bun-write.test.js | 589 +++++++++++++++++++++++++ test/regression/issue/25903.test.ts | 6 +- 6 files changed, 872 insertions(+), 39 deletions(-) diff --git a/src/runtime/webcore/Blob.bind.ts b/src/runtime/webcore/Blob.bind.ts index 6203f9f19d63..02bb2ffbe751 100644 --- a/src/runtime/webcore/Blob.bind.ts +++ b/src/runtime/webcore/Blob.bind.ts @@ -57,5 +57,10 @@ export const BunWriteOptions = b.dictionary( { createPath: b.bool.emptyStringAsNull, mode: b.f64.nullable }, { allowArray: true, fromJS: true, reportedAs: "options" }, ); +/** The `mode` that `BunFile.prototype.write` reads of its options. It does not read one for an S3 file. */ +export const BunWriteMode = b.dictionary( + { mode: b.f64.nullable }, + { allowArray: true, fromJS: true, reportedAs: "options" }, +); /** `options` is also what a write to S3 takes. */ export const write = b.fn({ ...method, args: { destination: b.any, data: b.any, options: b.any } }); diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 27010d72f859..a28e822f9370 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -117,7 +117,7 @@ const SERIALIZATION_VERSION: u8 = 4; pub(crate) use crate::generated_bindgen::blob::js_Blob as js; use crate::generated_bindgen::blob::{ - BlobOrBuildArtifact, BlobType, BunFileOptions, BunWriteOptions, + BlobOrBuildArtifact, BlobType, BunFileOptions, BunWriteMode, BunWriteOptions, }; // ────────────────────────────────────────────────────────────────────────── @@ -1226,6 +1226,7 @@ impl BlobExt for Blob { ))); } let mut mkdirp_if_not_exists: Option = None; + let mut mode: Option = None; let options = (!options.is_undefined()).then(|| options.to_js()); if let Some(options_object) = options { if options_object.is_object() { @@ -1241,6 +1242,16 @@ impl BlobExt for Blob { } mkdirp_if_not_exists = Some(create_directory.to_boolean()); } + // An S3 file has no mode. On Windows the mode is checked, and only `Bun.write` applies one. + if !self.is_s3() { + let checked = write_mode( + global_this, + BunWriteMode::from_js(global_this, options_object)?.mode, + )?; + if cfg!(not(windows)) { + mode = checked; + } + } if let Some(content_type) = options_object.get_truthy(global_this, "type")? { // override the content type set_content_type_from_js(global_this, self, content_type)?; @@ -1259,7 +1270,7 @@ impl BlobExt for Blob { WriteFileOptions { mkdirp_if_not_exists, extra_options: options, - mode: None, + mode, }, ) } @@ -1471,7 +1482,7 @@ impl BlobExt for Blob { let stream_start = streams::Start::FileSink(streams::FileSinkOptions { truncate: matches!(input_path, webcore::PathOrFileDescriptor::Path(_)), mkdirp: options.mkdirp_if_not_exists.unwrap_or(true), - mode: options.mode.unwrap_or(WRITE_PERMISSIONS), + mode: options.mode_for(&store.data.as_file().pathlike), input_path, ..Default::default() }); @@ -3992,6 +4003,78 @@ pub(crate) fn mkdirp_parent(path: &[u8]) -> bun_sys::Result<()> { .map(|_| ()) } +/// `O_TRUNC` for a destination `Bun.write` replaces. With a `mode`, [`apply_mode`] truncates instead. +pub(crate) fn truncate_on_open(mode: Option) -> i32 { + // Windows keeps `O_TRUNC`: its files have no permission bits, so `apply_mode` is POSIX only. + if cfg!(not(windows)) && mode.is_some() { + 0 + } else { + bun_sys::O::TRUNC + } +} + +/// When the writer drops the old contents of a destination that [`apply_mode`] prepares. +#[cfg(not(windows))] +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Discard { + /// The open left out `O_TRUNC` ([`truncate_on_open`]), so `apply_mode` truncates. + BeforeWrite, + /// The writer overwrites in place and trims to the written length when it is done. + AfterWrite, +} + +/// Sets `mode` on a regular file that `Bun.write` opened by path. Returns the mode that an `AfterWrite` writer sets after its trim. +#[cfg(not(windows))] +pub(crate) fn apply_mode( + fd: Fd, + mode: Option, + discard: Discard, +) -> bun_sys::Result> { + let Some(mode) = mode else { + return Ok(None); + }; + let stat = bun_sys::fstat(fd)?; + if !bun_sys::S::ISREG(stat.st_mode as _) { + return Ok(None); + } + let old = stat.st_mode as bun_sys::Mode & 0o7777; + let has_contents = stat.st_size > 0; + if old != mode { + // With contents, this only removes bits. A chmod the OS refuses fails before anything is discarded. + bun_sys::fchmod(fd, if has_contents { old & mode } else { mode })?; + } + if !has_contents { + return Ok(None); + } + let widen = (old & mode != mode).then_some(mode); + if discard == Discard::AfterWrite { + return Ok(widen); + } + if !is_duplicate(fd) { + bun_sys::ftruncate(fd, 0)?; + } + if let Some(mode) = widen { + bun_sys::fchmod(fd, mode)?; + } + Ok(None) +} + +/// The trim comes first, so the old tail is gone before `mode` lets a new reader in. +#[cfg(not(windows))] +fn trim_then_widen(fd: Fd, written: usize, mode: bun_sys::Mode) -> bun_sys::Result<()> { + bun_sys::ftruncate(fd, i64::try_from(written).expect("int cast"))?; + bun_sys::fchmod(fd, mode) +} + +/// macOS and FreeBSD open `/dev/fd/N` as a duplicate of descriptor N. Only a duplicate is in append mode or past offset 0 here. +#[cfg(not(windows))] +fn is_duplicate(fd: Fd) -> bool { + cfg!(any(target_os = "macos", target_os = "freebsd")) + && (bun_sys::get_fcntl_flags(fd) + .is_ok_and(|flags| flags as core::ffi::c_int & bun_sys::O::APPEND != 0) + || bun_sys::lseek(fd, 0, libc::SEEK_CUR).is_ok_and(|offset| offset != 0)) +} + // TODO: move this to bun_sys? #[inline(never)] #[cfg(not(windows))] @@ -4064,6 +4147,37 @@ pub(crate) struct WriteFileOptions { pub(crate) mode: Option, } +impl WriteFileOptions { + /// `mode` for a destination that Bun opens by path. A descriptor that the caller passed keeps its mode. + #[cfg(not(windows))] + fn mode_for(&self, destination: &PathOrFileDescriptor) -> Option { + self.mode + .filter(|_| matches!(destination, PathOrFileDescriptor::Path(_))) + } +} + +/// An empty source written to `path`: creates the file, or discards its contents. +#[cfg(not(windows))] +fn empty_file( + path: &PathLike<'_>, + mode: Option, + mkdirp: bool, +) -> bun_sys::Result<()> { + let mut buf = bun_paths::path_buffer_pool::get(); + let path_z = path.slice_z(&mut buf); + let flags = + bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::NONBLOCK | truncate_on_open(mode); + let create_mode = mode.unwrap_or(node::fs::DEFAULT_PERMISSION); + let mut result = bun_sys::open(path_z, flags, create_mode); + if mkdirp && matches!(&result, Err(err) if err.get_errno() == bun_sys::E::ENOENT) { + result = + mkdirp_parent(path.slice()).and_then(|()| bun_sys::open(path_z, flags, create_mode)); + } + let fd = result?; + let _close = bun_sys::CloseOnDrop::new(fd); + apply_mode(fd, mode, Discard::BeforeWrite).map(|_| ()) +} + /// Write an empty string to a file by truncating it. /// /// This behavior matches what we do with the fast path. @@ -4089,6 +4203,21 @@ fn write_file_with_empty_source_to_destination( match &destination_store.data { store::Data::File(file) => { + #[cfg(not(windows))] + if let PathOrFileDescriptor::Path(path) = &file.pathlike { + let mkdirp = options.mkdirp_if_not_exists != Some(false); + let mode = options.mode_for(&file.pathlike); + return Ok(match empty_file(path, mode, mkdirp) { + Ok(()) => { + JSPromise::resolved_promise_value(cx.global(), JSValue::js_number(0.0)) + } + Err(err) => JSPromise::rejected_promise( + cx.global(), + err.with_path(path.slice()).to_js(cx.global()), + ) + .to_js(), + }); + } // TODO: make this async // `VirtualMachine::node_fs()` currently returns `*mut c_void`; the // typed `&mut NodeFS` accessor isn't wired yet, so use a fresh @@ -4325,6 +4454,7 @@ pub(crate) fn write_file_with_source_destination( destination_blob.borrowed_view(), source_blob.borrowed_view(), options.mkdirp_if_not_exists.unwrap_or(true), + options.mode_for(&destination_store.data.as_file().pathlike), ) .expect("unreachable"); // SAFETY: `write_file_promise` was just produced by heap::alloc above; sole owner. @@ -4353,6 +4483,7 @@ pub(crate) fn write_file_with_source_destination( } #[cfg(not(windows))] { + let mode = options.mode_for(&destination_store.data.as_file().pathlike); return Ok(copy_file::CopyFile::create( destination_store, source_store, @@ -4360,7 +4491,7 @@ pub(crate) fn write_file_with_source_destination( destination_blob.size.get(), cx, options.mkdirp_if_not_exists.unwrap_or(true), - options.mode, + mode, )); } } else if destination_type == store::DataTag::File && source_type == store::DataTag::S3 { @@ -4645,6 +4776,7 @@ pub(crate) fn write_file_internal( cx.global(), pathlike, &str, + options.mode_for(pathlike), &mut needs_async, ) } else { @@ -4652,6 +4784,7 @@ pub(crate) fn write_file_internal( cx.global(), pathlike, &str, + options.mode_for(pathlike), &mut needs_async, ) }; @@ -4676,6 +4809,7 @@ pub(crate) fn write_file_internal( cx.global(), pathlike, buffer_view.byte_slice(), + options.mode_for(pathlike), &mut needs_async, ) } else { @@ -4683,6 +4817,7 @@ pub(crate) fn write_file_internal( cx.global(), pathlike, buffer_view.byte_slice(), + options.mode_for(pathlike), &mut needs_async, ) }; @@ -4893,6 +5028,8 @@ pub(crate) fn write_file_internal( ), promise: jsc::JSPromiseStrong::init(cx.global()), mkdirp_if_not_exists: options.mkdirp_if_not_exists.unwrap_or(true), + #[cfg(not(windows))] + mode: options.mode, })); // SAFETY: re-borrow after the early-return paths. let BodyValue::Locked(locked) = (unsafe { &mut *body_value }) else { @@ -5027,24 +5164,7 @@ pub(crate) fn write( Some(options) if !options.is_null() => BunWriteOptions::from_js(global_this, options)?, _ => BunWriteOptions::default(), }; - let mode = match own_options.mode { - Some(mode) => { - let mode_int = mode as i64; - if mode_int < 0 || mode_int > 0o777 { - return Err(global_this.throw_range_error( - mode_int, - jsc::RangeErrorOptions { - field_name: b"mode", - min: 0, - max: 0o777, - msg: b"", - }, - )); - } - Some(mode_int as bun_sys::Mode) - } - None => None, - }; + let mode = write_mode(global_this, own_options.mode)?; write_file_internal( &cx, &mut path_or_blob, @@ -5057,13 +5177,45 @@ pub(crate) fn write( ) } -const WRITE_PERMISSIONS: bun_sys::Mode = 0o664; +/// The `mode` of `Bun.write` and of `BunFile.prototype.write`: an integer from 0 to 0o777. +fn write_mode(global_this: &JSGlobalObject, mode: Option) -> JsResult> { + let Some(mode) = mode else { + return Ok(None); + }; + // `as i64` maps NaN to 0 and truncates a fraction, which would chmod to 000. + if !mode.is_finite() || mode.fract() != 0.0 { + return Err(global_this.throw_range_error( + mode, + jsc::RangeErrorOptions { + field_name: b"mode", + msg: b"an integer", + ..Default::default() + }, + )); + } + let mode_int = mode as i64; + if mode_int < 0 || mode_int > 0o777 { + return Err(global_this.throw_range_error( + mode_int, + jsc::RangeErrorOptions { + field_name: b"mode", + min: 0, + max: 0o777, + msg: b"", + }, + )); + } + Ok(Some(mode_int as bun_sys::Mode)) +} + +pub(crate) const WRITE_PERMISSIONS: bun_sys::Mode = 0o664; #[cfg(not(windows))] fn write_string_to_file_fast( global_this: &JSGlobalObject, pathlike: &PathOrFileDescriptor, str: &BunString, + mode: Option, needs_async: &mut bool, ) -> JSValue { let fd: Fd = if !NEEDS_OPEN { @@ -5075,7 +5227,7 @@ fn write_string_to_file_fast( // we deliberately don't use O_TRUNC here // it's a perf optimization bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::NONBLOCK, - WRITE_PERMISSIONS, + mode.unwrap_or(WRITE_PERMISSIONS), ) { bun_sys::Result::Ok(result) => result, bun_sys::Result::Err(err) => { @@ -5095,6 +5247,20 @@ fn write_string_to_file_fast( // Declared before the truncate guard so it drops *after* it (close runs last). let _close = NEEDS_OPEN.then(|| bun_sys::CloseOnDrop::new(fd)); + let mut widen = None; + if NEEDS_OPEN { + match apply_mode(fd, mode, Discard::AfterWrite) { + bun_sys::Result::Ok(pending) => widen = pending, + bun_sys::Result::Err(err) => { + return JSPromise::rejected_promise( + global_this, + err.with_path(pathlike.path().slice()).to_js(global_this), + ) + .to_js(); + } + } + } + // scopeguard's closure captures borrows at construction, conflicting // with later `written += ...` / `truncate = false`. Route through `Cell` // so the guard and the loop body share `&Cell<_>` (no mutable-borrow conflict). @@ -5138,6 +5304,17 @@ fn write_string_to_file_fast( } } + if let Some(mode) = widen { + truncate.set(false); + if let bun_sys::Result::Err(err) = trim_then_widen(fd, written.get(), mode) { + return JSPromise::rejected_promise( + global_this, + err.with_path(pathlike.path().slice()).to_js(global_this), + ) + .to_js(); + } + } + JSPromise::resolved_promise_value(global_this, JSValue::js_number(written.get() as f64)) } @@ -5146,6 +5323,7 @@ fn write_bytes_to_file_fast( global_this: &JSGlobalObject, pathlike: &PathOrFileDescriptor, bytes: &[u8], + mode: Option, needs_async: &mut bool, ) -> JSValue { let fd: Fd = if !NEEDS_OPEN { @@ -5160,7 +5338,7 @@ fn write_bytes_to_file_fast( match bun_sys::open( pathlike.path().slice_z(&mut file_path), flags, - WRITE_PERMISSIONS, + mode.unwrap_or(WRITE_PERMISSIONS), ) { bun_sys::Result::Ok(result) => result, bun_sys::Result::Err(err) => { @@ -5183,6 +5361,20 @@ fn write_bytes_to_file_fast( let mut written: usize = 0; let _close = NEEDS_OPEN.then(|| bun_sys::CloseOnDrop::new(fd)); + let mut widen = None; + if NEEDS_OPEN { + match apply_mode(fd, mode, Discard::AfterWrite) { + bun_sys::Result::Ok(pending) => widen = pending, + bun_sys::Result::Err(err) => { + return JSPromise::rejected_promise( + global_this, + err.with_path(pathlike.path().slice()).to_js(global_this), + ) + .to_js(); + } + } + } + let mut remain = bytes; while !remain.is_empty() { match bun_sys::write(fd, remain) { @@ -5208,7 +5400,15 @@ fn write_bytes_to_file_fast( } } - if truncate { + if let Some(mode) = widen { + if let bun_sys::Result::Err(err) = trim_then_widen(fd, written, mode) { + return JSPromise::rejected_promise( + global_this, + err.with_path(pathlike.path().slice()).to_js(global_this), + ) + .to_js(); + } + } else if truncate { let _ = bun_sys::ftruncate(fd, i64::try_from(written).expect("int cast")); } @@ -6398,6 +6598,11 @@ pub(crate) trait FileOpener: Sized { const OPEN_FLAGS: i32 = bun_sys::O::RDONLY; const OPENER_FLAGS: i32 = bun_sys::O::NONBLOCK | bun_sys::O::CLOEXEC; + /// The flags and permission bits of the open. Override when they depend on the instance. + #[cfg(not(windows))] + fn open_args(&self) -> (i32, bun_sys::Mode) { + (Self::OPEN_FLAGS, crate::node::fs::DEFAULT_PERMISSION) + } fn opened_fd(&self) -> Fd; fn set_opened_fd(&mut self, fd: Fd); fn set_errno(&mut self, e: crate::Error); @@ -6520,12 +6725,9 @@ pub(crate) trait FileOpener: Sized { #[cfg(not(windows))] { + let (open_flags, open_mode) = self.open_args(); loop { - match bun_sys::open( - path, - Self::OPEN_FLAGS | Self::OPENER_FLAGS, - crate::node::fs::DEFAULT_PERMISSION, - ) { + match bun_sys::open(path, open_flags | Self::OPENER_FLAGS, open_mode) { bun_sys::Result::Ok(fd) => { self.set_opened_fd(fd); break; diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index bbe907056487..2aaa1d4bb87e 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -157,7 +157,8 @@ bun_io::impl_streaming_writer_parent! { pub struct Options { pub(crate) input_path: PathOrFileDescriptor, - pub(crate) mode: bun_sys::Mode, + /// The create mode. With `truncate`, the file gets exactly this mode before the first chunk. + pub(crate) mode: Option, /// `Bun.write(path, stream)`: replace the file's contents. pub(crate) truncate: bool, /// `Bun.write(path, stream)`: create missing parent directories. @@ -168,7 +169,7 @@ impl Default for Options { fn default() -> Self { Self { input_path: PathOrFileDescriptor::Fd(Fd::INVALID), - mode: 0o664, + mode: None, truncate: false, mkdirp: false, } @@ -180,7 +181,7 @@ impl Options { let flags = bun_sys::O::NONBLOCK | bun_sys::O::CLOEXEC | bun_sys::O::CREAT | bun_sys::O::WRONLY; if self.truncate { - flags | bun_sys::O::TRUNC + flags | webcore::blob::truncate_on_open(self.mode) } else { flags } @@ -688,7 +689,7 @@ impl FileSink { Fd::cwd(), &io_path, options.flags(), - options.mode, + options.mode.unwrap_or(webcore::blob::WRITE_PERMISSIONS), pollable_out, is_socket_out, self.force_sync.get(), @@ -737,6 +738,16 @@ impl FileSink { sys::Result::Ok(fd) => fd, }; + #[cfg(not(windows))] + if let (true, bun_io::PathOrFileDescriptor::Path(path)) = (options.truncate, &io_path) { + if let sys::Result::Err(err) = + webcore::blob::apply_mode(fd, options.mode, webcore::blob::Discard::BeforeWrite) + { + fd.close(); + return sys::Result::Err(err.with_path(*path)); + } + } + if matches!(options.input_path, PathOrFileDescriptor::Path(_)) { self.close_with_graph(context); } diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index 42895c40d1a7..d1289d8743af 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -118,6 +118,8 @@ pub(crate) struct WriteFile { pub(crate) close_after_io: bool, #[cfg(not(windows))] pub(crate) mkdirp_if_not_exists: bool, + #[cfg(not(windows))] + pub(crate) mode: Option, } bun_threading::intrusive_work_task!(WriteFile, task); @@ -131,6 +133,13 @@ impl FileOpener for WriteFile { const OPEN_FLAGS: i32 = bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC | bun_sys::O::NONBLOCK; + #[cfg(not(windows))] + fn open_args(&self) -> (i32, sys::Mode) { + ( + (Self::OPEN_FLAGS & !bun_sys::O::TRUNC) | blob::truncate_on_open(self.mode), + self.mode.unwrap_or(crate::node::fs::DEFAULT_PERMISSION), + ) + } fn opened_fd(&self) -> Fd { self.opened_fd } @@ -298,6 +307,7 @@ impl WriteFile { file_blob: Blob, bytes_blob: Blob, mkdirp_if_not_exists: bool, + mode: Option, ) -> Result { let write_file = WriteFile { file_blob, @@ -318,6 +328,7 @@ impl WriteFile { could_block: false, close_after_io: false, mkdirp_if_not_exists, + mode, }; Ok(write_file) } @@ -428,6 +439,18 @@ impl WriteFile { let fd = self.opened_fd; + if self.is_allowed_to_close() { + if let bun_sys::Result::Err(err) = + blob::apply_mode(fd, self.mode, blob::Discard::BeforeWrite) + { + let err = err.with_path(self.pathlike().path().slice()); + self.errno = Some(bun_errno::from_errno(err.errno as i32).into()); + self.system_error = Some(err.to_system_error().into()); + self.on_finish(); + return; + } + } + self.could_block = 'brk: { if let Some(store) = self.file_blob.store.get().as_ref() { if let blob::store::Data::File(file) = &store.data { @@ -794,6 +817,7 @@ mod windows_impl { | uv::O::NONBLOCK | uv::O::SEQUENTIAL | uv::O::TRUNC, + // A `mode` from `Bun.write` is not applied here: `blob::apply_mode` is POSIX only. 0o644, Some(Self::on_open), ) @@ -1272,6 +1296,8 @@ pub(crate) struct WriteFileWaitFromLockedValueTask { pub global_this: bun_ptr::BackRef, pub(crate) promise: jsc::JSPromiseStrong, pub(crate) mkdirp_if_not_exists: bool, + #[cfg(not(windows))] + pub(crate) mode: Option, } impl WriteFileWaitFromLockedValueTask { @@ -1329,6 +1355,8 @@ impl WriteFileWaitFromLockedValueTask { &mut file_blob, &blob::WriteFileOptions { mkdirp_if_not_exists: Some(this.mkdirp_if_not_exists), + #[cfg(not(windows))] + mode: this.mode, ..Default::default() }, ) { diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index bcbd9deef142..fd0bd634ddc4 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -7,11 +7,14 @@ import { exampleSite, gcTick, isASAN, + isFreeBSD, + isLinux, isMacOS, isWindows, tempDir, withoutAggressiveGC, } from "harness"; +import { mkfifo } from "mkfifo"; import { once } from "node:events"; import http from "node:http"; import { finished } from "node:stream/promises"; @@ -1784,3 +1787,589 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { expect(exitCode).toBe(0); }); }); + +// `mode` is checked before a file is touched, on every platform. +describe.concurrent("Bun.write mode validation", () => { + const doors = [ + ["Bun.write(path)", (dest, data, options) => Bun.write(dest, data, options)], + ["Bun.write(Bun.file(path))", (dest, data, options) => Bun.write(Bun.file(dest), data, options)], + ["Bun.file(path).write()", (dest, data, options) => Bun.file(dest).write(data, options)], + ]; + const notAnInteger = { + name: "RangeError", + code: "ERR_OUT_OF_RANGE", + message: expect.stringContaining("It must be an integer"), + }; + const outOfRange = { + name: "RangeError", + code: "ERR_OUT_OF_RANGE", + message: expect.stringContaining("It must be >= 0 and <= 511"), + }; + const notANumber = { + name: "TypeError", + code: "ERR_INVALID_ARG_TYPE", + message: expect.stringContaining("options.mode"), + }; + + // NaN and 0.5 used to convert to 0, and a `Bun.file()` copy then wrote the file with mode 000. + for (const [label, mode, error] of [ + ["NaN", NaN, notAnInteger], + ["0.5", 0.5, notAnInteger], + ["384.5", 384.5, notAnInteger], + ["Infinity", Infinity, notAnInteger], + ["-Infinity", -Infinity, notAnInteger], + ["-1", -1, outOfRange], + ["0o1000", 0o1000, outOfRange], + ['"600"', "600", notANumber], + ["384n", 384n, notANumber], + ["true", true, notANumber], + ["new Number(384)", new Number(384), notANumber], + ]) { + test(`rejects mode ${label} and leaves the destination alone`, async () => { + using dir = tempDir("bun-write-mode", { "src.txt": "source", "existing.txt": "old" }); + const existing = join(String(dir), "existing.txt"); + const created = join(String(dir), "created.txt"); + const before = fs.statSync(existing).mode; + + for (const [, write] of doors) { + for (const make of [() => "hello", () => Bun.file(join(String(dir), "src.txt"))]) { + for (const dest of [existing, created]) { + // The async wrapper turns a synchronous throw into a rejection. + const attempt = async () => await write(dest, make(), { mode }); + await expect(attempt()).rejects.toMatchObject(error); + } + } + } + + expect(fs.existsSync(created)).toBe(false); + expect({ mode: fs.statSync(existing).mode, content: fs.readFileSync(existing, "utf8") }).toEqual({ + mode: before, + content: "old", + }); + }); + } + + // A mode means nothing for S3, so an S3 file does not validate one. + test("an S3 file does not read mode", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("") }); + const file = Bun.S3Client.file("key", { + accessKeyId: "test", + secretAccessKey: "test", + region: "us-east-1", + bucket: "bucket", + endpoint: server.url.href, + }); + expect([await file.write("x", { mode: NaN }), await file.write("x", { mode: "600" })]).toEqual([1, 1]); + }); +}); + +// A Windows file has no permission bits. There a mode can only set the read-only attribute, +// and the write paths that these cases cover do not apply it. +describe.skipIf(isWindows).concurrent("Bun.write mode option", () => { + const modeOf = p => fs.statSync(p).mode & 0o777; + const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); + const stream = () => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello")); + controller.close(); + }, + }); + // Above the 256 KiB limit of the synchronous path. + const large = Buffer.alloc(300 * 1024, "a").toString(); + + // [label, factory(dir), contents]. Factories, because a stream source can only be consumed once. + const sources = [ + ["small string", () => "hello", "hello"], + ["empty string", () => "", ""], + ["small Uint8Array", () => new Uint8Array([104, 105]), "hi"], + ["empty Uint8Array", () => new Uint8Array(0), ""], + ["large string", () => large, large], + ["large Uint8Array", () => Buffer.from(large), large], + ["Blob", () => new Blob(["hello"]), "hello"], + ["empty Blob", () => new Blob([]), ""], + ["ReadableStream", stream, "hello"], + ["buffered Response", () => new Response("hello"), "hello"], + ["streaming Response", () => new Response(stream()), "hello"], + ]; + // A `Bun.file()` source is copied by its own engine, which sets the mode after the copy. + const fileSource = [ + "BunFile", + dir => { + const source = join(String(dir), "source.txt"); + fs.writeFileSync(source, "hello"); + return Bun.file(source); + }, + "hello", + ]; + const forEachSource = (list, value) => Object.fromEntries(list.map(([label]) => [label, value])); + const doors = [ + ["Bun.write(path)", (dest, data, options) => Bun.write(dest, data, options)], + ["Bun.write(Bun.file(path))", (dest, data, options) => Bun.write(Bun.file(dest), data, options)], + ["Bun.file(path).write()", (dest, data, options) => Bun.file(dest).write(data, options)], + ]; + + for (const [door, write] of doors) { + for (const [label, make, contents] of [...sources, fileSource]) { + test(`${door} sets the mode and replaces the contents for ${label}`, async () => { + using dir = tempDir("bun-write-mode", {}); + // Longer than the payload, so a truncation that did not happen shows in the contents. + const old = contents + "-the-old-tail"; + const seed = (name, mode) => { + const dest = join(String(dir), name); + fs.writeFileSync(dest, old); + fs.chmodSync(dest, mode); + return dest; + }; + const result = async (dest, mode) => { + await write(dest, make(dir), { mode }); + return [modeOf(dest).toString(8), fs.readFileSync(dest, "utf8") === contents]; + }; + expect({ + // 0o646 has bits that a umask of 022 or 077 removes at open(2). + created: await result(join(String(dir), "created"), 0o646), + narrowed: await result(seed("narrowed", 0o644), 0o600), + widened: await result(seed("widened", 0o600), 0o644), + // 0o604 to 0o640 removes a bit and adds another one. + mixed: await result(seed("mixed", 0o604), 0o640), + unchanged: await result(seed("unchanged", 0o640), 0o640), + }).toEqual({ + created: ["646", true], + narrowed: ["600", true], + widened: ["644", true], + mixed: ["640", true], + unchanged: ["640", true], + }); + }); + } + } + + test("sets the mode when the parent directory must be created", async () => { + using dir = tempDir("bun-write-mode", {}); + const results = {}; + for (const [label, make] of sources) { + const dest = join(String(dir), label, "sub", "out.txt"); + await Bun.write(dest, make(), { mode: 0o646 }); + results[label] = modeOf(dest).toString(8); + } + expect(results).toEqual(forEachSource(sources, "646")); + }); + + test("createPath: false with a mode does not create the parent directory", async () => { + using dir = tempDir("bun-write-mode", {}); + const results = {}; + for (const [label, make] of sources) { + const missing = join(String(dir), label, "out.txt"); + let code = "resolved"; + try { + await Bun.write(missing, make(), { mode: 0o600, createPath: false }); + } catch (e) { + code = e.code; + } + const present = join(String(dir), `${label}.txt`); + await Bun.write(present, make(), { mode: 0o600, createPath: false }); + results[label] = [code, fs.existsSync(join(String(dir), label)), modeOf(present).toString(8)]; + } + expect(results).toEqual(forEachSource(sources, ["ENOENT", false, "600"])); + }); + + test("accepts mode 0", async () => { + using dir = tempDir("bun-write-mode", {}); + const dest = join(String(dir), "out.txt"); + await Bun.write(dest, "hello", { mode: 0o000 }); + expect(modeOf(dest)).toBe(0o000); + }); + + test("omitted mode leaves an existing file's permissions alone", async () => { + using dir = tempDir("bun-write-mode", {}); + const results = {}; + for (const [label, make, contents] of sources) { + const dest = join(String(dir), `${label}.txt`); + fs.writeFileSync(dest, contents + "-the-old-tail"); + fs.chmodSync(dest, 0o751); + await Bun.write(dest, make()); + results[label] = [modeOf(dest).toString(8), fs.readFileSync(dest, "utf8") === contents]; + } + expect(results).toEqual(forEachSource(sources, ["751", true])); + }); + + // An empty Blob or Response does not take the synchronous path. It creates the file like every other source. + test("an empty source creates the file for a bare filename and with createPath: false", async () => { + using dir = tempDir("bun-write-empty", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const fs = require("node:fs"); + process.umask(0); + const sources = { "empty-Blob": () => new Blob([]), "empty-Response": () => new Response("") }; + const results = {}; + for (const name in sources) { + const written = [ + await Bun.write(name, sources[name]()), + await Bun.write("./" + name + "-2", sources[name](), { createPath: false }), + ]; + const stat = fs.statSync(name); + results[name] = [written, (stat.mode & 0o777).toString(8), stat.size, fs.statSync(name + "-2").size]; + } + console.log(JSON.stringify(results)); + `, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, results: stdout && JSON.parse(stdout) }).toEqual({ + stderr: "", + results: { "empty-Blob": [[0, 0], "666", 0, 0], "empty-Response": [[0, 0], "666", 0, 0] }, + }); + expect(exitCode).toBe(0); + }); + + // pull() runs after Bun.write() has opened the destination and before any byte of the + // stream reaches it, so it sees what another reader of the file sees during the write. + for (const [label, wrap] of [ + ["ReadableStream", body => body], + ["streaming Response", body => new Response(body)], + ]) { + for (const [change, before, mode] of [ + ["narrowed", 0o644, 0o600], + ["widened", 0o600, 0o644], + ]) { + test(`the mode is ${change} and the old contents are gone before the first chunk of a ${label}`, async () => { + using dir = tempDir("bun-write-mode", { "out.txt": "the old contents" }); + const dest = join(String(dir), "out.txt"); + fs.chmodSync(dest, before); + const seen = []; + await Bun.write( + dest, + wrap( + new ReadableStream({ + pull(controller) { + const stat = fs.statSync(dest); + seen.push([(stat.mode & 0o777).toString(8), stat.size]); + controller.enqueue(new TextEncoder().encode("hello")); + controller.close(); + }, + }), + ), + { mode }, + ); + expect({ seen, content: fs.readFileSync(dest, "utf8") }).toEqual({ + seen: [[mode.toString(8), 0]], + content: "hello", + }); + }); + } + } + + // A FIFO has no permissions of its own to set and nothing to discard. + test("leaves a destination that is not a regular file as it is", async () => { + using dir = tempDir("bun-write-mode", {}); + const fifo = join(String(dir), "fifo"); + mkfifo(fifo, 0o644); + fs.chmodSync(fifo, 0o644); + const reader = fs.openSync(fifo, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); + try { + const written = [ + await Bun.write(fifo, "hello", { mode: 0o600 }), + await Bun.write(fifo, new Blob(["hello"]), { mode: 0o600 }), + await Bun.write(fifo, stream(), { mode: 0o600 }), + ]; + const buffer = Buffer.alloc(64); + const read = buffer.subarray(0, fs.readSync(reader, buffer)).toString(); + expect({ written, mode: modeOf(fifo).toString(8), read }).toEqual({ + written: [5, 5, 5], + mode: "644", + read: "hellohellohello", + }); + } finally { + fs.closeSync(reader); + } + }); + + // `mode` is for a file that Bun opens by path. A descriptor that the caller opened keeps its mode, + // and a write that is not empty keeps the length of its file. + for (const [door, write] of [ + ["Bun.write(fd)", (fd, data, options) => Bun.write(fd, data, options)], + ["Bun.write(Bun.file(fd))", (fd, data, options) => Bun.write(Bun.file(fd), data, options)], + ["Bun.file(fd).write()", (fd, data, options) => Bun.file(fd).write(data, options)], + ]) { + test(`${door} leaves the mode of the caller's file alone`, async () => { + using dir = tempDir("bun-write-mode-fd", {}); + const old = large + "-the-old-tail"; + const all = [...sources, fileSource]; + const results = {}; + for (const [label, make, contents] of all) { + const dest = join(String(dir), label); + fs.writeFileSync(dest, old); + fs.chmodSync(dest, 0o644); + for (const mode of [0o600, 0o666]) { + const fd = fs.openSync(dest, "r+"); + try { + await write(fd, make(dir), { mode }); + } finally { + fs.closeSync(fd); + } + } + results[label] = [modeOf(dest).toString(8), contents === "" || fs.statSync(dest).size === old.length]; + } + expect(results).toEqual(forEachSource(all, ["644", true])); + }); + } + + // Linux opens `/dev/fd/N` and `/dev/stdout` as the file again, so they are paths like any other. + // macOS and FreeBSD open a duplicate of the descriptor. When it is in append mode or past offset 0, + // its file gets the mode and keeps its contents, as it does without a mode. + const opensAsDup = isMacOS || isFreeBSD; + const throughDescriptorPath = contents => ["600", opensAsDup ? "old" : contents]; + const seen = target => [ + modeOf(target).toString(8), + fs.readFileSync(target, "utf8").slice(0, opensAsDup ? 3 : undefined), + ]; + // [label, the path that names descriptor `fd`]. + const spellings = [ + ["/dev/fd/N", fd => `/dev/fd/${fd}`], + ["/dev//fd/N", fd => `/dev//fd/${fd}`], + [ + "a symlink to /dev/fd/N", + (fd, link) => { + fs.symlinkSync(`/dev/fd/${fd}`, link); + return link; + }, + ], + ]; + + for (const [door, write] of doors) { + for (const [spelling, spell] of spellings) { + test(`${door} to ${spelling}`, async () => { + using dir = tempDir("bun-write-mode-dev-fd", {}); + const cases = [sources[0], sources[6], sources[8]]; + const results = {}; + for (const [label, make, contents] of cases) { + const target = join(String(dir), label); + fs.writeFileSync(target, "old"); + fs.chmodSync(target, 0o644); + const fd = fs.openSync(target, "a"); + try { + await write(spell(fd, `${target}-link`), make(), { mode: 0o600 }); + } finally { + fs.closeSync(fd); + } + results[label] = seen(target); + } + expect(results).toEqual( + Object.fromEntries(cases.map(([label, , contents]) => [label, throughDescriptorPath(contents)])), + ); + }); + } + } + + test("Bun.write to /dev/fd/N that is past offset 0", async () => { + using dir = tempDir("bun-write-mode-dev-fd", { "target.txt": "old" }); + const target = join(String(dir), "target.txt"); + fs.chmodSync(target, 0o644); + const fd = fs.openSync(target, "r+"); + try { + fs.writeSync(fd, "X"); + await Bun.write(`/dev/fd/${fd}`, new Blob(["hello"]), { mode: 0o600 }); + } finally { + fs.closeSync(fd); + } + expect([modeOf(target).toString(8), fs.readFileSync(target, "utf8")]).toEqual([ + "600", + opensAsDup ? "Xhello" : "hello", + ]); + }); + + test("Bun.write to /dev/stdin, /dev/stdout and /dev/stderr", async () => { + using dir = tempDir("bun-write-mode-dev-std", { "stdin.txt": "old", "stdout.txt": "old", "stderr.txt": "old" }); + const targets = ["stdin", "stdout", "stderr"].map(name => join(String(dir), `${name}.txt`)); + for (const target of targets) fs.chmodSync(target, 0o644); + const fds = targets.map(target => fs.openSync(target, "a")); + try { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + await Bun.write("/dev/stdin", "hello", { mode: 0o600 }); + await Bun.write("/dev/stdout", new Blob(["hello"]), { mode: 0o600 }); + await Bun.write("/dev/stderr", "hello", { mode: 0o600 }); + `, + ], + env: bunEnv, + stdin: fds[0], + stdout: fds[1], + stderr: fds[2], + }); + const exitCode = await proc.exited; + expect({ + stdin: seen(targets[0]), + stdout: seen(targets[1]), + // The child can print to stderr itself, so only the mode counts here. + stderr: modeOf(targets[2]).toString(8), + exitCode, + }).toEqual({ + stdin: throughDescriptorPath("hello"), + stdout: throughDescriptorPath("hello"), + stderr: throughDescriptorPath("hello")[0], + exitCode: 0, + }); + } finally { + for (const fd of fds) fs.closeSync(fd); + } + }); + + // The shim replaces libc's chmod family in a child, so the tests below need no second user: + // a refused chmod is what a caller gets for a file it can write but does not own. + const shimSource = ` +#define _GNU_SOURCE +#include +#include +#include +#include +#include +int fchmod(int fd, mode_t mode) { + static int (*real)(int, mode_t); + struct stat st; + if (getenv("BUN_TEST_REFUSE_CHMOD")) { errno = EPERM; return -1; } + if (!real) real = dlsym(RTLD_NEXT, "fchmod"); + if (fstat(fd, &st) == 0) dprintf(2, "[fchmod] %o %lld\\n", (unsigned)mode, (long long)st.st_size); + return real(fd, mode); +} +int chmod(const char *path, mode_t mode) { (void)path; (void)mode; errno = EPERM; return -1; } +int fchmodat(int dirfd, const char *path, mode_t mode, int flags) { + (void)dirfd; (void)path; (void)mode; (void)flags; errno = EPERM; return -1; +} +`; + // One child writes every source to its own file. `seed` is [contents, mode], or null for a new file. + const runWithShim = async (dir, refuse, seed, mode) => { + const shim = join(String(dir), "shim.so"); + fs.writeFileSync(join(String(dir), "shim.c"), shimSource); + await using compile = Bun.spawn({ + cmd: [cc, "-shared", "-fPIC", "-o", shim, join(String(dir), "shim.c"), "-ldl"], + env: bunEnv, + stderr: "pipe", + }); + const [compileError, compileExit] = await Promise.all([compile.stderr.text(), compile.exited]); + if (compileExit !== 0) throw new Error(`shim compile failed: ${compileError}`); + + const names = sources.map(([label]) => label.replaceAll(" ", "-")); + if (seed) { + for (const name of names) { + fs.writeFileSync(join(String(dir), name), seed[0]); + fs.chmodSync(join(String(dir), name), seed[1]); + } + } + const preload = bunEnv.LD_PRELOAD ? `${shim}:${bunEnv.LD_PRELOAD}` : shim; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const fs = require("node:fs"); + const stream = () => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello")); + controller.close(); + }, + }); + const large = Buffer.alloc(300 * 1024, "a").toString(); + const sources = { + "small-string": () => "hello", + "empty-string": () => "", + "small-Uint8Array": () => new Uint8Array([104, 105]), + "empty-Uint8Array": () => new Uint8Array(0), + "large-string": () => large, + "large-Uint8Array": () => Buffer.from(large), + "Blob": () => new Blob(["hello"]), + "empty-Blob": () => new Blob([]), + "ReadableStream": stream, + "buffered-Response": () => new Response("hello"), + "streaming-Response": () => new Response(stream()), + }; + const results = {}; + for (const name in sources) { + fs.writeSync(2, "@" + name + "\\n"); + let outcome = "resolved"; + try { + await Bun.write(name, sources[name](), { mode: ${mode} }); + } catch (e) { + outcome = [e.code, e.syscall, e.path].join(" "); + } + const stat = fs.statSync(name); + results[name] = [outcome, (stat.mode & 0o777).toString(8), stat.size]; + } + console.log(JSON.stringify(results)); + `, + ], + env: { ...bunEnv, LD_PRELOAD: preload, ...(refuse ? { BUN_TEST_REFUSE_CHMOD: "1" } : {}) }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The chmod calls of each write, as "mode size-of-the-file-at-that-moment". + const calls = {}; + const unexpected = []; + let current; + for (const line of stderr.split("\n")) { + if (line.startsWith("@")) calls[(current = line.slice(1))] = []; + else if (line.startsWith("[fchmod] ")) calls[current].push(line.slice("[fchmod] ".length)); + else if (line) unexpected.push(line); + } + expect(unexpected).toEqual([]); + expect(exitCode).toBe(0); + return { results: JSON.parse(stdout), calls, names }; + }; + const forEachName = (names, value) => + Object.fromEntries(names.map(name => [name, typeof value === "function" ? value(name) : value])); + // The synchronous path writes in place, trims to the written length, then adds bits. + // The other engines discard the old contents first. + const sizeWhenWidened = name => ({ "small-string": 5, "small-Uint8Array": 2 })[name] ?? 0; + + describe.skipIf(!isLinux || !cc)("with libc's chmod replaced", () => { + test("a refused chmod rejects the write and leaves an existing file as it was", async () => { + using narrowing = tempDir("bun-write-mode-refused", {}); + const narrowed = await runWithShim(narrowing, true, ["OLD", 0o666], 0o600); + expect(narrowed.results).toEqual(forEachName(narrowed.names, name => [`EPERM fchmod ${name}`, "666", 3])); + + using widening = tempDir("bun-write-mode-refused", {}); + const widened = await runWithShim(widening, true, ["OLD", 0o600], 0o644); + expect(widened.results).toEqual(forEachName(widened.names, name => [`EPERM fchmod ${name}`, "600", 3])); + }); + + test("no chmod is needed when the file already has the mode", async () => { + using dir = tempDir("bun-write-mode-refused", {}); + const existing = await runWithShim(dir, true, ["OLD-OLD-OLD-OLD-OLD", 0o640], 0o640); + expect(Object.values(existing.results).map(([outcome, mode]) => [outcome, mode])).toEqual( + existing.names.map(() => ["resolved", "640"]), + ); + // A new file gets 0o600 from open(2) under any umask that keeps the owner bits. + using created = tempDir("bun-write-mode-refused", {}); + const fresh = await runWithShim(created, true, null, 0o600); + expect(Object.values(fresh.results).map(([outcome, mode]) => [outcome, mode])).toEqual( + fresh.names.map(() => ["resolved", "600"]), + ); + }); + + test("bits that the mode removes go before the old contents, bits that it adds come after", async () => { + using narrowing = tempDir("bun-write-mode-order", {}); + const narrowed = await runWithShim(narrowing, false, ["0123456789", 0o644], 0o600); + expect(narrowed.calls).toEqual(forEachName(narrowed.names, ["600 10"])); + + using widening = tempDir("bun-write-mode-order", {}); + const widened = await runWithShim(widening, false, ["0123456789", 0o600], 0o644); + expect(widened.calls).toEqual(forEachName(widened.names, name => ["600 10", `644 ${sizeWhenWidened(name)}`])); + + using mixing = tempDir("bun-write-mode-order", {}); + const mixed = await runWithShim(mixing, false, ["0123456789", 0o604], 0o640); + expect(mixed.calls).toEqual(forEachName(mixed.names, name => ["600 10", `640 ${sizeWhenWidened(name)}`])); + }); + }); +}); diff --git a/test/regression/issue/25903.test.ts b/test/regression/issue/25903.test.ts index 02a7cebcea3f..0d1037723707 100644 --- a/test/regression/issue/25903.test.ts +++ b/test/regression/issue/25903.test.ts @@ -141,7 +141,5 @@ describe.skipIf(isWindows)("Bun.write() mode option", () => { }); }); // 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. +// These tests validate the Bun.file() copy path. The string/buffer/Blob write paths +// are covered by the "Bun.write mode option" suite in test/js/bun/io/bun-write.test.js. From 806df88d9df45ff7c1a8e0eb518a8deb11f96872 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:23:01 +0000 Subject: [PATCH 2/2] bun-types: document `mode` on every `Bun.write` overload and on `BunFile.write` --- packages/bun-types/bun.d.ts | 136 ++++++++++++++++++++-- test/integration/bun-types/fixture/bun.ts | 7 ++ 2 files changed, 135 insertions(+), 8 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 59aa530c8e58..6a68c0cb7dfa 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2153,7 +2153,28 @@ declare module "bun" { input: Blob | NodeJS.TypedArray | ArrayBufferLike | string | BlobPart[] | Archive | ReadableStream, options?: { /** - * If writing to a PathLike, set the permissions of the file. + * Set the Unix permissions of the destination file to exactly this mode. + * The process umask does not apply, and a file that already exists is + * changed too. + * + * The new data is never readable through permissions that the mode does + * not give. If the operating system refuses the change, the write is + * rejected and an existing file keeps its contents. A `Bun.file()` input + * is the exception: it is copied first, and the mode is set after the + * copy. + * + * If omitted, an existing file keeps its permissions, and a new file gets + * the default ones, which the process umask limits (typically 0o644). + * + * It has no effect when the destination is a file descriptor. For an input + * other than a `Bun.file()`, it also has no effect on a FIFO or a device. + * Windows has no Unix permissions, so the mode is not applied in this way + * there. + * + * Must be an integer from 0 to 0o777 (511 in decimal). + * + * @throws {TypeError} If the mode is not a number. + * @throws {RangeError} If the mode is not an integer in that range. */ mode?: number; /** @@ -2183,6 +2204,31 @@ declare module "bun" { destination: BunFile, input: Response | Request, options?: { + /** + * Set the Unix permissions of the destination file to exactly this mode. + * The process umask does not apply, and a file that already exists is + * changed too. + * + * The new data is never readable through permissions that the mode does + * not give. If the operating system refuses the change, the write is + * rejected and an existing file keeps its contents. A `Bun.file()` input + * is the exception: it is copied first, and the mode is set after the + * copy. + * + * If omitted, an existing file keeps its permissions, and a new file gets + * the default ones, which the process umask limits (typically 0o644). + * + * It has no effect when the destination is a file descriptor. For an input + * other than a `Bun.file()`, it also has no effect on a FIFO or a device. + * Windows has no Unix permissions, so the mode is not applied in this way + * there. + * + * Must be an integer from 0 to 0o777 (511 in decimal). + * + * @throws {TypeError} If the mode is not a number. + * @throws {RangeError} If the mode is not an integer in that range. + */ + mode?: number; /** * If `true`, create the parent directory if it doesn't exist. * @@ -2208,6 +2254,31 @@ declare module "bun" { destinationPath: PathLike, input: Response | Request, options?: { + /** + * Set the Unix permissions of the destination file to exactly this mode. + * The process umask does not apply, and a file that already exists is + * changed too. + * + * The new data is never readable through permissions that the mode does + * not give. If the operating system refuses the change, the write is + * rejected and an existing file keeps its contents. A `Bun.file()` input + * is the exception: it is copied first, and the mode is set after the + * copy. + * + * If omitted, an existing file keeps its permissions, and a new file gets + * the default ones, which the process umask limits (typically 0o644). + * + * It has no effect when the destination is a file descriptor. For an input + * other than a `Bun.file()`, it also has no effect on a FIFO or a device. + * Windows has no Unix permissions, so the mode is not applied in this way + * there. + * + * Must be an integer from 0 to 0o777 (511 in decimal). + * + * @throws {TypeError} If the mode is not a number. + * @throws {RangeError} If the mode is not an integer in that range. + */ + mode?: number; /** * If `true`, create the parent directory if it doesn't exist. * @@ -2242,12 +2313,23 @@ declare module "bun" { input: BunFile, options?: { /** - * Set the file permissions of the destination when it is created or overwritten. + * Set the Unix permissions of the destination file to exactly this mode. + * The process umask does not apply, and a file that already exists is + * changed too. + * + * The file is copied first, and the mode is set after the copy. If the + * operating system refuses the change, the write is rejected after the + * copy. * - * 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). + * It has no effect when the destination is a file descriptor. Windows has + * no Unix permissions, so the mode is not applied in this way there. + * + * Must be an integer from 0 to 0o777 (511 in decimal). + * + * @throws {TypeError} If the mode is not a number. + * @throws {RangeError} If the mode is not an integer in that range. * * @example * ```ts @@ -2288,12 +2370,23 @@ declare module "bun" { input: BunFile, options?: { /** - * Set the file permissions of the destination when it is created or overwritten. + * Set the Unix permissions of the destination file to exactly this mode. + * The process umask does not apply, and a file that already exists is + * changed too. + * + * The file is copied first, and the mode is set after the copy. If the + * operating system refuses the change, the write is rejected after the + * copy. * - * 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). + * It has no effect when the destination is a file descriptor. Windows has + * no Unix permissions, so the mode is not applied in this way there. + * + * Must be an integer from 0 to 0o777 (511 in decimal). + * + * @throws {TypeError} If the mode is not a number. + * @throws {RangeError} If the mode is not an integer in that range. * * @example * ```ts @@ -2759,7 +2852,34 @@ declare module "bun" { */ write( data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer | Request | Response | BunFile | ReadableStream, - options?: { highWaterMark?: number }, + options?: { + highWaterMark?: number; + /** + * Set the Unix permissions of this file to exactly this mode. + * The process umask does not apply, and a file that already exists is + * changed too. + * + * The new data is never readable through permissions that the mode does + * not give. If the operating system refuses the change, the write is + * rejected and an existing file keeps its contents. A `Bun.file()` input + * is the exception: it is copied first, and the mode is set after the + * copy. + * + * If omitted, an existing file keeps its permissions, and a new file gets + * the default ones, which the process umask limits (typically 0o644). + * + * It has no effect when this file is a file descriptor. For an input + * other than a `Bun.file()`, it also has no effect on a FIFO or a device. + * Windows has no Unix permissions, so the mode is not applied in this way + * there. + * + * Must be an integer from 0 to 0o777 (511 in decimal). + * + * @throws {TypeError} If the mode is not a number. + * @throws {RangeError} If the mode is not an integer in that range. + */ + mode?: number; + }, ): Promise; /** diff --git a/test/integration/bun-types/fixture/bun.ts b/test/integration/bun-types/fixture/bun.ts index f33b1b135a04..a54ff257df0c 100644 --- a/test/integration/bun-types/fixture/bun.ts +++ b/test/integration/bun-types/fixture/bun.ts @@ -103,3 +103,10 @@ tsd.expectType(Bun.mmap("./data.bin", { size: 1024 })).is>(); tsd.expectType(webview.goForward()).is>(); + +tsd.expectType(Bun.write("out.txt", "data", { mode: 0o600 })).is>(); +tsd.expectType(Bun.write("out.txt", new Response("data"), { mode: 0o600 })).is>(); +tsd + .expectType(Bun.write(Bun.file("out.txt"), new Response("data"), { mode: 0o600, createPath: false })) + .is>(); +tsd.expectType(Bun.file("out.txt").write("data", { mode: 0o600 })).is>();