cred: blind RSA signatures and stage T28 security regressions (WIP)

This commit is contained in:
2026-11-06 21:17:52 +00:00
parent 16a9a9eb6c
commit 940ae61af6
7 changed files with 503 additions and 67 deletions
Generated
+1
View File
@@ -7843,6 +7843,7 @@ version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
dependencies = [
"serde",
"zeroize_derive",
]
+1 -1
View File
@@ -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"
+63 -6
View File
@@ -190,12 +190,7 @@ pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8
let s: p256::ecdsa::Signature = k.sign(payload);
s.to_bytes().to_vec()
}
Key::Rsa(k) => if format == "rsa-sha2-512" {
k.sign(rsa::Pkcs1v15Sign::new::<Sha512>(), &Sha512::digest(payload))
} else {
k.sign(rsa::Pkcs1v15Sign::new::<Sha256>(), &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<Vec<u8
}
})
}
fn rsa_signature(
key: &rsa::RsaPrivateKey,
format: &str,
payload: &[u8],
rng: &mut impl rsa::rand_core::CryptoRngCore,
) -> Result<Vec<u8>> {
if format == "rsa-sha2-512" {
key.sign_with_rng(
rng,
rsa::Pkcs1v15Sign::new::<Sha512>(),
&Sha512::digest(payload),
)
} else {
key.sign_with_rng(
rng,
rsa::Pkcs1v15Sign::new::<Sha256>(),
&Sha256::digest(payload),
)
}
.map_err(|_| invalid_key())
}
fn string(out: &mut Vec<u8>, 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<String> {
_ => 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"
);
}
}
}
+139 -29
View File
@@ -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<Output = Result<Zeroizing<Vec<u8>>, Error>> + Send;
) -> impl std::future::Future<Output = Result<Response, Error>> + Send;
}
/// The status and body of one response, without any redirect exchange.
pub struct Response {
pub status: http::StatusCode,
pub body: Zeroizing<Vec<u8>>,
}
/// 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<String>,
@@ -69,51 +96,134 @@ pub struct Token {
pub fn decode(bytes: &[u8]) -> Result<Token, Error> {
#[derive(serde::Deserialize)]
struct Response {
access_token: String,
access_token: Zeroizing<String>,
token_type: String,
expires_in: u64,
scope: Option<String>,
}
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<String>,
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<State>,
}
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<Token, Error> {
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<String, Zeroizing<String>>,
http: &impl Http,
deadline: Instant,
) -> Result<BTreeMap<String, Zeroizing<String>>, 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)
}
}
+21 -8
View File
@@ -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<String, Error> {
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<String>,
export: String,
limits: Limits,
) -> Result<String, Error> {
@@ -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>, String, Limits) -> Result<String, Error> =
evaluate;
}
}
+222 -23
View File
@@ -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<Zeroizing<Vec<u8>>, Error> {
async fn send_once(&self, request: &Request, _: Instant) -> Result<Response, Error> {
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<Zeroizing<Vec<u8>>, Error> {
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
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<Zeroizing<Vec<u8>>, Error> {
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
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<Response, Error> {
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<Response, Error> {
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 [
+56
View File
@@ -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
);
}
}