diff --git a/src/CLAUDE.md b/src/CLAUDE.md index 01e4d563a11d..b98349ec5c16 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -58,6 +58,7 @@ Key types and functions: - `Fd` (`bun_core::Fd`, re-exported) — cross-platform file descriptor. `Fd::cwd()`, `Fd::stdin()/stdout()/stderr()`, `fd.close()`. - `File::open(path: &ZStr, flags, mode)` / `File::openat(dir: Fd, path: &[u8], flags, mode)` / `File::make_open(...)` (creates parent dirs) / `File::create(dir, path, truncate)` - `file.read(buf)` / `read_all(buf)` / `read_to_end()` / `read_to_end_small()` / `write(buf)` / `write_all(buf)` +- `File::read_from(dir, path)` and `File::open_regular_at(dir, path)` accept regular files only (a FIFO would block the open, `/dev/zero` has no end). A file bun reads whole because it found it (a config file, a lockfile, a cache entry) goes through them. `File::read_from_any_file_type` is for a path the user named that may be a device, such as `--config=/dev/null`. - `bun_sys::open`, `read`, `write`, `pread`, `pwrite`, `stat`, `fstat`, `lstat`, `mkdir`, `unlink`, `rename`, `symlink`, `chmod` — free fns over `Fd` - Open flags: `bun_sys::O::RDONLY`, `O::WRONLY | O::CREAT | O::TRUNC`, etc. @@ -327,10 +328,8 @@ canonical example of this bug class and its fix. ## Common Patterns ```rust -// Read a file, return JS error on failure -let contents = match bun_sys::File::openat(Fd::cwd(), path, O::RDONLY, 0) - .and_then(|f| f.read_to_end()) -{ +// Read a file (regular files only, see bun_sys above), return JS error on failure +let contents = match bun_sys::File::read_from(Fd::cwd(), path) { Ok(bytes) => bytes, Err(err) => return Ok(err.to_js(global)?), }; diff --git a/src/ast/lib.rs b/src/ast/lib.rs index d0972beba7f3..29830af54f9a 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -2796,6 +2796,8 @@ fn range_data_text(source: Option<&Source>, r: Range, text: Cow<'static, [u8]>) #[derive(Default, Clone, Copy)] pub struct ToSourceOptions { pub convert_bom: bool, + /// Read a device too ([`bun_sys::File::read_from_any_file_type`]): only for a path the user named. + pub any_file_type: bool, } /// Read `path` (rooted at cwd) into memory and wrap it in a `Source`. @@ -2813,7 +2815,11 @@ fn source_from_file_at( path: &bun_core::ZStr, opts: ToSourceOptions, ) -> bun_sys::Maybe { - let mut bytes = bun_sys::file::File::read_from(dir_fd, path)?; + let mut bytes = if opts.any_file_type { + bun_sys::file::File::read_from_any_file_type(dir_fd, path)? + } else { + bun_sys::file::File::read_from(dir_fd, path)? + }; if opts.convert_bom { if let Some(bom) = bun_core::strings::BOM::detect(&bytes) { bytes = bom.remove_and_convert_to_utf8_and_free(bytes); diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index d0a388d3ef7c..bea7c0b46d07 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -41,21 +41,25 @@ fn load_bunfig( config_path: &ZStr, ctx: Context<'_>, ) -> Result<(), crate::Error> { - let source = - match bun_ast::to_source(config_path, bun_ast::ToSourceOptions { convert_bom: true }) { - Ok(s) => s, - Err(err) => { - if auto_loaded { - return Ok(()); - } - bun_core::pretty_errorln!( - "{}\nwhile reading config \"{}\"", - err, - BStr::new(config_path.as_bytes()), - ); - Global::exit(1); + let options = bun_ast::ToSourceOptions { + convert_bom: true, + // `--config=/dev/null` is a way to run without a config. + any_file_type: !auto_loaded, + }; + let source = match bun_ast::to_source(config_path, options) { + Ok(s) => s, + Err(err) => { + if auto_loaded { + return Ok(()); } - }; + bun_core::pretty_errorln!( + "{}\nwhile reading config \"{}\"", + err, + BStr::new(config_path.as_bytes()), + ); + Global::exit(1); + } + }; bun_ast::stmt::data::Store::create(); bun_ast::expr::data::Store::create(); diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index ff5f900e1bda..48a25d35d1d7 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -798,33 +798,32 @@ impl Loader { // `bun_sys` is errno-based; the match arms below group the recoverable // errnos. Any errno not listed propagates. - let file = - match bun_sys::File::openat(dir, base, bun_sys::O::RDONLY | bun_sys::O::CLOEXEC, 0) { - Ok(file) => file, - Err(err) => { - use bun_sys::E; - match err.get_errno() { - E::EISDIR | E::ENOENT => { - // prevent retrying - self.default_files_loaded.insert(env_file); - return Ok(()); - } - E::EBUSY | E::EACCES => { - if !self.quiet { - bun_core::pretty_errorln!( - "{} error loading {} file", - bstr::BStr::new(err.name()), - bstr::BStr::new(base) - ); - } - // prevent retrying - self.default_files_loaded.insert(env_file); - return Ok(()); + let file = match bun_sys::File::open_regular_at(dir, base) { + Ok((file, _)) => file, + Err(err) => { + use bun_sys::E; + match err.get_errno() { + E::EISDIR | E::ENODEV | E::ENOENT => { + // prevent retrying + self.default_files_loaded.insert(env_file); + return Ok(()); + } + E::EBUSY | E::EACCES => { + if !self.quiet { + bun_core::pretty_errorln!( + "{} error loading {} file", + bstr::BStr::new(err.name()), + bstr::BStr::new(base) + ); } - _ => return Err(err.into()), + // prevent retrying + self.default_files_loaded.insert(env_file); + return Ok(()); } + _ => return Err(err.into()), } - }; + } + }; match read_env_file_contents(&file)? { ReadEnvFile::Empty => {} diff --git a/src/ini/lib.rs b/src/ini/lib.rs index fed27f26583d..54ec29e7945e 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1261,7 +1261,10 @@ mod draft { for &npmrc_path in npmrc_paths { let source = match bun_ast::source_from_file( npmrc_path, - bun_ast::ToSourceOptions { convert_bom: true }, + bun_ast::ToSourceOptions { + convert_bom: true, + ..Default::default() + }, ) { Ok(s) => s, Err(err) => { diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index a999c8d2a073..bb81dbd96295 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -161,11 +161,11 @@ impl NodeModulesFolder { ) -> bun_sys::Result { let mut path_buf = PathBuffer::uninit(); let parts: [&[u8]; 2] = [self.path.as_slice(), file_path.as_bytes()]; - root_node_modules_dir.open_file( + bun_sys::File::open_regular_at( + root_node_modules_dir, join_z_buf::(path_buf.as_mut_slice(), &parts), - bun_sys::O::RDONLY, - 0, ) + .map(|(file, _)| file) } pub(crate) fn read_small_file( @@ -208,8 +208,9 @@ impl NodeModulesFolder { } let dir = self.open_dir(root_node_modules_dir)?; - let res = dir.open_file(file_path, bun_sys::O::RDONLY, 0); - res.map_err(|e| e.to_zig_err().into()) + bun_sys::File::open_regular_at(&dir, file_path) + .map(|(file, _)| file) + .map_err(|e| e.to_zig_err().into()) } pub(crate) fn open_dir(&self, root: &Dir) -> crate::Result { diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index ed5bf8a3dd28..2b53041c3eeb 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1587,6 +1587,11 @@ pub fn init( this_cwd.len() + b"/package.json".len(), ); + // Only locates the project: like its readers (`File::open_regular_at`), never block on a FIFO. + #[cfg(unix)] + let flags = bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK; + #[cfg(not(unix))] + let flags = bun_sys::O::CLOEXEC; match bun_sys::File::openat( bun_sys::Fd::cwd(), package_json_path.as_bytes(), @@ -1594,7 +1599,7 @@ pub fn init( bun_sys::O::RDWR } else { bun_sys::O::RDONLY - } | bun_sys::O::CLOEXEC, + } | flags, 0, ) { Ok(f) => break 'child f, diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 2fa85d1f5d98..e6094ef09618 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -506,9 +506,9 @@ impl Lockfile { debug_assert!(Fs::INSTANCE_LOADED.load(core::sync::atomic::Ordering::Relaxed)); let mut lockfile_format = LockfileFormat::Text; - let file: File = 'file: { - match File::openat(dir, zstr!("bun.lock"), sys::O::RDONLY, 0) { - sys::Result::Ok(f) => break 'file f, + let (file, size): (File, u64) = 'file: { + match File::open_regular_at(dir, zstr!("bun.lock")) { + sys::Result::Ok(opened) => break 'file opened, sys::Result::Err(text_open_err) => { if text_open_err.errno != sys::SystemErrno::ENOENT as u16 { return LoadResult::Err(LoadResultErr { @@ -521,8 +521,8 @@ impl Lockfile { lockfile_format = LockfileFormat::Binary; - match File::openat(dir, zstr!("bun.lockb"), sys::O::RDONLY, 0) { - sys::Result::Ok(f) => break 'file f, + match File::open_regular_at(dir, zstr!("bun.lockb")) { + sys::Result::Ok(opened) => break 'file opened, sys::Result::Err(binary_open_err) => { if binary_open_err.errno != sys::SystemErrno::ENOENT as u16 { return LoadResult::Err(LoadResultErr { @@ -549,9 +549,7 @@ impl Lockfile { } }; - // `bun_sys::File::read_to_end` returns `Maybe>` - // (fstat-presized, pread-from-0); map the error arm to `.read_file`. - let buf = match file.read_to_end() { + let buf = match file.read_to_end_sized(size) { Ok(bytes) => bytes, Err(e) => { return LoadResult::Err(LoadResultErr { @@ -1833,14 +1831,8 @@ impl Lockfile { } break 'bytes bytes; }; - if File::openat( - Fd::cwd(), - save_format.filename().as_bytes(), - sys::O::RDONLY, - 0, - ) - .and_then(|existing| existing.read_to_end()) - .is_ok_and(|existing| existing == bytes) + if File::read_from(Fd::cwd(), save_format.filename()) + .is_ok_and(|existing| existing == bytes) { return false; } diff --git a/src/install/migration.rs b/src/install/migration.rs index dd5270fe8424..d32e2fd4ebec 100644 --- a/src/install/migration.rs +++ b/src/install/migration.rs @@ -5,7 +5,7 @@ use bun_core::{Output, zstr}; use bun_paths::PathBuffer; use bun_semver::query::token::Wildcard; use bun_semver::{self as Semver, SlicedString}; -use bun_sys::{self, Fd, File, O}; +use bun_sys::{self, Fd, File}; use crate::install::{self as Install, PackageManager, Subcommand}; use crate::lockfile::{ @@ -38,7 +38,7 @@ pub fn detect_and_load_other_lockfile<'a>( 'npm: { let timer = std::time::Instant::now(); - let Ok(lockfile) = File::openat(dir, b"package-lock.json", O::RDONLY, 0) else { + let Ok((lockfile, size)) = File::open_regular_at(dir, b"package-lock.json") else { break 'npm; }; // file closes on Drop @@ -48,7 +48,7 @@ pub fn detect_and_load_other_lockfile<'a>( break 'npm; }; let lockfile_path: &[u8] = &*lockfile_path; - let Ok(data) = lockfile.read_to_end() else { + let Ok(data) = lockfile.read_to_end_sized(size) else { break 'npm; }; let migrate_result = diff --git a/src/install/migration/npm_lock.rs b/src/install/migration/npm_lock.rs index 263077dfa258..b2d61cc9f7a9 100644 --- a/src/install/migration/npm_lock.rs +++ b/src/install/migration/npm_lock.rs @@ -7,7 +7,7 @@ use bun_install_types::DependencyGroup; use bun_paths::resolve_path; use bun_semver::query::token::Wildcard; use bun_semver::{self as Semver, SlicedString, String as SemverString}; -use bun_sys::{Fd, File, O}; +use bun_sys::{Fd, File}; use super::{package_name_from_path, pkg_flag_is_true, string_hash}; use crate::Error; @@ -1017,13 +1017,9 @@ pub(super) fn apply_root_overrides( workspace_map: Option<&WorkspaceMap>, abs_lockfile_path: &[u8], ) -> Result<(), Error> { - let Ok(file) = File::openat(dir, b"package.json", O::RDONLY, 0) else { + let Ok(contents) = File::read_from(dir, b"package.json") else { return Ok(()); }; - let Ok(contents) = file.read_to_end() else { - return Ok(()); - }; - drop(file); let mut package_json_path_buf = bun_paths::path_buffer_pool::get(); let package_json_path = resolve_path::join_string_buf::( diff --git a/src/install/npm.rs b/src/install/npm.rs index c3f2c4d93f53..2554956b207e 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -1384,7 +1384,8 @@ pub mod package_manifest { ) -> Result, Error> { let mut file_path_buf = [0u8; 512 + 64]; let file_name = Self::manifest_file_name(&mut file_path_buf, file_id, scope)?; - let Ok(cache_file) = File::openat(cache_dir, file_name, bun_sys::O::RDONLY, 0) else { + // A non-regular entry counts as missing too: the save after the fetch replaces it. + let Ok((cache_file, _)) = File::open_regular_at(cache_dir, file_name) else { return Ok(None); }; diff --git a/src/install/repository.rs b/src/install/repository.rs index d48f93518218..d995b78b5a6d 100644 --- a/src/install/repository.rs +++ b/src/install/repository.rs @@ -129,7 +129,10 @@ impl SloppyGlobalGitConfig { // MOVE_DOWN: `File::toSource` lives in `bun_logger` (T1→T2 cyclebreak). let Ok(source) = bun_ast::to_source( config_file_path, - bun_ast::ToSourceOptions { convert_bom: true }, + bun_ast::ToSourceOptions { + convert_bom: true, + ..Default::default() + }, ) else { return SloppyGlobalGitConfig::default(); }; diff --git a/src/install/resolvers/folder_resolver.rs b/src/install/resolvers/folder_resolver.rs index d75d0c9c96df..e36ced77524f 100644 --- a/src/install/resolvers/folder_resolver.rs +++ b/src/install/resolvers/folder_resolver.rs @@ -5,7 +5,7 @@ use bun_core::{ZStr, strings}; use bun_paths::{self, MAX_PATH_BYTES, PathBuffer, SEP, SEP_STR}; use bun_resolver::fs::FileSystem; use bun_semver::{self as semver, String as SemverString}; -use bun_sys::{self, Fd, File, O}; +use bun_sys::{self, Fd, File}; use crate::bun_json::Expr; use crate::dependency::{self}; @@ -320,7 +320,7 @@ fn read_package_json_from_disk( bun_perf::trace(bun_perf::PerfEvent::FolderResolverReadPackageJSONFromDiskFolder); let source = { - let file = File::openat(Fd::cwd(), abs.as_bytes(), O::RDONLY, 0)?; + let (file, _) = File::open_regular_at(Fd::cwd(), abs.as_bytes())?; // defer file.close() body.reset(); let read_result = file diff --git a/src/install/yarn.rs b/src/install/yarn.rs index 542067033283..a9ee37b66b47 100644 --- a/src/install/yarn.rs +++ b/src/install/yarn.rs @@ -631,11 +631,10 @@ pub(crate) fn migrate_yarn_lockfile<'a>( let mut root_dependencies: Vec = Vec::new(); // read package.json to get specified dependencies - let Ok(package_json_fd) = bun_sys::File::openat(dir, b"package.json", bun_sys::O::RDONLY, 0) - else { + let Ok((package_json_fd, size)) = bun_sys::File::open_regular_at(dir, b"package.json") else { return Err(crate::Error::InvalidPackageJSON); }; - let Ok(package_json_contents) = package_json_fd.read_to_end() else { + let Ok(package_json_contents) = package_json_fd.read_to_end_sized(size) else { return Err(crate::Error::InvalidPackageJSON); }; diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index f978022d1736..1b48daef50f2 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -287,11 +287,7 @@ impl BunxCommand { dir_fd: Fd, subpath_z: &ZStr, ) -> crate::Result> { - let target_package_json_fd = bun_sys::openat(dir_fd, subpath_z, O::RDONLY, 0)?; - let target_package_json = bun_sys::File::from_fd(target_package_json_fd); - - // TODO: make this better - let package_json_bytes = target_package_json.read_to_end()?; + let package_json_bytes = bun_sys::File::read_from(dir_fd, subpath_z)?; let package_json_contents = package_json_bytes.as_slice(); let source = bun_ast::Source::init_path_string(subpath_z.as_bytes(), package_json_contents); @@ -417,11 +413,10 @@ impl BunxCommand { subpath[len] = 0; // SAFETY: subpath[len] == 0 written above let subpath_z = ZStr::from_buf(&subpath[..], len); - let target_package_json_fd = match bun_sys::openat(Fd::cwd(), subpath_z, O::RDONLY, 0) { - Ok(fd) => fd, + let target_package_json = match bun_sys::File::open_regular_at(Fd::cwd(), subpath_z) { + Ok((file, _)) => file, Err(_) => return Err(crate::Error::NeedToInstall), }; - let target_package_json = bun_sys::File::from_fd(target_package_json_fd); let is_stale: bool = 'is_stale: { #[cfg(windows)] @@ -432,7 +427,7 @@ impl BunxCommand { // SAFETY: FFI call with valid out-params let rc = unsafe { win::ntdll::NtQueryInformationFile( - target_package_json_fd.native(), + target_package_json.handle.native(), &mut io_status_block, (&mut info as *mut win::FILE_BASIC_INFORMATION).cast(), u32::try_from(size_of::()) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 1490b3cd74ca..a9735c357c9b 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -3689,8 +3689,8 @@ impl IgnorePatterns { let mut ignore_kind = IgnorePatternsKind::Npmignore; - let ignore_file: File = match dir.open_file(b".npmignore", bun_sys::O::RDONLY, 0) { - Ok(f) => f, + let (ignore_file, size): (File, u64) = match File::open_regular_at(dir, b".npmignore") { + Ok(opened) => opened, Err(err) => 'ignore_file: { if err.get_errno() != bun_sys::E::ENOENT { // Crash if the file exists and fails to open. Don't want to create a tarball @@ -3703,8 +3703,8 @@ impl IgnorePatterns { ); } ignore_kind = IgnorePatternsKind::Gitignore; - match dir.open_file(b".gitignore", bun_sys::O::RDONLY, 0) { - Ok(f) => break 'ignore_file f, + match File::open_regular_at(dir, b".gitignore") { + Ok(opened) => break 'ignore_file opened, Err(err2) => { if err2.get_errno() != bun_sys::E::ENOENT { Self::ignore_file_fail( @@ -3720,7 +3720,7 @@ impl IgnorePatterns { } }; - let contents = match ignore_file.read_to_end() { + let contents = match ignore_file.read_to_end_sized(size) { Ok(c) => c, Err(err) => { Self::ignore_file_fail(dir, ignore_kind, IgnoreFileFailReason::Read, err.into()); diff --git a/src/runtime/cli/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index d03bd50c0d34..41b2e31d20ba 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -541,14 +541,16 @@ impl TrustCommand { (*pm_raw).root_package_json_file.handle = bun_core::Fd::INVALID; bun_sys::File::from_fd(fd) }; - let package_json_contents = root_file.read_to_end().map_err(crate::Error::from)?; - // SAFETY: `ROOT_PACKAGE_JSON_PATH` is set during `PackageManager::init` // (single-threaded startup) and immutable thereafter. - let package_json_source = bun_ast::Source::init_path_string( - unsafe { ROOT_PACKAGE_JSON_PATH.read() }.as_bytes(), - package_json_contents.as_slice(), - ); + let package_json_path = unsafe { ROOT_PACKAGE_JSON_PATH.read() }.as_bytes(); + let package_json_contents = root_file + .ensure_regular(package_json_path) + .and_then(|size| root_file.read_to_end_sized(size)) + .map_err(crate::Error::from)?; + + let package_json_source = + bun_ast::Source::init_path_string(package_json_path, package_json_contents.as_slice()); let bump = Bump::new(); // SAFETY: `ctx.log` set by `Command::init`, non-null for the command. diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 9269a6e9ca86..befa91f75306 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -1546,53 +1546,19 @@ impl PipelineTask { // SAFETY: `p` borrows `image.source.path`, which outlives the task // because `this_ref` is held Strong while pending_tasks > 0. let p: &ZStr = unsafe { &*p }; - // The path string came straight from the constructor, so treat - // it as untrusted: open + fstat first instead of `readFrom`. - // • !S_ISREG → ENODEV. `/dev/zero`/`/dev/urandom` would - // otherwise pread forever (st_size=0, never returns 0) until - // the doubling Vec OOMs the process; a FIFO with no writer - // would park this WorkPool thread in-kernel forever. - // • st_size cap → file-based decompression-bomb fails up - // front with a clear error instead of materialising a - // multi-GB encoded buffer before `maxPixels` even runs. - // O_NONBLOCK so the open itself can't block on a FIFO. POSIX-only: - // on Windows it omits FILE_SYNCHRONOUS_IO_NONALERT (overlapped - // handle) and the subsequent sync read fails EINVAL. Windows has - // no open-blocking FIFOs in the same sense; the !S_ISREG check - // below still rejects pipes/devices. - #[cfg(unix)] - let oflags = sys::O::RDONLY | sys::O::NONBLOCK; - #[cfg(not(unix))] - let oflags = sys::O::RDONLY; - let file = match sys::File::openat(sys::Fd::cwd(), p, oflags, 0) { - sys::Result::Ok(f) => f, + let (file, size) = match sys::File::open_regular_at(sys::Fd::cwd(), p) { + sys::Result::Ok(opened) => opened, sys::Result::Err(e) => { self.result = TaskResult::IoErr(e.with_path(p.as_bytes())); return; } }; - // `defer file.close()` — assume `sys::File` closes on Drop. - let st = match file.stat() { - sys::Result::Ok(s) => s, - sys::Result::Err(e) => { - self.result = TaskResult::IoErr(e.with_path(p.as_bytes())); - return; - } - }; - if !sys::S::ISREG(st.st_mode as _) { - self.result = TaskResult::IoErr(sys::Error { - errno: sys::E::ENODEV as _, - syscall: sys::Tag::read, - path: p.as_bytes().to_vec().into_boxed_slice(), - ..Default::default() - }); - return; - } - if u64::try_from(st.st_size.max(0)).expect("int cast") > MAX_INPUT_FILE_BYTES { + // A decompression bomb on disk fails before its encoded bytes are even read. + if size > MAX_INPUT_FILE_BYTES { self.result = TaskResult::Err(codecs::Error::TooManyPixels); return; } - match file.read_to_end() { + match file.read_to_end_sized(size) { Ok(bytes) => owned_file = Some(bytes), Err(e) => { self.result = TaskResult::IoErr(e.with_path(p.as_bytes())); diff --git a/src/sys/file.rs b/src/sys/file.rs index 42758c842443..fbfeee1ed5bd 100644 --- a/src/sys/file.rs +++ b/src/sys/file.rs @@ -191,10 +191,16 @@ impl File { self.read_to_end_with_array_list(&mut v, SizeHint::ProbablySmall)?; Ok(v) } + /// [`File::read_to_end`] presized from the size [`File::open_regular_at`] returned, so the file is not `fstat`ed again. + pub fn read_to_end_sized(&self, size: u64) -> Maybe> { + let mut v = Vec::new(); + self.read_to_end_with_array_list(&mut v, SizeHint::Known(size))?; + Ok(v) + } /// `File.readToEndWithArrayList(buf, hint)` — like `read_all` but takes a /// `SizeHint` so callers can pre-reserve. Returns total bytes appended. /// `ProbablySmall` reserves 64; `UnknownSize` fstats and reserves - /// `size+16`. + /// `size+16`, as does `Known(size)` without the fstat. pub fn read_to_end_with_array_list(&self, list: &mut Vec, hint: SizeHint) -> Maybe { match hint { SizeHint::ProbablySmall => { @@ -202,14 +208,15 @@ impl File { return Err(Error::oom()); } } - SizeHint::UnknownSize => { + SizeHint::UnknownSize | SizeHint::Known(_) => { + let size = match hint { + SizeHint::Known(size) => usize::try_from(size).unwrap_or(usize::MAX), + _ => self.get_end_pos()?, + }; // `st_size` is only a hint (sparse files, racing writers, /proc): // reserve fallibly so an absurd size surfaces as ENOMEM to the // caller instead of aborting the process in `handle_alloc_error`. - let want = self - .get_end_pos()? - .saturating_add(16) - .saturating_sub(list.len()); + let want = size.saturating_add(16).saturating_sub(list.len()); if list.try_reserve_exact(want).is_err() { return Err(Error::oom()); } @@ -339,20 +346,48 @@ impl File { } // ── one-shot path helpers (open + io + close) ─────────────────────── - /// Open + read + close. Accepts `&[u8]`; `&ZStr` callers deref-coerce. + /// Open `path` for reading and return it with its size; `EISDIR` for a directory, `ENODEV` for any other non-regular file. + pub fn open_regular_at(dir: impl AsFd, path: &[u8]) -> Maybe<(Self, u64)> { + let dir = dir.as_fd(); + // On Windows `O_NONBLOCK` would make the handle overlapped; the fstat still rejects there. + #[cfg(unix)] + let flags = O::RDONLY | O::CLOEXEC | O::NONBLOCK; + #[cfg(not(unix))] + let flags = O::RDONLY | O::CLOEXEC; + let file = Self::openat(dir, path, flags, 0)?; + let size = file.ensure_regular(path)?; + Ok((file, size)) + } + /// The check of [`File::open_regular_at`] for a file opened with other flags: the size of a regular file, else its error. + pub fn ensure_regular(&self, path: &[u8]) -> Maybe { + let st = self.stat().map_err(|e| e.with_path(path))?; + let mode = st.st_mode as Mode; + if !S::ISREG(mode) { + let errno = if S::ISDIR(mode) { E::EISDIR } else { E::ENODEV }; + return Err(Error::new(errno, Tag::open).with_path(path)); + } + Ok(st.st_size.max(0) as u64) + } + /// Open + read + close of a regular file ([`File::open_regular_at`]); `&ZStr` deref-coerces. pub fn read_from(dir: impl AsFd, path: &[u8]) -> Maybe> { let dir = dir.as_fd(); - let f = Self::openat(dir, path, O::RDONLY, 0)?; + let (f, size) = Self::open_regular_at(dir, path)?; // `Drop` closes the fd on all paths (no leak on read failure). + f.read_to_end_sized(size) + } + /// [`File::read_from`] that reads a device too: for a path the user named (`--config=/dev/null`). + pub fn read_from_any_file_type(dir: impl AsFd, path: &[u8]) -> Maybe> { + let dir = dir.as_fd(); + let f = Self::openat(dir, path, O::RDONLY, 0)?; f.read_to_end() } - /// Open + read; returns BOTH + /// [`File::read_from`] that returns BOTH /// the open `File` handle and the bytes. Caller owns the fd and must /// `close()` it. On read error the fd is closed before returning (no leak). pub fn read_file_from(dir: impl AsFd, path: &[u8]) -> Maybe<(Self, Vec)> { let dir = dir.as_fd(); - let f = Self::openat(dir, path, O::RDONLY, 0)?; - match f.read_to_end() { + let (f, size) = Self::open_regular_at(dir, path)?; + match f.read_to_end_sized(size) { Ok(bytes) => Ok((f, bytes)), // The fd escapes only on success; `Drop` closes it here. Err(e) => Err(e), diff --git a/src/sys/lib.rs b/src/sys/lib.rs index c755755c6a95..bb3d8dc04d97 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -4891,6 +4891,8 @@ pub enum SizeHint { ProbablySmall, /// `fstat()` the fd to pre-size the buffer. UnknownSize, + /// Pre-size the buffer to the size the caller's own `fstat()` reported. + Known(u64), } /// Owned `KEY → VALUE` map of environment variables. diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index 20b40b68678a..ff85893bb3e3 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -2,7 +2,7 @@ import { file, spawn, write } from "bun"; import { install_test_helpers, npm_manifest_test_helpers } from "bun:internal-for-testing"; import { afterAll, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { copyFileSync, mkdirSync } from "fs"; -import { cp, exists, lstat, mkdir, readlink, rename, rm, writeFile } from "fs/promises"; +import { cp, exists, lstat, mkdir, readlink, rename, rm, symlink, writeFile } from "fs/promises"; import { assertManifestsPopulated, bunExe, @@ -25,6 +25,7 @@ import { VerdaccioRegistry, writeShebangScript, } from "harness"; +import { mkfifo } from "mkfifo"; import { join, resolve } from "path"; const { parseLockfile } = install_test_helpers; @@ -6755,6 +6756,32 @@ describe("pm trust", async () => { expect(await exists(join(packageDir, "node_modules", "uses-what-bin", "what-bin.txt"))).toBeTrue(); }); }); + + // After the scripts ran, `pm trust` records the packages in package.json, + // which it reads through the descriptor opened while the project was + // located. A device there used to be read like a file. + test.skipIf(isWindows)("package.json that is not a regular file", async () => { + await writeFile(packageJson, JSON.stringify({ name: "foo", dependencies: { "uses-what-bin": "1.0.0" } })); + await runBunInstall(env, packageDir); + + await rm(packageJson); + await symlink("/dev/null", packageJson); + + await using proc = spawn({ + cmd: [bunExe(), "pm", "trust", "uses-what-bin"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(err).toContain("ENODEV"); + expect({ out, exitCode, stillTheSymlink: (await lstat(packageJson)).isSymbolicLink() }).toEqual({ + out: "", + exitCode: 1, + stillTheSymlink: true, + }); + }); }); test("it should be able to find binary in node_modules/.bin from parent directory of root package", async () => { @@ -9865,6 +9892,48 @@ test("npm manifest cache entries are only reused for the package name they were expect(exitCode).toBe(0); }); +// A FIFO at the cache entry's path used to block the install forever in +// open(); a device file used to be read until the process ran out of memory. +test.skipIf(isWindows)("npm manifest cache entry that is not a regular file is skipped and replaced", async () => { + const { parseManifest } = npm_manifest_test_helpers; + const cacheDir = join(packageDir, ".bun-cache"); + await write(packageJson, JSON.stringify({ name: "foo", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } })); + + async function install() { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + // Only matters if the install blocks on the cache entry. + timeout: 30_000, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(err).toContain("Saved lockfile"); + expect(out).toContain("+ no-deps@1.0.0"); + expect(exitCode).toBe(0); + } + + await install(); + const manifestFiles = (await readdirSorted(cacheDir)).filter(name => name.endsWith(".npm")); + expect(manifestFiles).toHaveLength(1); + const manifestPath = join(cacheDir, manifestFiles[0]); + + await Promise.all([ + rm(manifestPath), + rm(join(packageDir, "node_modules"), { recursive: true, force: true }), + rm(join(packageDir, "bun.lockb"), { force: true }), + rm(join(packageDir, "bun.lock"), { force: true }), + ]); + mkfifo(manifestPath); + + await install(); + expect((await lstat(manifestPath)).isFile()).toBe(true); + expect(parseManifest(manifestPath, registryUrl()).name).toBe("no-deps"); +}); + describe("manifest conditional requests", () => { type ManifestRequest = { accept: string | null; diff --git a/test/cli/install/bun-install-tarball-integrity.test.ts b/test/cli/install/bun-install-tarball-integrity.test.ts index 422352e6a667..d39a9a0baf72 100644 --- a/test/cli/install/bun-install-tarball-integrity.test.ts +++ b/test/cli/install/bun-install-tarball-integrity.test.ts @@ -1,7 +1,8 @@ import { file, spawn } from "bun"; import { afterAll, beforeAll, describe, expect, it, setDefaultTimeout } from "bun:test"; -import { rm, writeFile } from "fs/promises"; -import { bunExe, bunEnv as env, readdirSorted, tempDir } from "harness"; +import { rm, symlink, writeFile } from "fs/promises"; +import { bunExe, bunEnv as env, isWindows, readdirSorted, tempDir } from "harness"; +import { mkfifo } from "mkfifo"; import { createHash } from "node:crypto"; import { gzipSync } from "node:zlib"; import { join } from "path"; @@ -854,3 +855,104 @@ describe.concurrent.each(["hoisted", "isolated"] as const)("tarball download fai }); }); }); + +// The tarball of a `file:` dependency is read whole. A FIFO at its path used to +// block the install forever in open(); a device file used to be read until the +// process ran out of memory. No Windows variant: FIFOs and device files are POSIX. +describe.skipIf(isWindows).concurrent("local tarball that is not a regular file", () => { + async function install(cwd: string, cacheDir: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], + cwd, + env: { ...env, BUN_INSTALL_CACHE_DIR: cacheDir }, + stdout: "pipe", + stderr: "pipe", + // Only matters if the install blocks on the tarball. + timeout: 30_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + const rootProject = { + "package.json": JSON.stringify({ name: "app", dependencies: { dep: "file:./dep.tgz" } }), + }; + + // Without a lockfile the tarball is read while the dependency is resolved. + it("resolving a root dependency on a FIFO fails", async () => { + using dir = tempDir("local-tarball-fifo", rootProject); + mkfifo(join(String(dir), "dep.tgz")); + + const { stdout, stderr, exitCode } = await install(String(dir), join(String(dir), ".bun-cache")); + expect(stderr).toContain("error: ENODEV extracting tarball from dep"); + expect(stderr).toContain("error: dep@file:./dep.tgz failed to resolve"); + expect(stdout).not.toContain("installed"); + expect(exitCode).toBe(1); + }); + + // The path of a workspace dependency is resolved against the workspace when + // the read is enqueued, the path of a root dependency when it is read. + it("resolving a workspace dependency on a FIFO fails", async () => { + using dir = tempDir("local-tarball-fifo-workspace", { + "package.json": JSON.stringify({ name: "root", workspaces: ["packages/*"] }), + "packages/app/package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { dep: "file:./dep.tgz" }, + }), + }); + mkfifo(join(String(dir), "packages", "app", "dep.tgz")); + + const { stdout, stderr, exitCode } = await install(String(dir), join(String(dir), ".bun-cache")); + expect(stderr).toContain("error: ENODEV extracting tarball from dep"); + expect(stderr).toContain("error: dep@file:./dep.tgz failed to resolve"); + expect(stdout).not.toContain("installed"); + expect(exitCode).toBe(1); + }); + + it("resolving a dependency on a character device fails instead of reading it", async () => { + using dir = tempDir("local-tarball-chardev", rootProject); + await symlink("/dev/null", join(String(dir), "dep.tgz")); + + const { stdout, stderr, exitCode } = await install(String(dir), join(String(dir), ".bun-cache")); + expect(stderr).toContain("error: ENODEV extracting tarball from dep"); + expect(stderr).toContain("error: dep@file:./dep.tgz failed to resolve"); + expect(stdout).not.toContain("installed"); + expect(exitCode).toBe(1); + }); + + // With a lockfile and an empty cache the tarball is read while the package + // is installed. Each linker reports that failure in its own words. + for (const [linker, message] of [ + ["hoisted", "error: ENODEV extracting tarball from dep"], + ["isolated", "error: failed to download dep@./dep.tgz: ENODEV"], + ] as const) { + it(`installing a locked dependency from a FIFO fails (${linker})`, async () => { + using dir = tempDir("local-tarball-fifo-locked-" + linker, rootProject); + const tarball = join(String(dir), "dep.tgz"); + const archive = new Bun.Archive( + { "package/package.json": JSON.stringify({ name: "dep", version: "1.0.0" }) }, + { compress: "gzip" }, + ); + await Bun.write(tarball, await archive.bytes()); + { + const { stderr, exitCode } = await install(String(dir), join(String(dir), ".cache-1"), "--linker", linker); + expect(stderr).toContain("Saved lockfile"); + expect(exitCode).toBe(0); + } + + await Promise.all([rm(tarball), rm(join(String(dir), "node_modules"), { recursive: true })]); + mkfifo(tarball); + + const { stdout, stderr, exitCode } = await install( + String(dir), + join(String(dir), ".cache-2"), + "--linker", + linker, + ); + expect(stderr).toContain(message); + expect(stdout).not.toContain("installed"); + expect(exitCode).toBe(1); + }); + } +}); diff --git a/test/cli/install/bun-lock.test.ts b/test/cli/install/bun-lock.test.ts index c8ba27d17e5f..8030db4815ea 100644 --- a/test/cli/install/bun-lock.test.ts +++ b/test/cli/install/bun-lock.test.ts @@ -1,7 +1,7 @@ import { file, spawn, write } from "bun"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { readlinkSync } from "fs"; -import { access, copyFile, cp, exists, open, rm, writeFile } from "fs/promises"; +import { access, copyFile, cp, exists, open, rm, stat, symlink, writeFile } from "fs/promises"; import { bunExe, bunEnv as env, @@ -13,6 +13,7 @@ import { toBeValidBin, VerdaccioRegistry, } from "harness"; +import { mkfifo } from "mkfifo"; import { join } from "path"; expect.extend({ @@ -1267,6 +1268,141 @@ describe.concurrent("hand-edited bun.lock that lists workspaces but has no packa }); }); +// `bun install` reads these files on its own. A FIFO at any of their paths used +// to block it forever inside open(); a character device such as /dev/zero used +// to be read until the process ran out of memory. No Windows variant: FIFOs and +// device files are POSIX. +describe.skipIf(isWindows).concurrent("a file bun install reads is not a regular file", () => { + const projectFiles = { + "package.json": JSON.stringify({ name: "not-a-file", workspaces: ["packages/*"] }), + "packages/member/package.json": JSON.stringify({ name: "member", version: "1.0.0" }), + }; + const installed = "bun install ()\n\nDone! Checked 2 packages (no changes)"; + + async function install(cwd: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { out: normalizeBunSnapshot(out, cwd), err: normalizeBunSnapshot(err, cwd), exitCode }; + } + + // Both the load and the "did the lockfile change" comparison before the save + // open the lockfile. + for (const lockfile of ["bun.lock", "bun.lockb"]) { + it(`${lockfile} is a FIFO: it is ignored like a corrupt lockfile and replaced`, async () => { + using dir = tempDir("bun-lock-fifo", projectFiles); + mkfifo(join(String(dir), lockfile)); + + const { out, err, exitCode } = await install(String(dir)); + expect(err).toBe(`ENODEV: failed to open lockfile: '${lockfile}'\n\nwarn: Ignoring lockfile\nSaved lockfile`); + expect(out).toBe(installed); + expect(exitCode).toBe(0); + expect((await stat(join(String(dir), lockfile))).isFile()).toBe(true); + }); + } + + it("bun.lock is a character device: it is rejected instead of read", async () => { + using dir = tempDir("bun-lock-chardev", projectFiles); + await symlink("/dev/null", join(String(dir), "bun.lock")); + + const { out, err, exitCode } = await install(String(dir), "--frozen-lockfile"); + expect(err).toMatchInlineSnapshot(` + "ENODEV: failed to open lockfile: 'bun.lock' + + warn: Ignoring lockfile + error: lockfile had changes, but lockfile is frozen" + `); + expect(out).toMatchInlineSnapshot(`"bun install ()"`); + expect(exitCode).toBe(1); + }); + + for (const lockfile of ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]) { + it(`${lockfile} is a FIFO: there is nothing to migrate`, async () => { + using dir = tempDir("bun-lock-migrate-fifo", projectFiles); + mkfifo(join(String(dir), lockfile)); + + const { out, err, exitCode } = await install(String(dir)); + expect(err).toBe("Saved lockfile"); + expect(out).toBe(installed); + expect(exitCode).toBe(0); + }); + } + + it(".npmrc is a FIFO: it is skipped like an unreadable one", async () => { + using dir = tempDir("bun-lock-npmrc-fifo", projectFiles); + mkfifo(join(String(dir), ".npmrc")); + + const { out, err, exitCode } = await install(String(dir)); + expect(err).toBe("Saved lockfile"); + expect(out).toBe(installed); + expect(exitCode).toBe(0); + }); + + // Locating the project opens package.json before anything reads it. + it("package.json is a FIFO: the install fails", async () => { + using dir = tempDir("bun-lock-package-json-fifo", {}); + mkfifo(join(String(dir), "package.json")); + + const { out, err, exitCode } = await install(String(dir)); + expect(err).toMatchInlineSnapshot(`"ENODEV: failed to read '/package.json'"`); + expect(out).toMatchInlineSnapshot(`"bun install ()"`); + expect(exitCode).toBe(1); + }); + + it("the package.json of a file: directory dependency is a FIFO: the dependency fails to resolve", async () => { + using dir = tempDir("bun-lock-folder-dep-fifo", { + "package.json": JSON.stringify({ name: "app", dependencies: { dep: "file:./dep" } }), + "dep/.keep": "", + }); + mkfifo(join(String(dir), "dep", "package.json")); + + const { out, err, exitCode } = await install(String(dir)); + expect(err).toMatchInlineSnapshot(` + "error: ENODEV + + note: error occurred while resolving dep + error: dep@file:./dep failed to resolve" + `); + expect(out).toMatchInlineSnapshot(`"bun install ()"`); + expect(exitCode).toBe(1); + }); + + // A second install checks each installed package through the package.json + // in node_modules. One that is not a regular file means the package needs + // to be installed again. + it("an installed package's package.json is a FIFO: the package is installed again", async () => { + using dir = tempDir("bun-lock-installed-fifo", { + "package.json": JSON.stringify({ name: "app", dependencies: { dep: "file:./dep.tgz" } }), + }); + const archive = new Bun.Archive( + { "package/package.json": JSON.stringify({ name: "dep", version: "1.0.0" }) }, + { compress: "gzip" }, + ); + await write(join(String(dir), "dep.tgz"), await archive.bytes()); + expect((await install(String(dir), "--linker=hoisted")).exitCode).toBe(0); + + const installedPackageJson = join(String(dir), "node_modules", "dep", "package.json"); + await rm(installedPackageJson); + mkfifo(installedPackageJson); + + const { out, exitCode } = await install(String(dir), "--linker=hoisted"); + expect(out).toMatchInlineSnapshot(` + "bun install () + + + dep@./dep.tgz + + 1 package installed" + `); + expect(exitCode).toBe(0); + expect(await file(installedPackageJson).json()).toEqual({ name: "dep", version: "1.0.0" }); + }); +}); + const makeInstallRunner = (cwd: string) => async (args: string[]) => { await using proc = spawn({ cmd: [bunExe(), ...args], diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 34f42ac8a360..1c8524ec9431 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -3,6 +3,7 @@ import { readTarball } from "bun:internal-for-testing"; import { beforeEach, describe, expect, test } from "bun:test"; import { exists, mkdir, rm } from "fs/promises"; import { bunEnv, bunExe, isLinux, isWindows, pack, runBunInstall, tempDir, tmpdirSync } from "harness"; +import { mkfifo } from "mkfifo"; import fs from "node:fs/promises"; import { join } from "path"; @@ -1467,23 +1468,31 @@ describe(".gitignore/.npmignore", () => { } for (const ignoreFile of [".gitignore", ".npmignore"]) { - test(`reports which ${ignoreFile} could not be read`, async () => { - await Promise.all([ - write( - join(packageDir, "package.json"), - JSON.stringify({ - name: "pack-ignore-unreadable", - version: "1.0.0", - }), - ), - write(join(packageDir, "subdir", "index.js"), "console.log('hello ./subdir/index.js')"), - // a directory where the ignore file is expected: opening it succeeds, reading it fails - mkdir(join(packageDir, "subdir", ignoreFile), { recursive: true }), - ]); - - const { err } = await packExpectError(packageDir, bunEnv); - expect(err).toContain(`EISDIR: failed to read ${ignoreFile} at: "${join(packageDir, "subdir", ignoreFile)}"\n`); - }); + // Only a regular file is accepted where an ignore file is expected. A FIFO + // there used to block the pack forever in open(). + for (const [kind, errno, create] of [ + ["a directory", "EISDIR", (path: string) => mkdir(path, { recursive: true })], + ["a FIFO", "ENODEV", async (path: string) => mkfifo(path)], + ] as const) { + test.skipIf(isWindows && kind === "a FIFO")(`reports which ${ignoreFile} is ${kind}`, async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-ignore-unreadable", + version: "1.0.0", + }), + ), + write(join(packageDir, "subdir", "index.js"), "console.log('hello ./subdir/index.js')"), + ]); + await create(join(packageDir, "subdir", ignoreFile)); + + const { err } = await packExpectError(packageDir, bunEnv); + expect(err).toContain( + `${errno}: failed to open ${ignoreFile} at: "${join(packageDir, "subdir", ignoreFile)}"\n`, + ); + }); + } } test("excludes files recursively", async () => { diff --git a/test/cli/run/env.test.ts b/test/cli/run/env.test.ts index 5efec5c6e10a..50d63ee586e9 100644 --- a/test/cli/run/env.test.ts +++ b/test/cli/run/env.test.ts @@ -13,6 +13,7 @@ import { tempDir, tempDirWithFiles, } from "harness"; +import { mkfifo } from "mkfifo"; import { parseEnv } from "node:util"; import path from "path"; @@ -50,6 +51,19 @@ describe.concurrent(".env file is loaded", () => { const { stdout } = await bunRun(`${dir}/index.ts`); expect(stdout).toBe("bar baz"); }); + // A FIFO at a .env path used to block the process in open() before it ran + // anything. (A FIFO listed in the directory itself is never tried; a symlink + // to one is.) + test.skipIf(isWindows)(".env that is not a regular file is skipped", async () => { + using dir = tempDir("dotenv", { + ".env.local": "FOO=bar\n", + "index.ts": "console.log(process.env.FOO);", + }); + mkfifo(`${dir}/fifo`); + fs.symlinkSync("fifo", `${dir}/.env`); + const { stdout, stderr, exitCode } = await bunRun(`${dir}/index.ts`); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "bar", stderr: "", exitCode: 0 }); + }); test(".env.development (NODE_ENV=undefined)", async () => { using dir = tempDir("dotenv", { ".env": "FOO=fail\nBAR=baz\n", diff --git a/test/config/bunfig/bunfig-errors.test.ts b/test/config/bunfig/bunfig-errors.test.ts index 7f6358af7b18..ea846c784e29 100644 --- a/test/config/bunfig/bunfig-errors.test.ts +++ b/test/config/bunfig/bunfig-errors.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { mkfifo } from "mkfifo"; +import { join } from "node:path"; describe.concurrent("bunfig.toml type-mismatch error messages", () => { const cases: [config: string, expected: string][] = [ @@ -30,3 +32,33 @@ describe.concurrent("bunfig.toml type-mismatch error messages", () => { expect(exitCode).not.toBe(0); }); }); + +// No Windows variant: FIFOs and device files are POSIX. +describe.skipIf(isWindows).concurrent("config file that is not a regular file", () => { + async function run(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args, "-e", "console.log('ran')"], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // The open of the FIFO used to block the process before it ran anything. + test("a bunfig.toml that bun found on its own is skipped like an unreadable one", async () => { + using dir = tempDir("bunfig-fifo", {}); + mkfifo(join(String(dir), "bunfig.toml")); + + expect(await run(String(dir))).toEqual({ stdout: "ran\n", stderr: "", exitCode: 0 }); + }); + + // The user named this one, so it is read whatever it is. + test("--config=/dev/null is an empty config", async () => { + using dir = tempDir("bunfig-dev-null", { "bunfig.toml": `smol = "not read"\n` }); + + expect(await run(String(dir), "--config=/dev/null")).toEqual({ stdout: "ran\n", stderr: "", exitCode: 0 }); + }); +}); diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index dcf0cc2b7617..5dd4cc1acbe5 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -1,5 +1,7 @@ import { describe, expect, jest, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { mkfifo } from "mkfifo"; +import { symlinkSync } from "node:fs"; test("error.cause", () => { const err = new Error("error 1"); @@ -9,24 +11,25 @@ test("error.cause", () => { .replaceAll("\\", "/") .replaceAll(import.meta.dir.replaceAll("\\", "/"), "[dir]"), ).toMatchInlineSnapshot(` -"1 | import { describe, expect, jest, test } from "bun:test"; -2 | import { bunEnv, bunExe, tempDir } from "harness"; -3 | -4 | test("error.cause", () => { -5 | const err = new Error("error 1"); -6 | const err2 = new Error("error 2", { cause: err }); +"3 | import { mkfifo } from "mkfifo"; +4 | import { symlinkSync } from "node:fs"; +5 | +6 | test("error.cause", () => { +7 | const err = new Error("error 1"); +8 | const err2 = new Error("error 2", { cause: err }); ^ error: error 2 - at ([dir]/inspect-error.test.js:6:20) + at ([dir]/inspect-error.test.js:8:20) -1 | import { describe, expect, jest, test } from "bun:test"; -2 | import { bunEnv, bunExe, tempDir } from "harness"; -3 | -4 | test("error.cause", () => { -5 | const err = new Error("error 1"); +2 | import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +3 | import { mkfifo } from "mkfifo"; +4 | import { symlinkSync } from "node:fs"; +5 | +6 | test("error.cause", () => { +7 | const err = new Error("error 1"); ^ error: error 1 - at ([dir]/inspect-error.test.js:5:19) + at ([dir]/inspect-error.test.js:7:19) " `); }); @@ -38,15 +41,15 @@ test("Error", () => { .replaceAll("\\", "/") .replaceAll(import.meta.dir.replaceAll("\\", "/"), "[dir]"), ).toMatchInlineSnapshot(` -"30 | " -31 | \`); -32 | }); -33 | -34 | test("Error", () => { -35 | const err = new Error("my message"); +"33 | " +34 | \`); +35 | }); +36 | +37 | test("Error", () => { +38 | const err = new Error("my message"); ^ error: my message - at ([dir]/inspect-error.test.js:35:19) + at ([dir]/inspect-error.test.js:38:19) " `); }); @@ -105,7 +108,7 @@ test("Error inside minified file (no color) ", () => { error: error inside long minified file! at ([dir]/inspect-error-fixture.min.js:26:2850) at ([dir]/inspect-error-fixture.min.js:26:2890) - at ([dir]/inspect-error.test.js:86:7)" + at ([dir]/inspect-error.test.js:89:7)" `); } }); @@ -134,7 +137,7 @@ test("Error inside minified file (color) ", () => { error: error inside long minified file! at ([dir]/inspect-error-fixture.min.js:26:2850) at ([dir]/inspect-error-fixture.min.js:26:2890) - at ([dir]/inspect-error.test.js:114:7)" + at ([dir]/inspect-error.test.js:117:7)" `); } }); @@ -148,7 +151,7 @@ test("Inserted originalLine and originalColumn do not appear in node:util.inspec .replaceAll(import.meta.path.replaceAll("\\", "/"), "[file]"), ).toMatchInlineSnapshot(` "Error: my message - at ([file]:143:19)" + at ([file]:146:19)" `); }); @@ -196,8 +199,10 @@ describe("source map remapping of the printed stack", () => { .map(line => line.replaceAll(prefix, "")); } - async function run(files) { + // `prepare(dir)` runs once the files exist, for what a file tree can't express. + async function run(files, prepare = () => {}) { using dir = tempDir("inspect-error-sourcemap", files); + prepare(String(dir)); await using proc = Bun.spawn({ cmd: [bunExe(), "main.js"], cwd: String(dir), @@ -326,6 +331,38 @@ describe("source map remapping of the printed stack", () => { }); expect(exitCode).toBe(1); }); + + // The `main.js.map` next to a prebuilt file is read when the error is + // printed. It is only a source map if it is a regular file: a FIFO used to + // block the exiting process forever in open(), and a device such as + // /dev/zero used to be read until the process ran out of memory. The frames + // then stay unmapped, as with no map at all. + describe.skipIf(isWindows)("external map that is not a regular file", () => { + const files = { + "main.js": [ + "// @bun", + 'function thrower() { throw new Error("HOSTILE"); }', + 'console.log("{}");', + "thrower();", + "", + ].join("\n"), + }; + const unmapped = ["at thrower (main.js:2:28)", "at main.js:4:8"]; + + test.concurrent("a FIFO", async () => { + const { dir, stderr, exitCode } = await run(files, cwd => mkfifo(`${cwd}/main.js.map`)); + expect(stderr).not.toContain("Could not decode sourcemap"); + expect(frames(stderr, dir, ["main.js"])).toEqual(unmapped); + expect(exitCode).toBe(1); + }); + + test.concurrent("a character device", async () => { + const { dir, stderr, exitCode } = await run(files, cwd => symlinkSync("/dev/null", `${cwd}/main.js.map`)); + expect(stderr).not.toContain("Could not decode sourcemap"); + expect(frames(stderr, dir, ["main.js"])).toEqual(unmapped); + expect(exitCode).toBe(1); + }); + }); }); // The printer replaces an AggregateError with the members of its `errors`