diff --git a/src/ast/e.rs b/src/ast/e.rs index 039522c286b9..0bcd16b00791 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1295,7 +1295,7 @@ impl Default for Object { } } -/// Dotted-key path used by the INI parser (`get_or_put_object`). +/// The dot-separated segments of a section header of the INI parser. /// /// Node types are lifetime-free, so `next` is a raw `*mut Rope` /// into the bump arena. Segments are bulk-freed at arena reset. @@ -1323,28 +1323,7 @@ impl Rope { tail.next = rope; Ok(rope) } - - /// Re-borrow `next` as `Option<&Rope>`. Same `StoreRef` arena contract: - /// the pointee is a bump allocation valid until arena reset. Centralises - /// the one `unsafe` so the `get_or_put_object`/`get_rope` walkers - /// don't repeat `if !next.is_null() { unsafe { &*next } }` at every hop. - #[inline] - pub(crate) fn next_ref<'a>(&self) -> Option<&'a Rope> { - // SAFETY: `next` is either null or a bump-arena allocation valid until - // arena reset. Read-only borrow; no `&mut` alias is - // outstanding at any caller (the chain is fully built before walking). - unsafe { self.next.cast_const().as_ref() } - } -} - -// thiserror is not a dep of this crate; hand-roll Error+Display. -#[derive(Debug, strum::IntoStaticStr)] -pub enum SetError { - OutOfMemory, - Clobber, } -bun_core::impl_tag_error!(SetError); -bun_core::oom_from_alloc!(SetError); // ── live Object accessor surface ─────────────────────────────────────────── // Adapted to the current `Vec` API (`append(v)`, `slice()`, `slice_mut()`). @@ -1419,68 +1398,6 @@ impl Object { Expr::allocate(bump, EString::init(value), crate::Loc::EMPTY), ) } - - /// Walks `rope` segments, creating nested objects as needed, and returns - /// the leaf `E.Object` expression. - pub fn get_or_put_object(&mut self, rope: &Rope, _bump: &Bump) -> Result { - let head_key = match rope.head.data.e_string() { - Some(s) => s.data, - None => return Err(SetError::Clobber), - }; - if let Some(existing) = self.get(&head_key) { - match existing.data { - crate::expr::Data::EArray(array) => { - let Some(next) = rope.next_ref() else { - return Err(SetError::Clobber); - }; - if let Some(last) = array.items.last() { - if let crate::expr::Data::EObject(mut obj) = last.data { - return obj.get_or_put_object(next, _bump); - } - return Err(SetError::Clobber); - } - return Err(SetError::Clobber); - } - crate::expr::Data::EObject(mut object) => { - if let Some(next) = rope.next_ref() { - return object.get_or_put_object(next, _bump); - } - return Ok(existing); - } - _ => return Err(SetError::Clobber), - } - } - - if let Some(next) = rope.next_ref() { - let obj = Expr::init(Object::default(), rope.head.loc); - let out = match obj.data { - crate::expr::Data::EObject(mut o) => o.get_or_put_object(next, _bump)?, - _ => unreachable!(), - }; - VecExt::append( - &mut self.properties, - G::Property { - key: Some(rope.head), - value: Some(obj), - flags: own_key_property_flags(&rope.head), - ..G::Property::default() - }, - ); - return Ok(out); - } - - let out = Expr::init(Object::default(), rope.head.loc); - VecExt::append( - &mut self.properties, - G::Property { - key: Some(rope.head), - value: Some(out), - flags: own_key_property_flags(&rope.head), - ..G::Property::default() - }, - ); - Ok(out) - } } /// Data-file parsers (JSON/JSON5/TOML/YAML) define every key as an own diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 74b64b7bec4f..90c0a912649a 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -46,7 +46,7 @@ pub(crate) fn inline_comment_start(val: &[u8]) -> Option { // ConfigOpt // ────────────────────────────────────────────────────────────────────────── -#[derive(Clone, Copy, PartialEq, Eq, strum::IntoStaticStr, strum::EnumString)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, strum::IntoStaticStr, strum::EnumString)] pub enum ConfigOpt { /// usually `${username}:${password}` encoded in base64, but sent verbatim #[strum(serialize = "_auth")] @@ -120,9 +120,11 @@ mod draft { use bun_alloc::{AllocError, Arena, ArenaVec, ArenaVecExt as _}; use bun_ast::E::Rope; - use bun_ast::{E, Expr, ExprData, StoreRef}; + use bun_ast::{E, Expr, ExprData, G, StoreRef}; use bun_ast::{Loc, Log, Source}; - use bun_collections::VecExt; + use bun_collections::array_hash_map::hash_string; + use bun_collections::hashbrown::HashTable; + use bun_collections::{HashMap, VecExt}; use bun_core::{Output, ZStr}; use bun_dotenv::Loader as DotEnvLoader; use bun_options_types::schema::api::{BunInstall, Ca, NpmRegistry, NpmUrlAuth}; @@ -135,13 +137,108 @@ mod draft { type OOM = Result; - /// Hard cap on dot-separated segments in a section-header rope. The rope is - /// consumed by `E::Object::get_or_put_object`, which recurses once per - /// `rope.next` link, so an unbounded header overflows the stack. Past the - /// cap the remainder of the header (dots included) becomes the final - /// segment. + /// Bounds the nesting that one section header makes, and the tail walk of `Rope::append`. The rest of a longer header is its last segment. const MAX_SECTION_ROPE_SEGMENTS: usize = 512; + /// A lookup scans up to this many keys. More keys than that get a hash table. + const SCAN_MAX: usize = 32; + + /// A debug build checks the table of an object of up to this many properties against a scan. + const CHECKED_MAX: usize = 512; + + /// Finds a property by key in an object that `Parser::parse` fills, through a hash table if the object is large. + #[derive(Default)] + struct KeyIndex { + /// By object address. + objects: HashMap, + } + + #[derive(Default)] + struct ObjectKeys { + /// The first property of each key. + slots: HashTable, + /// `properties[..indexed]` are in `slots`. + indexed: usize, + } + + struct KeySlot { + /// `hash_string` of the key. A table that grows reads it and not the key. + hash: u32, + /// In `properties`. + position: u32, + } + + impl KeyIndex { + /// The position that `E::Object::as_property` returns. + #[inline] + fn find(&mut self, object: StoreRef, key: &[u8]) -> Option { + if object.properties.len() <= SCAN_MAX { + return object.as_property(key).map(|query| query.i as usize); + } + self.find_indexed(object, key) + } + + #[cold] + #[inline(never)] + fn find_indexed(&mut self, object: StoreRef, key: &[u8]) -> Option { + let props = object.properties.slice(); + let key_at = |slot: &KeySlot| property_key(&props[slot.position as usize]); + let rehash = |slot: &KeySlot| table_hash(slot.hash); + let keys = self.objects.entry(object.as_ptr() as usize).or_default(); + // The table is read from the property list, so it has the keys of a JSON value that a `[section]` reopens. + keys.slots + .reserve(props.len().saturating_sub(keys.indexed), rehash); + for (position, prop) in props.iter().enumerate().skip(keys.indexed) { + let Some(name) = property_key(prop) else { + continue; + }; + let hash = hash_string(name); + keys.slots + .entry( + table_hash(hash), + |slot| slot.hash == hash && key_at(slot) == Some(name), + rehash, + ) + .or_insert(KeySlot { + hash, + position: position as u32, + }); + } + keys.indexed = props.len(); + let hash = hash_string(key); + let found = keys + .slots + .find(table_hash(hash), |slot| { + slot.hash == hash && key_at(slot) == Some(key) + }) + .map(|slot| slot.position as usize); + // `parse` only appends properties and replaces their values, so the table matches the list. + debug_assert!( + props.len() > CHECKED_MAX + || found == object.as_property(key).map(|query| query.i as usize) + ); + found + } + } + + /// The key that `E::Object::as_property` compares for this property, or `None` for a property that it skips. + fn property_key(prop: &G::Property) -> Option<&[u8]> { + prop.value?; + match &prop.key.as_ref()?.data { + ExprData::EString(key) => { + // `as_property` transcodes a UTF-16 key. `parse` and the JSON parser that it calls only make UTF-8 ones. + debug_assert!(key.is_utf8()); + Some(key.data.slice()) + } + _ => None, + } + } + + /// `hashbrown` takes its tag from the top bits. + fn table_hash(hash: u32) -> u64 { + (u64::from(hash) << 32) | u64::from(hash) + } + // ────────────────────────────────────────────────────────────────────────── // Parser // ────────────────────────────────────────────────────────────────────────── @@ -203,6 +300,7 @@ mod draft { let root: StoreRef = self.out.data.e_object().expect("Parser.out is E.Object"); let mut head: StoreRef = root; + let mut keys = KeyIndex::default(); let ropealloc = bump; @@ -249,11 +347,9 @@ mod draft { offset, )? .into_section(); - let mut r = root; - let parent_object = match r.get_or_put_object(section, bump) { - Ok(v) => v, - Err(E::SetError::OutOfMemory) => return Err(AllocError), - Err(E::SetError::Clobber) => { + head = match Self::open_section(&mut keys, root, section) { + Some(object) => object, + None => { // We're in here if key exists but it is not an object // // This is possible if someone did: @@ -289,10 +385,6 @@ mod draft { break 'treat_as_key; } }; - head = parent_object - .data - .e_object() - .expect("get_or_put_object returns E.Object"); break 'treat_as_key; } if !treat_as_key { @@ -367,38 +459,74 @@ mod draft { _ => value_raw, }; - if is_array { - if let Some(val) = E::Object::get(&head, key) { - if !matches!(val.data, ExprData::EArray(_)) { - let mut arr = E::Array::default(); - arr.push(bump, val)?; - head.put(bump, key, Expr::init(arr, Loc::EMPTY))?; - } - } else { - head.put(bump, key, Expr::init(E::Array::default(), Loc::EMPTY))?; - } - } + let at = keys.find(head, key); + let existing = at.and_then(|i| head.properties.slice()[i].value); // safeguard against resetting a previously defined // array by accidentally forgetting the brackets - let mut was_already_array = false; - if let Some(mut val) = E::Object::get(&head, key) { - if matches!(val.data, ExprData::EArray(_)) { - was_already_array = true; - val.data - .e_array_mut() - .expect("infallible: variant checked") - .push(bump, value)?; - head.put(bump, key, val)?; - } + if let Some(ExprData::EArray(mut array)) = existing.map(|val| val.data) { + array.push(bump, value)?; + continue; } - if !was_already_array { - head.put(bump, key, value)?; + + let value = if is_array { + let mut array = E::Array::default(); + if let Some(val) = existing { + array.push(bump, val)?; + } + array.push(bump, value)?; + Expr::init(array, Loc::EMPTY) + } else { + value + }; + match at { + Some(i) => head.properties.slice_mut()[i].value = Some(value), + None => { + let key = Expr::allocate(bump, E::EString::init(key), value.loc); + head.append_property(key, value); + } } } Ok(()) } + /// The object that a section header names, with the objects on the way made if they are missing. `None` if a segment names another kind of value. + fn open_section( + keys: &mut KeyIndex, + root: StoreRef, + section: &mut Rope, + ) -> Option> { + let mut object = root; + let mut segment = StoreRef::from_bump(section); + loop { + let next = ptr::NonNull::new(segment.next).map(StoreRef::from_non_null); + let name = segment.head.data.e_string()?; + let child = match keys.find(object, &name.data) { + Some(i) => match object.properties.slice()[i].value?.data { + ExprData::EObject(existing) => existing, + // Under an array, the rest of the header is in its last element. + ExprData::EArray(array) if next.is_some() => { + match array.items.last()?.data { + ExprData::EObject(last) => last, + _ => return None, + } + } + _ => return None, + }, + None => { + let created = Expr::init(E::Object::default(), segment.head.loc); + object.append_property(segment.head, created); + created.data.e_object()? + } + }; + let Some(next) = next else { + return Some(child); + }; + object = child; + segment = next; + } + } + fn prepare_str( env: &DotEnvLoader, source_path: &[u8], @@ -1244,10 +1372,33 @@ mod draft { /// npm's config is a flat map, so a key that's repeated across `.npmrc` files collapses to the /// last one read. - fn lookup<'a>(configs: &'a [ConfigItem], key: &[u8], opt: ConfigOpt) -> Option<&'a ConfigItem> { - configs - .iter() - .rfind(|conf_item| conf_item.optname == opt && *conf_item.registry_url == *key) + struct Collapsed<'a> { + configs: &'a [ConfigItem], + /// The last line of each `(key, option)`, if there are more than `SCAN_MAX` lines. + last: Option>, + } + + impl<'a> Collapsed<'a> { + fn new(configs: &'a [ConfigItem]) -> Self { + let last = (configs.len() > SCAN_MAX).then(|| { + let mut last = HashMap::with_capacity(configs.len()); + for item in configs { + last.insert((&*item.registry_url, item.optname), item); + } + last + }); + Collapsed { configs, last } + } + + fn lookup(&self, key: &[u8], opt: ConfigOpt) -> Option<&'a ConfigItem> { + match &self.last { + Some(last) => last.get(&(key, opt)).copied(), + None => self + .configs + .iter() + .rfind(|item| item.optname == opt && *item.registry_url == *key), + } + } } /// npm's `Buffer.from(value, "base64")`, which skips invalid bytes and never fails. @@ -1265,11 +1416,11 @@ mod draft { /// Follows the order of npm's `hasAuth`, without `certfile` and `keyfile` (no mTLS), which /// would stop the walk. - fn credentials_of_key(configs: &[ConfigItem], key: &[u8]) -> Option { + fn credentials_of_key(collapsed: &Collapsed<'_>, key: &[u8]) -> Option { // npm's `opts[k]` truthiness test, applied after the collapse, so a later `username=` // clears an earlier one. let truthy = - |opt| Some(&lookup(configs, key, opt)?.value).filter(|value| !value.is_empty()); + |opt| Some(&collapsed.lookup(key, opt)?.value).filter(|value| !value.is_empty()); let mut credentials = NpmRegistry::default(); if let Some(token) = truthy(ConfigOpt::_AuthToken) { credentials.token.clone_from(token); @@ -1294,14 +1445,21 @@ mod draft { log: &mut Log, ) { install.url_auth.clear(); + let collapsed = Collapsed::new(configs); + // The keys of the lines so far. Only filled if `collapsed` has its table. + let mut seen: HashMap<&[u8], ()> = HashMap::new(); for ConfigItem { registry_url: key, .. } in configs { - if install.url_auth.iter().any(|entry| entry.key == *key) { + let handled = match collapsed.last { + Some(_) => seen.insert(key, ()).is_some(), + None => install.url_auth.iter().any(|entry| entry.key == *key), + }; + if handled { continue; } - if let Some(credentials) = credentials_of_key(configs, key) { + if let Some(credentials) = credentials_of_key(&collapsed, key) { install.url_auth.push(NpmUrlAuth { key: key.clone(), credentials, @@ -1314,8 +1472,9 @@ mod draft { .filter(|item| { item.optname == ConfigOpt::_Auth && item.value.is_empty() - && lookup(configs, &item.registry_url, ConfigOpt::_Auth) - .is_some_and(|collapsed| core::ptr::eq(collapsed, *item)) + && collapsed + .lookup(&item.registry_url, ConfigOpt::_Auth) + .is_some_and(|last| core::ptr::eq(last, *item)) }) .peekable(); if empty.peek().is_none() { @@ -1338,12 +1497,26 @@ mod draft { .chain(from_bunfig) .map(RegistryKey::from_url) .collect(); + // Without its trailing `/` the key is a distinct one, which the walk visits right after the slashed one. + fn spellings(registry: &RegistryKey) -> [&[u8]; 2] { + [®istry.key, ®istry.key[..registry.key.len() - 1]] + } + let registry_keys = (registries.len() > SCAN_MAX).then(|| { + let mut keys: HashMap<&[u8], ()> = HashMap::with_capacity(registries.len() * 2); + for key in registries.iter().flat_map(spellings) { + keys.insert(key, ()); + } + keys + }); for item in empty { - // Without its trailing `/` the key is a distinct one, which the walk visits right after the slashed one. - if registries.iter().any(|registry| { - *item.registry_url == *registry.key - || *item.registry_url == registry.key[..registry.key.len() - 1] - }) { + let names_registry = match ®istry_keys { + Some(keys) => keys.contains_key(&*item.registry_url), + None => registries + .iter() + .flat_map(spellings) + .any(|key| *key == *item.registry_url), + }; + if names_registry { log.add_error_opts( b"ignoring empty _auth value", bun_ast::AddErrorOptions { diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index d407a60187a8..1005a625fa13 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -44,7 +44,7 @@ pub struct Options { pub(crate) registries: Npm::registry::Map, /// `.npmrc` `//host/path/` credential lines, resolved by request URL. - pub(crate) url_auth: Vec, + pub(crate) url_auth: Npm::registry::UrlAuthList, pub(crate) cache_directory: &'static [u8], pub enable: Enable, pub do_: Do, @@ -138,7 +138,7 @@ impl Default for Options { // Always assigned in `load()` before read. scope: Npm::registry::Scope::default(), registries: Npm::registry::Map::default(), - url_auth: Vec::new(), + url_auth: Npm::registry::UrlAuthList::default(), cache_directory: b"", enable: Enable::default(), do_: Do::default(), @@ -312,7 +312,7 @@ impl Options { // This URL comes from package data, and an `.npmrc` key has no scheme, so credentials are // never sent over plain http. let own = (normalized && tarball.is_https()) - .then(|| Npm::registry::UrlAuth::find_entry(&self.url_auth, tarball)) + .then(|| self.url_auth.find_entry(tarball)) .flatten(); if scope.has_credentials() && same_origin(tarball, &scope.url.url()) { // The registry's own key is in `scope` already, behind bunfig, env and CLI. @@ -328,8 +328,11 @@ impl Options { } /// `.npmrc`'s credentials for the URL of `scope` yield to bunfig.toml, the environment and flags. -fn fill_scope_from_url_auth(url_auth: &[Npm::registry::UrlAuth], scope: &mut Npm::registry::Scope) { - let own = Npm::registry::UrlAuth::find_entry(url_auth, &scope.url.url()); +fn fill_scope_from_url_auth( + url_auth: &Npm::registry::UrlAuthList, + scope: &mut Npm::registry::Scope, +) { + let own = url_auth.find_entry(&scope.url.url()); scope.url_auth_key = own.map(|entry| entry.key.clone()); if let Some(entry) = own.filter(|_| !scope.has_credentials() || scope.declared_in_npmrc) { scope.copy_credentials_from(&entry.credentials); @@ -384,7 +387,7 @@ impl Options { /// A registry at or under `old` keeps its credentials unless `.npmrc` has a deeper line for it. fn inherits_credentials(&self, old: &Npm::registry::Scope, new_url: &bun_url::URL) -> bool { - use Npm::registry::{UrlAuth, query_free_path}; + use Npm::registry::query_free_path; use bun_core::strings::tokenize; let old_url = old.url.url(); let mut segments = tokenize(query_free_path(new_url), b"/"); @@ -392,8 +395,10 @@ impl Options { == bun_core::without_trailing_slash(old_url.host) && (new_url.is_https() || !old_url.is_https()) && tokenize(query_free_path(&old_url), b"/").all(|base| segments.next() == Some(base)) - && match UrlAuth::find_entry(&self.url_auth, new_url) { - Some(own) => UrlAuth::find_entry(&self.url_auth, &old_url) + && match self.url_auth.find_entry(new_url) { + Some(own) => self + .url_auth + .find_entry(&old_url) .is_some_and(|old| old.key == own.key), None => true, } @@ -737,6 +742,7 @@ impl Options { // Scopes that have credentials but no URL use the default registry. They are registered // further down, once the default registry's URL is final. let mut at_default_registry: Vec<(&[u8], &Api::NpmRegistry)> = Vec::new(); + let mut url_auth = Vec::new(); // npmrc < bunfig for config in [runtime_npmrc].into_iter().chain(bunfigs).flatten() { @@ -760,12 +766,13 @@ impl Options { } } - for url_auth in &config.url_auth { - if let Some(url_auth) = Npm::registry::UrlAuth::from_api(url_auth, env)? { - self.url_auth.push(url_auth); + for entry in &config.url_auth { + if let Some(entry) = Npm::registry::UrlAuth::from_api(entry, env)? { + url_auth.push(entry); } } } + self.url_auth = Npm::registry::UrlAuthList::new(url_auth); for config in bunfigs.into_iter().flatten() { if let Some(cache_directory) = config.cache_directory.as_deref() { diff --git a/src/install/npm.rs b/src/install/npm.rs index 42cc8be8811c..3b6188f42d1d 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -615,16 +615,51 @@ pub mod registry { credentials, })) } + } + + /// Every credential key of `.npmrc`, in the order it was read. + #[derive(Default)] + pub(crate) struct UrlAuthList { + entries: Vec, + /// The position of the first entry of each key, if there are more than `SCAN_MAX` entries. + by_key: bun_collections::StringHashMap, + } + + impl UrlAuthList { + /// A lookup scans up to this many entries. More entries than that get a hash table. + const SCAN_MAX: usize = 32; + + pub(crate) fn new(entries: Vec) -> UrlAuthList { + let mut by_key = bun_collections::StringHashMap::new(); + if entries.len() > Self::SCAN_MAX { + for (position, entry) in entries.iter().enumerate() { + // A scan finds the first entry of a key. + bun_core::handle_oom(by_key.get_or_put_value(&entry.key, position as u32)); + } + } + UrlAuthList { entries, by_key } + } + + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + fn get(&self, key: &[u8]) -> Option<&UrlAuth> { + if self.entries.len() > Self::SCAN_MAX { + return Some(&self.entries[*self.by_key.get(key)? as usize]); + } + self.entries.iter().find(|entry| *entry.key == *key) + } /// Walks the path like npm does. `url` must be a WHATWG serialization, from `Scope::url` or /// `normalize_tarball_url`. - pub(crate) fn find_entry<'a>(list: &'a [UrlAuth], url: &URL) -> Option<&'a UrlAuth> { - if list.is_empty() || !url.has_http_like_protocol() { + pub(crate) fn find_entry(&self, url: &URL) -> Option<&UrlAuth> { + if self.is_empty() || !url.has_http_like_protocol() { return None; } bun_ini::RegistryKey::from_parsed(url) .walk() - .find_map(|key| list.iter().find(|entry| *entry.key == *key)) + .find_map(|key| self.get(key)) } } diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index bab1fc4b9e6b..79c357daa993 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -800,10 +800,6 @@ impl InitCommand { } } - if template.is_react() { - template.write_to_package_json(&mut fields, &bump)?; - } - 'write_package_json: { let (fd, created_close): (Fd, Option) = match package_json_file .as_ref() @@ -1380,47 +1376,6 @@ impl TemplateFile { } impl Template { - fn is_react(self) -> bool { - matches!( - self, - Template::ReactBlank | Template::ReactTailwind | Template::ReactTailwindShadcn - ) - } - - fn write_to_package_json( - self, - fields: &mut PackageJSONFields, - bump: &bun_alloc::Arena, - ) -> Result<(), Error> { - type Rope = bun_ast::E::Rope; - fields.name = self.name().to_vec(); - // Allocate in the process-lifetime CLI arena. - let key: &mut Rope = crate::cli::cli_arena().alloc(Rope { - head: bun_ast::Expr::init(bun_ast::E::String::init(b"scripts"), bun_ast::Loc::EMPTY), - next: core::ptr::null_mut(), - }); - // SAFETY: object is arena-allocated and live for the command duration. - let object = unsafe { &mut *fields.object.unwrap().as_ptr() }; - let mut scripts_json = object.get_or_put_object(key, bump).map_err(|e| match e { - bun_ast::E::SetError::OutOfMemory => Error::Alloc(bun_alloc::AllocError), - bun_ast::E::SetError::Clobber => Error::Unexpected, - })?; - let the_scripts = self.scripts(); - let mut i: usize = 0; - while i < the_scripts.len() { - let script_name = the_scripts[i]; - let script_command = the_scripts[i + 1]; - - scripts_json.data.e_object_mut().unwrap().put_string( - bump, - script_name, - script_command, - )?; - i += 2; - } - Ok(()) - } - fn dependencies(self) -> &'static DependencyGroup { match self { Template::Blank => &DependencyGroup::BLANK, @@ -1441,24 +1396,6 @@ impl Template { } } - fn scripts(self) -> &'static [&'static [u8]] { - match self { - Template::Blank | Template::TypescriptLibrary => &[], - Template::ReactTailwind | Template::ReactTailwindShadcn => &[ - b"dev", b"bun './**/*.html'", - b"build", b"bun 'REPLACE_ME_WITH_YOUR_APP_FILE_NAME.build.ts'", - ], - Template::ReactBlank => &[ - b"dev", - b"bun --hot .", - b"static", - b"bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", - b"build", - b"NODE_ENV=production bun .", - ], - } - } - const AGENT_RULE: &'static [u8] = include_bytes!("./init/rule.md"); const CURSOR_RULE: TemplateFile = TemplateFile::new( b".cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc", diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 9ec486cdaaa5..d395fa53584e 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -2,7 +2,18 @@ import { write } from "bun"; import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; import { chmodSync, mkdirSync, readdirSync, writeFileSync } from "fs"; import { rm } from "fs/promises"; -import { bunEnv, bunExe, isIPv6, isLinux, isWindows, tempDir, tls, unprivilegedSpawnOptions } from "harness"; +import { + bunEnv, + bunExe, + isASAN, + isDebug, + isIPv6, + isLinux, + isWindows, + tempDir, + tls, + unprivilegedSpawnOptions, +} from "harness"; import { createServer as createTlsServer } from "node:tls"; import { dirname, join } from "path"; import { TestRegistry } from "registry"; @@ -742,6 +753,50 @@ ${Object.keys(opts) await diagnosed(`//somehost.com/:_auth=${blob}\n`, `registry=http://somehost.com/\n//somehost.com/:_auth=\n`), ).toBe(true); }); + + // More than 32 credential lines are collapsed with a hash table. + const others = Array.from({ length: 40 }, (_, i) => `//other${i}.example.com/:_auth=${blob}\n`).join(""); + + test("among many credential lines, a home line the project overrides with a value is not diagnosed", async () => { + expect( + await diagnosed( + `//somehost.com/:_auth=\n${others}`, + `registry=http://somehost.com/\n${others}//somehost.com/:_auth=${blob}\n`, + ), + ).toBe(false); + }); + + test("among many credential lines, a project line that clears the home file's value is diagnosed", async () => { + expect( + await diagnosed( + `${others}//somehost.com/:_auth=${blob}\n`, + `registry=http://somehost.com/\n//somehost.com/:_auth=\n${others}`, + ), + ).toBe(true); + }); + + // Without its trailing `/`, the key of a registry is another key that npm reads for it. + test.each([ + ["the host of the registry", `registry=http://somehost.com/\n`, `//somehost.com:_auth=\n`, true], + ["the path of the registry", `@myorg:registry=https://somehost.com/api/\n`, `//somehost.com/api:_auth=\n`, true], + ["a part of that path", `@myorg:registry=https://somehost.com/api/\n`, `//somehost.com/ap:_auth=\n`, false], + ])("a line for %s without a trailing slash", async (_, registry, line, expected) => { + expect(await diagnosed(line, registry)).toBe(expected); + }); + + // More than 32 registries are found through a hash table. + const registries = Array.from( + { length: 40 }, + (_, i) => `@scope${i}:registry=https://registry${i}.example.com/npm/\n`, + ).join(""); + + test.each([ + ["its key", `//registry7.example.com/npm/:_auth=\n`, true], + ["its key without the trailing slash", `//registry7.example.com/npm:_auth=\n`, true], + ["a key above it", `//registry7.example.com/:_auth=\n`, false], + ])("among many registries, a line that names one by %s", async (_, line, expected) => { + expect(await diagnosed(line, registries)).toBe(expected); + }); }); await makeTest( @@ -1997,6 +2052,81 @@ describe.concurrent(".npmrc auth resolves by path-segment ancestor", () => { const auth = await probeAuthorization(host => `//${host}${path}:username=bob`, { path, homeNpmrc: homeBasic }); expect(auth).toBe(`Basic ${Buffer.from("bob:s3cret").toString("base64")}`); }); + + // Up to 32 credential lines are scanned for each key. More than that are collapsed with a hash table. + describe("among many other credential lines", () => { + const others = Array.from({ length: 40 }, (_, i) => `//other${i}.example.com/:_authToken=OTHER${i}`).join("\n"); + + it("a project token overrides the home file's token at the same key", async () => { + const auth = await probeAuthorization(host => `${others}\n//${host}${path}:_authToken=PROJECT`, { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN\n${others}`, + }); + expect(auth).toBe("Bearer PROJECT"); + }); + + it("a later line overrides an earlier one whose key is spelled another way", async () => { + const auth = await probeAuthorization( + host => `//${host}${path}:_authToken=FIRST\n${others}\n//http://${host}${path}:_authToken=LAST`, + { path }, + ); + expect(auth).toBe("Bearer LAST"); + }); + + it("the project's empty _authToken falsifies the home file's token, so _auth wins", async () => { + const auth = await probeAuthorization( + host => `//${host}${path}:_authToken=\n${others}\n//${host}${path}:_auth=${basic}`, + { path, homeNpmrc: host => `${others}\n//${host}${path}:_authToken=HOMETOKEN` }, + ); + expect(auth).toBe(`Basic ${basic}`); + }); + + it("the project's empty username clears the home file's username and password pair", async () => { + const auth = await probeAuthorization(host => `${others}\n//${host}${path}:username=`, { + path, + homeNpmrc: host => `${homeBasic(host)}\n${others}`, + }); + expect(auth).toBe(null); + }); + + it("a project username overrides the home file's at the same key", async () => { + const auth = await probeAuthorization(host => `${others}\n//${host}${path}:username=bob`, { + path, + homeNpmrc: host => `${others}\n${homeBasic(host)}`, + }); + expect(auth).toBe(`Basic ${Buffer.from("bob:s3cret").toString("base64")}`); + }); + + const thousands = Array.from({ length: 4000 }, (_, i) => `//host${i}.example.com/:_authToken=TOKEN${i}\n`).join( + "", + ); + + it("the token of a registry is found among 4,000 credential lines", () => { + const token = (registry: string, more = "") => + loadNpmrc(`registry=${registry}\n${thousands}${more}`).default_registry_token; + expect([0, 2000, 3999, 4000].map(i => token(`https://host${i}.example.com/`))).toEqual([ + "TOKEN0", + "TOKEN2000", + "TOKEN3999", + "", + ]); + expect(token("https://host7.example.com/", "//HOST7.example.com/:_authToken=LAST\n")).toBe("LAST"); + expect( + loadNpmrc(`registry=https://first.example.com/\n${thousands}registry=https://host9.example.com/\n`), + ).toMatchObject({ + default_registry_url: "https://host9.example.com/", + default_registry_token: "TOKEN9", + }); + }); + + it("the token of a registry is sent among 4,000 credential lines", async () => { + const auth = await probeAuthorization(host => `${thousands}//${host}${path}:_authToken=PROJECT`, { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN\n${thousands}`, + }); + expect(auth).toBe("Bearer PROJECT"); + }); + }); }); // npm never decodes `_auth`; it forwards the value as `Basic `. An opaque @@ -2602,6 +2732,31 @@ describe(".npmrc diagnostics", () => { expect(stderr).not.toContain("SECRETTOKEN"); }); + // A file of more than 32 keys is read through a hash table of its keys, and so are more than 32 credential lines. + it("warns once, at the first line, about an unknown option that a large file sets twice", async () => { + const others = Array.from({ length: 40 }, (_, i) => `other${i}=x`).join("\n"); + const stderr = await stderrOf( + `registry=https://example.com/\n//example.com/:_authtoken=FIRSTSECRET\n${others}\n//example.com/:_authtoken=LASTSECRET\n`, + ); + expect(stderr.match(/ignoring unknown .npmrc option _authtoken[^\n]*\n\s+at \S+[\\/]\.npmrc:\d+:/g)).toEqual([ + expect.stringMatching(/\.npmrc:2:$/), + ]); + expect(stderr).not.toContain("SECRET"); + }); + + it("reports an empty _auth once among many credential lines, for the registry it names", async () => { + const others = Array.from({ length: 40 }, (_, i) => `//other${i}.example.com/:_auth=`).join("\n"); + const stderr = await stderrOf( + `registry=https://example.com/\n//example.com/:_auth=\n${others}\n//Example.COM/:_auth=\n`, + ); + expect(stderr.match(/ignoring empty _auth value/g)).toEqual(["ignoring empty _auth value"]); + }); + + it("reports an empty _auth for each spelling of the key of a registry", async () => { + const stderr = await stderrOf(`registry=http://somehost.com/\n//somehost.com/:_auth=\n//somehost.com:_auth=\n`); + expect(stderr.match(/ignoring empty _auth value/g)).toHaveLength(2); + }); + it("prints nothing about an unknown option with --silent", async () => { const stderr = await stderrOf(`registry=https://example.com/\n//example.com/:_authtoken=SECRETTOKEN\n`, undefined, [ "--silent", @@ -2689,6 +2844,70 @@ describe(".npmrc diagnostics", () => { } }); +// A read that scans the credential lines for each line, or the credentials for each registry, takes 8 to 13 seconds +// of CPU for these files in a debug build, and 12 to 30 seconds in a release build. +describe.concurrent("a .npmrc of many credential lines is read in time in proportion to them", () => { + const lines = isDebug ? 8_000 : 64_000; + const numbered = (count: number, text: (i: number) => string) => { + let all = ""; + for (let i = 0; i < count; i++) all += text(i); + return all; + }; + + test.each<[string, () => string]>([ + ["_authToken lines", () => numbered(lines, i => `//host${i}.example.com/:_authToken=token${i}\n`)], + ["username lines", () => numbered(lines, i => `//host${i}.example.com/:username=user${i}\n`)], + [ + "username and _password lines", + () => + numbered( + lines / 2, + i => `//host${i}.example.com/:username=user${i}\n//host${i}.example.com/:_password=cGFzcw==\n`, + ), + ], + ["empty _auth lines", () => numbered(lines, i => `//host${i}.example.com/:_auth=\n`)], + [ + "registries, and an empty _auth line for another host each", + () => + numbered( + lines / 2, + i => `@scope${i}:registry=https://host${i}.example.com/\n//other${i}.example.com/:_auth=\n`, + ), + ], + [ + "registries under a path, and a token for another host each", + () => + numbered( + lines / 2, + i => + `@scope${i}:registry=https://host${i}.example.com/org/project/_packaging/feed/npm/registry/\n` + + `//other${i}.example.com/:_authToken=token${i}\n`, + ), + ], + ])("%s", async (_, npmrc) => { + using dir = tempDir("npmrc-many-credentials", { + ".npmrc": npmrc(), + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "bin"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + // CPU time, in seconds. Wall-clock time means little on a busy machine. + expect(Number(proc.resourceUsage()!.cpuTime.total) / 1e6).toBeLessThan(isDebug || isASAN ? 4 : 1); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); +}); + describe("--registry override", () => { test("does not send the token configured for the previous registry host to the --registry host", async () => { const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); @@ -5020,6 +5239,23 @@ describe("trims Unicode whitespace like npm", () => { }); describe("inline comments and boolean options", () => { + // The root object is scanned for a key up to 32 keys, and read through a hash table past that. + test.each([0, 31, 32, 33])("a [section] header after %d other keys", count => { + const others = Array.from({ length: count }, (_, i) => `other${i}=x\n`).join(""); + // A key under a header is not an option. + expect(loadNpmrc(`${others}[section]\nsave-exact=true\n`).save_exact).toBeUndefined(); + // A header makes an object of its name, which is a true option. + expect(loadNpmrc(`${others}[hoist]\n`).hoist).toBe(true); + // The header of a key that has a value is skipped, with the keys under it. + const skipped = loadNpmrc(`${others}hoist=false\n[hoist]\nsave-exact=true\n`); + expect([skipped.hoist, skipped.save_exact]).toEqual([false, undefined]); + // A later `registry=` replaces the first. + expect( + loadNpmrc(`registry=https://first.example.com/\n${others}registry=https://last.example.com/\n`) + .default_registry_url, + ).toBe("https://last.example.com/"); + }); + test("an inline comment is not part of the registry url or the auth token", () => { // ini cuts an unquoted value at the first unescaped `;` or `#`. With the // comment left in, the registry URL no longer matched its `//host/` credential. diff --git a/test/js/bun/ini/ini.test.ts b/test/js/bun/ini/ini.test.ts index d38b7747e30e..f38c44382693 100644 --- a/test/js/bun/ini/ini.test.ts +++ b/test/js/bun/ini/ini.test.ts @@ -1,7 +1,14 @@ const { iniInternals } = require("bun:internal-for-testing"); const { parse } = iniInternals; import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; +import { join } from "node:path"; + +function numbered(count: number, text: (i: number) => string) { + let all = ""; + for (let i = 0; i < count; i++) all += text(i); + return all; +} describe("parse ini", () => { test("weird section", () => { @@ -535,6 +542,194 @@ brr = 3 }); }); + // The parser scans an object of up to 32 keys for a key, and uses a hash table for a larger object. + // Each case runs with the object under test on both sides of that limit. + describe.each([0, 31, 32, 33, 300])("an object with %d other keys", count => { + const otherKeys = Array.from({ length: count }, (_, i) => `other${i}`); + const otherLines = otherKeys.map(key => `${key} = x`); + const others = Object.fromEntries(otherKeys.map(key => [key, "x"])); + + function expectParsed(lines: string[], expected: object) { + const parsed = parse(lines.join("\n")); + expect(parsed).toEqual(expected); + // `toEqual` accepts the keys in any order. + expect(JSON.stringify(parsed)).toBe(JSON.stringify(expected)); + } + + test("a repeated key replaces the value and keeps its place", () => { + expectParsed(["a = 1", ...otherLines, "b = 2", "a = 3"], { a: "3", ...others, b: "2" }); + }); + + test("key[] after a plain key makes an array of both values", () => { + expectParsed(["a = 1", ...otherLines, "b = 2", "a[] = 3"], { a: ["1", "3"], ...others, b: "2" }); + }); + + test("a plain key after key[] adds to the array", () => { + expectParsed(["a[] = 1", ...otherLines, "b = 2", "a = 3", "a[] = 4"], { + a: ["1", "3", "4"], + ...others, + b: "2", + }); + }); + + test("a section that is opened again keeps its keys", () => { + expectParsed(["[s]", "x = 1", ...otherLines, "[t]", "y = 2", "[s]", "z = 3", "x = 9"], { + s: { x: "9", ...others, z: "3" }, + t: { y: "2" }, + }); + }); + + test("a nested section is found again in its parent", () => { + expectParsed(["[a]", ...otherLines, "[a.b]", "x = 1", "[a]", "y = 2", "[a.b]", "z = 3"], { + a: { ...others, b: { x: "1", z: "3" }, y: "2" }, + }); + }); + + test("a key replaces a nested section, and the header of that section is then skipped", () => { + expectParsed(["[a]", ...otherLines, "[a.b]", "x = 1", "[a]", "b = 5", "[a.b]", "y = 2", "[c]", "z = 3"], { + a: { ...others, b: "5" }, + c: { z: "3" }, + }); + }); + + test("the header of a key with a string value is skipped", () => { + expectParsed(["foo = bar", ...otherLines, "[foo]", "x = 1", "[ok]", "y = 2"], { + foo: "bar", + ...others, + ok: { y: "2" }, + }); + }); + + test("a section adds to the keys of a JSON value", () => { + const json = JSON.stringify({ a: 1, b: [2], ...others }); + expectParsed([`foo = '${json}'`, "[foo]", "c = 3", "a = 7", "b = 8"], { + foo: { a: "7", b: [2, "8"], ...others, c: "3" }, + }); + }); + + test("a section replaces the first of two equal keys of a JSON value", () => { + const json = `{"a":1,${otherKeys.map(key => `"${key}":"x",`).join("")}"a":2}`; + expectParsed([`foo = '${json}'`, "[foo]", "a = 3"], { foo: { a: 2, ...others } }); + }); + + test("a header continues in the last object of an array", () => { + const json = JSON.stringify({ x: 1, ...others }); + expectParsed([...otherLines, `a[] = '${json}'`, "[a.b]", "k = v", "[a.c]", "[a.b]", "l = w", "[a]", "m = n"], { + ...others, + a: [{ x: 1, ...others, b: { k: "v", l: "w" }, c: {} }], + }); + }); + + test.each([ + ["whose last element is not an object", "a[] = 1", ["1"]], + ["that is empty", "a = '[]'", []], + ])("a header under an array %s is skipped", (_, line, a) => { + expectParsed([...otherLines, line, "[a.b]", "k = v", "[ok]", "y = 2"], { ...others, a, ok: { y: "2" } }); + }); + + test("a header that is skipped leaves the section that was open", () => { + expectParsed(["[s]", ...otherLines, "a[] = 1", "[s.a.b]", "k = v", "[", "m = n"], { + s: { ...others, a: ["1"], m: "n" }, + }); + }); + + test("a __proto__ key is skipped, and a [__proto__] section is an own property", () => { + expectParsed([...otherLines, "__proto__ = 1", "ok = 1", "[__proto__]", "x = 1", "[s]", "[__proto__]", "y = 2"], { + ...others, + ok: "1", + ["__proto__"]: { x: "1", y: "2" }, + s: {}, + }); + }); + + test("two sections that take turns, with their keys in opposite orders", () => { + const keys = ["first", ...otherKeys]; + const reversed = keys.toReversed(); + expectParsed( + [ + ...keys.flatMap((key, i) => ["[a]", `${key} = a`, "[b]", `${reversed[i]} = b`]), + ...keys.flatMap((key, i) => ["[a]", `${key} = A`, "[b]", `${reversed[i]}[] = B`]), + ], + { + a: Object.fromEntries(keys.map(key => [key, "A"])), + b: Object.fromEntries(reversed.map(key => [key, ["b", "B"]])), + }, + ); + }); + }); + + // The same lines with and without 33 more keys in each object: the first is read through the hash table, the second + // with a scan. + test("an object of many keys is read like a small one in 24 random files", () => { + const filler = numbered(33, i => `filler${i} = x\n`); + const withoutFiller = (value: unknown): unknown => + Array.isArray(value) + ? value.map(withoutFiller) + : value && typeof value === "object" + ? Object.fromEntries( + Object.entries(value) + .filter(([key]) => !key.startsWith("filler")) + .map(([key, inner]) => [key, withoutFiller(inner)]), + ) + : value; + let seed = 1; + const random = (below: number) => (seed = (seed * 48271) % 0x7fffffff) % below; + for (let file = 0; file < 24; file++) { + let small = ""; + let large = filler; + for (let line = 0; line < 30; line++) { + const key = `k${random(5)}`; + const text = [ + `${key} = ${line}\n`, + `${key}[] = ${line}\n`, + `${key} = '{"k${random(5)}":${line},"a":[1,{"b":2}]}'\n`, + `[${key}]\n`, + `[${key}.k${random(5)}]\n`, + `[${key}.a.c]\n`, + ][random(6)]; + small += text; + large += text.startsWith("[") ? text + filler : text; + } + expect(JSON.stringify(withoutFiller(parse(large)))).toBe(JSON.stringify(parse(small))); + } + }); + + test("two keys with the same hash stay apart in an object of many keys", () => { + // The hash table of an object uses the low 32 bits of wyhash. + const [one, two] = ["key85982", "key146395"]; + expect(Bun.hash.wyhash(one) & 0xffff_ffffn).toBe(Bun.hash.wyhash(two) & 0xffff_ffffn); + const parsed = parse(numbered(40, i => `other${i} = x\n`) + `${one} = 1\n${two} = 2\n${one} = 3\n${two}[] = 4\n`); + expect(Object.keys(parsed)).toHaveLength(42); + expect([parsed[one], parsed[two]]).toEqual(["3", ["2", "4"]]); + }); + + test("a key is found among 4,000 keys", () => { + const parsed = parse( + numbered(4000, i => `key${i} = ${i}\n`) + "key0 = first\nkey2000[] = middle\nkey3999 = last\n[key1]\nx = 1\n", + ); + expect(Object.keys(parsed)).toHaveLength(4000); + expect([parsed.key0, parsed.key1, parsed.key2000, parsed.key3999]).toEqual([ + "first", + "1", + ["2000", "middle"], + "last", + ]); + }); + + test("a section is found among 4,000 sections", () => { + const parsed = parse( + numbered(4000, i => `[section${i}]\nkey = ${i}\n`) + + "[section0]\nkey = first\n[section2000]\nkey[] = middle\n[section3999.nested]\nkey = last\n", + ); + expect(Object.keys(parsed)).toHaveLength(4000); + expect([parsed.section0, parsed.section1, parsed.section2000, parsed.section3999]).toEqual([ + { key: "first" }, + { key: "1" }, + { key: ["2000", "middle"] }, + { key: "3999", nested: { key: "last" } }, + ]); + }); + test("bigboi", async () => { const foo = await Bun.$`cat ${__dirname}/foo.ini`.text(); const result = parse(foo); @@ -641,6 +836,55 @@ brr = 3 }); }); +// A read that scans the lines before each line takes 13 to 50 seconds of CPU for these files in a debug build, +// and 5 to 17 seconds in a release build. +describe.concurrent("a large .npmrc is read in time in proportion to its lines", () => { + const lines = isDebug ? 16_000 : 64_000; + + test.each<[string, () => string]>([ + ["keys", () => numbered(lines, i => `key${i} = value\n`)], + ["key[] lines", () => numbered(lines, i => `key${i}[] = value\n`)], + ["sections", () => numbered(lines, i => `[section${i}]\n`)], + ["nested sections", () => numbered(lines, i => `[a.b${i}]\n`)], + ["the keys of one section", () => "[section]\n" + numbered(lines, i => `key${i} = value\n`)], + [ + "the keys that a section adds to a JSON value", + () => + `foo = '{${numbered(lines / 2, i => `"json${i}":${i},`)}"last":0}'\n[foo]\n` + + numbered(lines / 2, i => `key${i} = value\n`), + ], + ["two sections that take turns", () => numbered(lines / 2, i => `[a]\nkey${i} = value\n[b]\nkey${i} = value\n`)], + ])("%s", async (_, npmrc) => { + using dir = tempDir("ini-large-npmrc", { + ".npmrc": npmrc(), + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "bin"], + cwd: String(dir), + // `bun pm bin` reads the `.npmrc` of the project, and no other: `home` has none. + env: { + ...bunEnv, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: home, + NPM_CONFIG_USERCONFIG: undefined, + npm_config_userconfig: undefined, + }, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + // CPU time, in seconds. Wall-clock time means little on a busy machine. + expect(Number(proc.resourceUsage()!.cpuTime.total) / 1e6).toBeLessThan(isDebug || isASAN ? 4 : 1); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); +}); + const wtf = { "o": "p", "a with spaces": "b c",