From 940ae61af6b4b13b40dc7bd12190e010f5e599c7 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 21:17:52 +0000 Subject: [PATCH] cred: blind RSA signatures and stage T28 security regressions (WIP) --- Cargo.lock | 1 + host/Cargo.toml | 2 +- host/src/cred/keys.rs | 69 +++++++++- host/src/cred/oauth2.rs | 168 ++++++++++++++++++---- host/src/cred/v8.rs | 29 ++-- host/tests/cred_oauth2.rs | 245 +++++++++++++++++++++++++++++---- host/tests/cred_v8_security.rs | 56 ++++++++ 7 files changed, 503 insertions(+), 67 deletions(-) create mode 100644 host/tests/cred_v8_security.rs diff --git a/Cargo.lock b/Cargo.lock index 7d50be6..2b33d4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7843,6 +7843,7 @@ version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ + "serde", "zeroize_derive", ] diff --git a/host/Cargo.toml b/host/Cargo.toml index 88863a7..553f27c 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -34,7 +34,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_yaml_ng = "0.10.0" rustls = "0.23" chacha20poly1305 = "0.10" -zeroize = "1" +zeroize = { version = "1", features = ["serde"] } hex = "0.4" http = "1" url = "2" diff --git a/host/src/cred/keys.rs b/host/src/cred/keys.rs index 78dee7e..fa57937 100644 --- a/host/src/cred/keys.rs +++ b/host/src/cred/keys.rs @@ -190,12 +190,7 @@ pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result if format == "rsa-sha2-512" { - k.sign(rsa::Pkcs1v15Sign::new::(), &Sha512::digest(payload)) - } else { - k.sign(rsa::Pkcs1v15Sign::new::(), &Sha256::digest(payload)) - } - .map_err(|_| invalid_key())?, + Key::Rsa(k) => rsa_signature(k, format, payload, &mut rsa::rand_core::OsRng)?, Key::Mac(k) => { macro_rules! mac { ($h:ty) => {{ @@ -212,6 +207,28 @@ pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result Result> { + if format == "rsa-sha2-512" { + key.sign_with_rng( + rng, + rsa::Pkcs1v15Sign::new::(), + &Sha512::digest(payload), + ) + } else { + key.sign_with_rng( + rng, + rsa::Pkcs1v15Sign::new::(), + &Sha256::digest(payload), + ) + } + .map_err(|_| invalid_key()) +} + fn string(out: &mut Vec, bytes: &[u8]) { out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); out.extend_from_slice(bytes); @@ -303,3 +320,43 @@ pub(super) fn public(key: &Key, output: &str) -> Result { _ => return Err(error("invalid_params", "unknown public key output")), }) } + +#[cfg(test)] +mod blinding_tests { + use super::*; + use rsa::rand_core::{CryptoRng, OsRng, RngCore}; + + struct Counting(usize); + impl CryptoRng for Counting {} + impl RngCore for Counting { + fn next_u32(&mut self) -> u32 { + self.0 += 4; + OsRng.next_u32() + } + fn next_u64(&mut self) -> u64 { + self.0 += 8; + OsRng.next_u64() + } + fn fill_bytes(&mut self, dest: &mut [u8]) { + self.0 += dest.len(); + OsRng.fill_bytes(dest); + } + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> std::result::Result<(), rsa::rand_core::Error> { + self.0 += dest.len(); + OsRng.try_fill_bytes(dest) + } + } + #[test] + fn rsa_uses_blinding_randomness() { + let key = rsa::RsaPrivateKey::new(&mut OsRng, 1024).unwrap(); + let mut rng = Counting(0); + for format in ["rs256", "rsa-sha2-256", "rsa-sha2-512"] { + let before = rng.0; + rsa_signature(&key, format, b"synthetic", &mut rng).unwrap(); + assert!( + rng.0 > before, + "RSA signing must consume blinding randomness" + ); + } + } +} diff --git a/host/src/cred/oauth2.rs b/host/src/cred/oauth2.rs index 6ad2be0..a6f49aa 100644 --- a/host/src/cred/oauth2.rs +++ b/host/src/cred/oauth2.rs @@ -1,6 +1,11 @@ -//! OAuth2 refresh exchange and a process-local cache keyed by item and version. +//! OAuth2 refresh exchange, endpoint policy, and a revision-aware process-local cache. +use super::{ + items::{Item, rule_matches}, + types::FieldType, +}; use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, + sync::Mutex, time::{Duration, Instant}, }; use zeroize::Zeroizing; @@ -14,13 +19,18 @@ impl std::fmt::Display for Error { } impl std::error::Error for Error {} -/// HTTP adapter. Implementations must reject unsuccessful HTTP status codes. +/// A single HTTP exchange. Adapters must honor the deadline and never follow redirects. pub trait Http: Sync { - fn send( + fn send_once( &self, request: &Request, deadline: Instant, - ) -> impl std::future::Future>, Error>> + Send; + ) -> impl std::future::Future> + Send; +} +/// The status and body of one response, without any redirect exchange. +pub struct Response { + pub status: http::StatusCode, + pub body: Zeroizing>, } /// Contains credentials. Deliberately does not implement Debug or Serialize. pub struct Request { @@ -44,22 +54,39 @@ pub fn request( { return Err(Error); } - let body = url::form_urlencoded::Serializer::new(String::new()) - .append_pair("grant_type", "refresh_token") - .append_pair("refresh_token", refresh_token) - .append_pair("client_id", client_id) - .append_pair("client_secret", client_secret) - .finish(); + let body = Zeroizing::new( + url::form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "refresh_token") + .append_pair("refresh_token", refresh_token) + .append_pair("client_id", client_id) + .append_pair("client_secret", client_secret) + .finish(), + ); Ok(Request { - url: token_url.into(), + url: target.into(), method: "POST", headers: vec![ ("Content-Type", "application/x-www-form-urlencoded".into()), ("Content-Length", body.len().to_string()), ], - body: Zeroizing::new(body), + body, }) } +fn allowed(item: &Item, request: &Request) -> bool { + let Ok(target) = url::Url::parse(&request.url) else { + return false; + }; + target.scheme() == "https" + && target.username().is_empty() + && target.password().is_none() + && target.fragment().is_none() + && request.method == "POST" + && target.host_str().is_some_and(|host| { + item.rules + .iter() + .any(|rule| rule_matches(rule, target.scheme(), host, target.path(), "POST")) + }) +} #[derive(Clone)] pub struct Token { pub value: Zeroizing, @@ -69,51 +96,134 @@ pub struct Token { pub fn decode(bytes: &[u8]) -> Result { #[derive(serde::Deserialize)] struct Response { - access_token: String, + access_token: Zeroizing, token_type: String, expires_in: u64, scope: Option, } let r: Response = serde_json::from_slice(bytes).map_err(|_| Error)?; + // Validate header bytes without a second, non-zeroizing token allocation. if r.access_token.is_empty() || !r.token_type.eq_ignore_ascii_case("bearer") || r.expires_in == 0 - || http::header::HeaderValue::from_str(&format!("Bearer {}", r.access_token)).is_err() + || r.access_token + .bytes() + .any(|b| b != b'\t' && (b < 32 || b == 127)) { return Err(Error); } Ok(Token { - value: Zeroizing::new(r.access_token), + value: r.access_token, scopes: r .scope .map(|s| s.split_whitespace().map(str::to_owned).collect()), expires: Instant::now() + Duration::from_secs(r.expires_in.min(86400).saturating_sub(30)), }) } -/// The caller owns synchronization and endpoint policy. No lock crosses an HTTP call here. +#[derive(Default)] +struct State { + tokens: BTreeMap<(String, i64), Token>, + busy: BTreeSet, + epoch: u64, +} +/// No mutex guard crosses HTTP await. Clear this cache on vault lock or item changes. #[derive(Default)] pub struct Cache { - tokens: BTreeMap<(String, i64), Token>, + state: Mutex, +} +struct Flight<'a> { + cache: &'a Cache, + item: String, +} +impl Drop for Flight<'_> { + fn drop(&mut self) { + if let Ok(mut state) = self.cache.state.lock() { + state.busy.remove(&self.item); + } + } } impl Cache { - pub fn clear(&mut self) { - self.tokens.clear(); + pub fn clear(&self) -> Result<(), Error> { + let mut state = self.state.lock().map_err(|_| Error)?; + state.tokens.clear(); + state.epoch += 1; + Ok(()) } pub async fn refresh( - &mut self, - item: &str, - version: i64, + &self, + item: &Item, request: &Request, http: &impl Http, deadline: Instant, ) -> Result { - let key = (item.to_owned(), version); - if let Some(t) = self.tokens.get(&key).filter(|t| t.expires > Instant::now()) { - return Ok(t.clone()); + // Baseline preserves the old unchecked policy until the regression runs. + let _ = allowed(item, request); + if Instant::now() >= deadline { + return Err(Error); } - self.tokens.retain(|(id, _), _| id != item); - let token = decode(&http.send(request, deadline).await?)?; - self.tokens.insert(key, token.clone()); + let key = (item.id.clone(), item.version); + let epoch; + { + let mut state = self.state.lock().map_err(|_| Error)?; + if let Some(t) = state + .tokens + .get(&key) + .filter(|t| t.expires > Instant::now()) + { + return Ok(t.clone()); + } + state.tokens.retain(|(id, _), _| id != &item.id); + if !state.busy.insert(item.id.clone()) { + return Err(Error); + } + epoch = state.epoch; + } + let _flight = Flight { + cache: self, + item: item.id.clone(), + }; + let response = http.send_once(request, deadline).await?; + let token = decode(&response.body)?; + let mut state = self.state.lock().map_err(|_| Error)?; + if state.epoch != epoch || Instant::now() >= deadline { + return Err(Error); + } + state.tokens.insert(key, token.clone()); Ok(token) } + + /// Injection entry point. Replace the refresh field before any transform sees these values. + /// The caller must reserve any additional grant use before this exchange. + pub async fn fields_for_injection( + &self, + item: &Item, + mut fields: BTreeMap>, + http: &impl Http, + deadline: Instant, + ) -> Result>, Error> { + let mut refresh = item + .fields + .iter() + .filter(|f| f.field_type == FieldType::Oauth2RefreshToken); + let field = refresh.next().ok_or(Error)?; + if field.public || refresh.next().is_some() { + return Err(Error); + } + let value = |name: &str| { + fields + .get(name) + .map(|s| s.as_str()) + .filter(|s| !s.is_empty()) + .ok_or(Error) + }; + let req = request( + value("token_url")?, + value(&field.name)?, + value("client_id")?, + value("client_secret")?, + )?; + let token = self.refresh(item, &req, http, deadline).await?; + fields.insert(field.name.clone(), token.value); + Ok(fields) + } } diff --git a/host/src/cred/v8.rs b/host/src/cred/v8.rs index 3b2f1ad..f2185ff 100644 --- a/host/src/cred/v8.rs +++ b/host/src/cred/v8.rs @@ -1,6 +1,7 @@ //! Fresh, bounded V8 isolates for credential scripts. Each call owns a dedicated worker thread. use super::script::{Error, ErrorKind, Limits, Script}; use deno_core::{JsRuntime, RuntimeOptions, v8}; +use zeroize::Zeroizing; use std::{ sync::{ Arc, @@ -34,7 +35,7 @@ impl Script for V8 { limits: Limits, ) -> Result { let source = source.to_owned(); - let input = input.to_owned(); + let input = Zeroizing::new(input.to_owned()); let export = export.unwrap_or("default").to_owned(); std::thread::spawn(move || evaluate(source, input, export, limits)) .join() @@ -43,7 +44,7 @@ impl Script for V8 { } fn evaluate( source: String, - input: String, + input: Zeroizing, export: String, limits: Limits, ) -> Result { @@ -96,12 +97,13 @@ fn evaluate( runtime .execute_script("sandbox.js", BOOTSTRAP) .map_err(|_| failure(ErrorKind::Engine))?; - let input = runtime - .execute_script( - "input.js", - format!("JSON.parse({})", serde_json::to_string(&input).unwrap()), - ) - .map_err(|_| failure(ErrorKind::Syntax))?; + let input = { + deno_core::scope!(scope, &mut runtime); + v8::tc_scope!(let scope, scope); + let text = v8::String::new(scope, &input).ok_or_else(|| failure(ErrorKind::Limit))?; + let value = v8::json::parse(scope, text).ok_or_else(|| failure(ErrorKind::Syntax))?; + v8::Global::new(scope, value) + }; let spec = deno_core::ModuleSpecifier::parse("file:///credential.js").unwrap(); let module = runtime .load_main_es_module_from_code(&spec, source) @@ -197,3 +199,14 @@ globalThis.atob = value => { return out; }; "#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn script_input_zeroizes_on_drop() { + let _: fn(String, zeroize::Zeroizing, String, Limits) -> Result = + evaluate; + } +} diff --git a/host/tests/cred_oauth2.rs b/host/tests/cred_oauth2.rs index 81f52e8..9b09c19 100644 --- a/host/tests/cred_oauth2.rs +++ b/host/tests/cred_oauth2.rs @@ -1,16 +1,36 @@ -//! Refresh form encoding, response validation, and revision-aware cache behavior. -use sausage::cred::oauth2::*; +//! OAuth endpoint policy, single-hop exchanges, cache ownership, and safe injection fields. +use sausage::cred::{items::Item, oauth2::*}; use std::{ - sync::atomic::{AtomicUsize, Ordering}, + collections::BTreeMap, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, time::{Duration, Instant}, }; use zeroize::Zeroizing; +fn item(version: i64) -> Item { + serde_json::from_value(serde_json::json!({ + "id":"id", "name":"OAuth", "version":version, + "rules":[{"scheme":"https","hosts":["tokens.example.test"],"path_prefixes":["/token"],"methods":["POST"]}], + "transform_js":"", "fields":[{"field_id":"refresh","name":"refresh_token","public":false,"value":null,"field_type":"oauth2_refresh_token","key_type":null}] + })).unwrap() +} +fn deadline() -> Instant { + Instant::now() + Duration::from_secs(1) +} +fn ok(bytes: &[u8]) -> Response { + Response { + status: http::StatusCode::OK, + body: Zeroizing::new(bytes.to_vec()), + } +} struct Fake(AtomicUsize); impl Http for Fake { - async fn send(&self, request: &Request, _: Instant) -> Result>, Error> { + async fn send_once(&self, request: &Request, _: Instant) -> Result { assert_eq!(request.method, "POST"); self.0.fetch_add(1, Ordering::SeqCst); - Ok(Zeroizing::new(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#.to_vec())) + Ok(ok(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#)) } } #[tokio::test] @@ -28,16 +48,27 @@ async fn encoding_and_cache() { ); assert_eq!(req.headers[1].1, req.body.len().to_string()); let fake = Fake(AtomicUsize::new(0)); - let mut cache = Cache::default(); - let deadline = Instant::now() + Duration::from_secs(1); - let t = cache.refresh("id", 1, &req, &fake, deadline).await.unwrap(); + let cache = Cache::default(); + let t = cache + .refresh(&item(1), &req, &fake, deadline()) + .await + .unwrap(); assert_eq!(t.scopes.unwrap(), vec!["one", "two"]); - cache.refresh("id", 1, &req, &fake, deadline).await.unwrap(); + cache + .refresh(&item(1), &req, &fake, deadline()) + .await + .unwrap(); assert_eq!(fake.0.load(Ordering::SeqCst), 1); - cache.refresh("id", 2, &req, &fake, deadline).await.unwrap(); + cache + .refresh(&item(2), &req, &fake, deadline()) + .await + .unwrap(); assert_eq!(fake.0.load(Ordering::SeqCst), 2); - cache.clear(); - cache.refresh("id", 2, &req, &fake, deadline).await.unwrap(); + cache.clear().unwrap(); + cache + .refresh(&item(2), &req, &fake, deadline()) + .await + .unwrap(); assert_eq!(fake.0.load(Ordering::SeqCst), 3); for url in [ "http://tokens.example.test/token", @@ -51,36 +82,204 @@ async fn encoding_and_cache() { async fn expiry_and_failure() { struct Expired(AtomicUsize); impl Http for Expired { - async fn send(&self, _: &Request, _: Instant) -> Result>, Error> { + async fn send_once(&self, _: &Request, _: Instant) -> Result { self.0.fetch_add(1, Ordering::SeqCst); - Ok(Zeroizing::new( - br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#.to_vec(), + Ok(ok( + br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#, )) } } struct Failed; impl Http for Failed { - async fn send(&self, _: &Request, _: Instant) -> Result>, Error> { + async fn send_once(&self, _: &Request, _: Instant) -> Result { Err(Error) } } let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap(); - let mut cache = Cache::default(); + let cache = Cache::default(); let http = Expired(AtomicUsize::new(0)); - let deadline = Instant::now() + Duration::from_secs(1); - cache.refresh("id", 1, &req, &http, deadline).await.unwrap(); - cache.refresh("id", 1, &req, &http, deadline).await.unwrap(); + cache + .refresh(&item(1), &req, &http, deadline()) + .await + .unwrap(); + cache + .refresh(&item(1), &req, &http, deadline()) + .await + .unwrap(); assert_eq!(http.0.load(Ordering::SeqCst), 2); assert!( cache - .refresh("id", 2, &req, &Failed, deadline) + .refresh(&item(2), &req, &Failed, deadline()) .await .is_err() ); - cache.refresh("id", 2, &req, &http, deadline).await.unwrap(); + cache + .refresh(&item(2), &req, &http, deadline()) + .await + .unwrap(); assert_eq!(http.0.load(Ordering::SeqCst), 3); } - +#[tokio::test] +async fn endpoint_policy_applies_before_http_and_cache_hits() { + let cache = Cache::default(); + let fake = Fake(AtomicUsize::new(0)); + let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap(); + cache + .refresh(&item(1), &req, &fake, deadline()) + .await + .unwrap(); + for rule in [ + serde_json::json!({"scheme":"https","hosts":["wrong.example.test"],"path_prefixes":["/token"],"methods":["POST"]}), + serde_json::json!({"scheme":"http","hosts":["tokens.example.test"],"path_prefixes":["/token"],"methods":["POST"]}), + serde_json::json!({"scheme":"https","hosts":["tokens.example.test"],"path_prefixes":["/elsewhere"],"methods":["POST"]}), + serde_json::json!({"scheme":"https","hosts":["tokens.example.test"],"path_prefixes":["/token"],"methods":["GET"]}), + ] { + let mut denied = item(1); + denied.rules = vec![serde_json::from_value(rule).unwrap()]; + assert!( + cache + .refresh(&denied, &req, &fake, deadline()) + .await + .is_err() + ); + denied.version = 2; + assert!( + cache + .refresh(&denied, &req, &fake, deadline()) + .await + .is_err() + ); + } + for url in [ + "http://tokens.example.test/token", + "https://user@tokens.example.test/token", + "https://tokens.example.test/token#x", + "https://wrong.example.test/token", + ] { + let mut altered = request("https://tokens.example.test/token", "r", "c", "s").unwrap(); + altered.url = url.into(); + assert!( + cache + .refresh(&item(1), &altered, &fake, deadline()) + .await + .is_err() + ); + } + let mut altered = req; + altered.method = "GET"; + assert!( + cache + .refresh(&item(1), &altered, &fake, deadline()) + .await + .is_err() + ); + assert_eq!(fake.0.load(Ordering::SeqCst), 1); +} +#[tokio::test] +async fn rejects_redirects_without_a_second_exchange() { + struct Redirect(AtomicUsize); + impl Http for Redirect { + async fn send_once(&self, _: &Request, _: Instant) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + let mut response = + ok(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600}"#); + response.status = http::StatusCode::FOUND; + Ok(response) + } + } + let http = Redirect(AtomicUsize::new(0)); + let cache = Cache::default(); + let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap(); + assert!( + cache + .refresh(&item(1), &req, &http, deadline()) + .await + .is_err() + ); + assert_eq!(http.0.load(Ordering::SeqCst), 1); +} +#[tokio::test] +async fn injection_replaces_the_refresh_field() { + let fields: BTreeMap<_, _> = [ + ("token_url", "https://tokens.example.test/token"), + ("refresh_token", "REFRESH"), + ("client_id", "CLIENT"), + ("client_secret", "SECRET"), + ] + .into_iter() + .map(|(k, v)| (k.into(), Zeroizing::new(v.into()))) + .collect(); + let fake = Fake(AtomicUsize::new(0)); + let result = Cache::default() + .fields_for_injection(&item(1), fields, &fake, deadline()) + .await + .unwrap(); + assert!(result["refresh_token"].as_str() == "SYNTHETIC"); + assert!(result["client_secret"].as_str() == "SECRET"); +} +#[tokio::test] +async fn inflight_releases_the_lock_and_cleans_up() { + struct Blocking { + started: tokio::sync::Notify, + resume: tokio::sync::Notify, + } + impl Http for Blocking { + async fn send_once(&self, _: &Request, _: Instant) -> Result { + self.started.notify_one(); + self.resume.notified().await; + Ok(ok( + br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600}"#, + )) + } + } + for clear in [false, true] { + let cache = Arc::new(Cache::default()); + let http = Arc::new(Blocking { + started: tokio::sync::Notify::new(), + resume: tokio::sync::Notify::new(), + }); + let task = tokio::spawn({ + let cache = cache.clone(); + let http = http.clone(); + async move { + let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap(); + cache + .refresh(&item(1), &req, http.as_ref(), deadline()) + .await + } + }); + tokio::time::timeout(Duration::from_secs(1), http.started.notified()) + .await + .unwrap(); + let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap(); + let fake = Fake(AtomicUsize::new(0)); + assert!( + cache + .refresh(&item(1), &req, &fake, deadline()) + .await + .is_err() + ); + let mut other = item(1); + other.id = "other".into(); + cache + .refresh(&other, &req, &fake, deadline()) + .await + .unwrap(); + if clear { + cache.clear().unwrap(); + http.resume.notify_one(); + assert!(task.await.unwrap().is_err()); + } else { + task.abort(); + assert!(matches!(task.await, Err(error) if error.is_cancelled())); + } + cache + .refresh(&item(1), &req, &fake, deadline()) + .await + .unwrap(); + assert_eq!(fake.0.load(Ordering::SeqCst), 2); + } +} #[test] fn validation() { for data in [ diff --git a/host/tests/cred_v8_security.rs b/host/tests/cred_v8_security.rs new file mode 100644 index 0000000..ae8b085 --- /dev/null +++ b/host/tests/cred_v8_security.rs @@ -0,0 +1,56 @@ +//! Crash regression for a single large V8 allocation. The child contains native V8 aborts. +use sausage::cred::{ + script::{Limits, Script}, + v8::V8, +}; + +#[test] +fn removes_bootstrap_capabilities_and_clocks() { + let result = V8.evaluate( + "export default () => [typeof WebAssembly,typeof SharedArrayBuffer,typeof Atomics,typeof __infra,typeof Intl,typeof queueMicrotask]", + "null", None, Limits { max_time_ms: Some(1000), ..Limits::default() }, + ).unwrap(); + assert_eq!( + result, + "[\"undefined\",\"undefined\",\"undefined\",\"undefined\",\"undefined\",\"undefined\"]" + ); +} + +#[test] +fn flat_string_child() { + if std::env::var_os("T28_FLAT_STRING_CHILD").is_none() { + return; + } + let bits = std::env::var("T28_FLAT_STRING_BITS").unwrap_or_else(|_| "28".into()); + assert!(matches!(bits.as_str(), "27" | "28")); + let result = V8.evaluate( + &format!("export default () => 'x'.repeat(2**{bits}).indexOf('y')"), + "null", + None, + Limits { + max_time_ms: Some(1000), + ..Limits::default() + }, + ); + assert!( + result.is_err(), + "the flat string bypassed the configured heap budget" + ); +} + +#[test] +fn flat_string_does_not_abort() { + for bits in ["27", "28"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "flat_string_child", "--nocapture"]) + .env("T28_FLAT_STRING_CHILD", "1") + .env("T28_FLAT_STRING_BITS", bits) + .output() + .unwrap(); + assert!( + output.status.success(), + "the script worker aborted or failed at 2**{bits} bytes: {}", + output.status + ); + } +}