use std::collections::BTreeMap;
use std::sync::Arc;
#[cfg(any(
feature = "outbound-http",
feature = "outbound-tcp",
feature = "fat-guest",
feature = "streaming-body"
))]
use wasmtime::component::Linker;
use wasmtime::{StoreLimits, StoreLimitsBuilder};
use crate::LogLevel;
use crate::bindings;
use crate::bindings::plecto::filter::{
host_clock, host_config, host_counter, host_kv, host_log, host_ratelimit,
};
#[cfg(feature = "outbound-http")]
use crate::outbound_http;
#[cfg(feature = "outbound-tcp")]
use crate::outbound_tcp;
use crate::quota::KvQuota;
use crate::util::wall_now_ms;
use crate::{Bucket, KvBackend};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
pub level: LogLevel,
pub message: String,
}
pub(crate) const KV_NS_DELIM: char = '\u{1f}';
const TAG_KV: u8 = b'k';
const TAG_COUNTER: u8 = b'c';
const TAG_RATELIMIT: u8 = b'r';
const MAX_KV_VALUE_BYTES: usize = 256 * 1024;
const MAX_KV_KEY_BYTES: usize = 1024;
const MAX_LOG_LINES_PER_REQUEST: usize = 256;
const MAX_LOG_MSG_BYTES: usize = 8 * 1024;
pub(crate) const MAX_TABLE_ELEMENTS: usize = 100_000;
pub(crate) fn sanitize_log_message(mut message: String, max_bytes: usize) -> String {
if message.len() > max_bytes {
let mut end = max_bytes;
while !message.is_char_boundary(end) {
end -= 1;
}
message.truncate(end);
}
if message.bytes().any(|b| b < 0x20 || b == 0x7f) {
message = message
.chars()
.map(|c| if c.is_control() { '\u{fffd}' } else { c })
.collect();
}
message
}
pub(crate) trait LogBudget {
fn admit(&mut self, level: LogLevel, message: String) -> Option<(LogLevel, String)>;
}
pub(crate) struct LineCountBudget {
max_lines: usize,
count: usize,
}
impl LineCountBudget {
pub(crate) fn new(max_lines: usize) -> Self {
Self {
max_lines,
count: 0,
}
}
pub(crate) fn reset(&mut self) {
self.count = 0;
}
}
impl LogBudget for LineCountBudget {
fn admit(&mut self, level: LogLevel, message: String) -> Option<(LogLevel, String)> {
let last_slot = self.max_lines.saturating_sub(1);
if self.count < last_slot {
self.count += 1;
Some((level, sanitize_log_message(message, MAX_LOG_MSG_BYTES)))
} else if self.count == last_slot {
self.count += 1;
Some((
LogLevel::Warn,
"… host-log truncated (per-request line cap reached)".to_string(),
))
} else {
None
}
}
}
pub struct HostState {
kv: Arc<dyn KvBackend>,
kv_prefix: String,
pub(crate) logs: Vec<LogLine>,
host_log_budget: LineCountBudget,
now_ms: u64,
pub(crate) limits: StoreLimits,
ratelimit_bucket: Option<Bucket>,
quota: Arc<KvQuota>,
#[cfg(any(
feature = "outbound-http",
feature = "outbound-tcp",
feature = "fat-guest"
))]
wasi: wasmtime_wasi::WasiCtx,
#[cfg(any(
feature = "outbound-http",
feature = "outbound-tcp",
feature = "fat-guest"
))]
table: wasmtime::component::ResourceTable,
#[cfg(feature = "outbound-http")]
http_ctx: wasmtime_wasi_http::WasiHttpCtx,
#[cfg(feature = "outbound-http")]
hooks: outbound_http::PlectoHttpHooks,
#[cfg(feature = "outbound-tcp")]
tcp: outbound_tcp::TcpGuard,
#[cfg(feature = "fat-guest")]
stdio_bridge: Option<crate::stdio::StdioBridge>,
config: Arc<BTreeMap<String, String>>,
}
pub(crate) struct HostStateInit {
pub(crate) kv: Arc<dyn KvBackend>,
pub(crate) kv_prefix: String,
pub(crate) max_memory_bytes: u64,
pub(crate) ratelimit_bucket: Option<Bucket>,
pub(crate) quota: Arc<KvQuota>,
pub(crate) config: Arc<BTreeMap<String, String>>,
#[cfg(feature = "fat-guest")]
pub(crate) wasi_minimal: bool,
}
impl HostState {
pub(crate) fn new(
init: HostStateInit,
#[cfg(feature = "outbound-http")] hooks: outbound_http::PlectoHttpHooks,
#[cfg(feature = "outbound-tcp")] tcp: outbound_tcp::TcpGuard,
) -> Self {
let HostStateInit {
kv,
kv_prefix,
max_memory_bytes,
ratelimit_bucket,
quota,
config,
#[cfg(feature = "fat-guest")]
wasi_minimal,
} = init;
#[cfg(any(
feature = "outbound-http",
feature = "outbound-tcp",
feature = "fat-guest"
))]
let mut wasi_builder = wasmtime_wasi::WasiCtxBuilder::new();
#[cfg(feature = "outbound-tcp")]
tcp.configure_wasi_ctx(&mut wasi_builder);
#[cfg(feature = "fat-guest")]
let stdio_bridge = if wasi_minimal {
let bridge = crate::stdio::StdioBridge::new();
wasi_builder.stdout(bridge.stream(crate::stdio::StdioKind::Stdout));
wasi_builder.stderr(bridge.stream(crate::stdio::StdioKind::Stderr));
Some(bridge)
} else {
None
};
Self {
kv,
kv_prefix,
logs: Vec::new(),
host_log_budget: LineCountBudget::new(MAX_LOG_LINES_PER_REQUEST),
now_ms: wall_now_ms(),
limits: StoreLimitsBuilder::new()
.memory_size(max_memory_bytes as usize)
.table_elements(MAX_TABLE_ELEMENTS)
.build(),
ratelimit_bucket,
quota,
config,
#[cfg(any(
feature = "outbound-http",
feature = "outbound-tcp",
feature = "fat-guest"
))]
wasi: wasi_builder.build(),
#[cfg(any(
feature = "outbound-http",
feature = "outbound-tcp",
feature = "fat-guest"
))]
table: wasmtime::component::ResourceTable::new(),
#[cfg(feature = "outbound-http")]
http_ctx: wasmtime_wasi_http::WasiHttpCtx::new(),
#[cfg(feature = "outbound-http")]
hooks,
#[cfg(feature = "outbound-tcp")]
tcp,
#[cfg(feature = "fat-guest")]
stdio_bridge,
}
}
pub(crate) fn take_logs(&mut self) -> Vec<LogLine> {
#[cfg(feature = "fat-guest")]
if let Some(bridge) = &self.stdio_bridge {
self.logs.extend(bridge.drain());
}
std::mem::take(&mut self.logs)
}
pub(crate) fn take_logs_final(&mut self) -> Vec<LogLine> {
#[cfg(feature = "fat-guest")]
if let Some(bridge) = &self.stdio_bridge {
self.logs.extend(bridge.drain_final());
}
std::mem::take(&mut self.logs)
}
pub(crate) fn begin_request(&mut self) {
self.logs.clear();
self.host_log_budget.reset();
#[cfg(feature = "fat-guest")]
if let Some(bridge) = &self.stdio_bridge {
bridge.begin_request();
}
self.now_ms = wall_now_ms();
#[cfg(feature = "outbound-tcp")]
self.tcp.begin_request();
}
#[cfg(feature = "outbound-tcp")]
pub(crate) fn tcp_lookup(&mut self) -> outbound_tcp::TcpLookupView<'_> {
outbound_tcp::TcpLookupView {
table: &mut self.table,
guard: &self.tcp,
}
}
fn ns_key(&self, tag: u8, key: &str) -> Vec<u8> {
let mut k = Vec::with_capacity(self.kv_prefix.len() + 2 + key.len());
k.extend_from_slice(self.kv_prefix.as_bytes());
k.push(tag);
k.push(KV_NS_DELIM as u8);
k.extend_from_slice(key.as_bytes());
k
}
}
#[cfg(any(
feature = "outbound-http",
feature = "outbound-tcp",
feature = "fat-guest"
))]
impl wasmtime_wasi::WasiView for HostState {
fn ctx(&mut self) -> wasmtime_wasi::WasiCtxView<'_> {
wasmtime_wasi::WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
#[cfg(feature = "outbound-http")]
impl wasmtime_wasi_http::p2::WasiHttpView for HostState {
fn http(&mut self) -> wasmtime_wasi_http::p2::WasiHttpCtxView<'_> {
wasmtime_wasi_http::p2::WasiHttpCtxView {
ctx: &mut self.http_ctx,
table: &mut self.table,
hooks: &mut self.hooks,
}
}
}
#[cfg(any(
feature = "outbound-http",
feature = "outbound-tcp",
feature = "fat-guest",
feature = "streaming-body"
))]
pub(crate) fn add_cli_runtime<T: wasmtime_wasi::cli::WasiCliView>(
linker: &mut Linker<T>,
) -> wasmtime::Result<()> {
use wasmtime_wasi::cli::{WasiCli, WasiCliView};
use wasmtime_wasi::p2::bindings::cli;
let getter = <T as WasiCliView>::cli;
cli::environment::add_to_linker::<T, WasiCli>(linker, getter)?;
cli::exit::add_to_linker::<T, WasiCli>(linker, getter)?;
cli::terminal_input::add_to_linker::<T, WasiCli>(linker, getter)?;
cli::terminal_output::add_to_linker::<T, WasiCli>(linker, getter)?;
cli::terminal_stdin::add_to_linker::<T, WasiCli>(linker, getter)?;
cli::terminal_stdout::add_to_linker::<T, WasiCli>(linker, getter)?;
cli::terminal_stderr::add_to_linker::<T, WasiCli>(linker, getter)?;
Ok(())
}
#[cfg(feature = "fat-guest")]
pub(crate) fn add_inert_filesystem<T: wasmtime_wasi::filesystem::WasiFilesystemView>(
linker: &mut Linker<T>,
) -> wasmtime::Result<()> {
use wasmtime_wasi::filesystem::{WasiFilesystem, WasiFilesystemView};
use wasmtime_wasi::p2::bindings::filesystem;
let getter = <T as WasiFilesystemView>::filesystem;
filesystem::types::add_to_linker::<T, WasiFilesystem>(linker, getter)?;
filesystem::preopens::add_to_linker::<T, WasiFilesystem>(linker, getter)?;
Ok(())
}
impl bindings::plecto::filter::types::Host for HostState {}
impl host_log::Host for HostState {
fn log(&mut self, level: LogLevel, message: String) {
if let Some((level, message)) = self.host_log_budget.admit(level, message) {
self.logs.push(LogLine { level, message });
}
}
}
impl host_clock::Host for HostState {
fn now_ms(&mut self) -> u64 {
self.now_ms
}
}
impl host_kv::Host for HostState {
fn get(&mut self, key: String) -> Option<Vec<u8>> {
self.kv.get(&self.ns_key(TAG_KV, &key))
}
fn set(&mut self, key: String, value: Vec<u8>) {
if key.len() > MAX_KV_KEY_BYTES || value.len() > MAX_KV_VALUE_BYTES {
return;
}
let nskey = self.ns_key(TAG_KV, &key);
let kv = &self.kv;
let key_len = key.len();
let value_len = value.len();
self.quota.charge_and_apply(
&self.kv_prefix,
&nskey,
|| match kv.get(&nskey).map(|v| v.len()) {
None => (1isize, (key_len + value_len) as isize),
Some(old) => (0isize, value_len as isize - old as isize),
},
|| kv.set(&nskey, value),
);
}
fn delete(&mut self, key: String) {
let nskey = self.ns_key(TAG_KV, &key);
let kv = &self.kv;
let key_len = key.len();
self.quota.charge_and_apply(
&self.kv_prefix,
&nskey,
|| match kv.get(&nskey).map(|v| v.len()) {
Some(old) => (-1isize, -((key_len + old) as isize)),
None => (0isize, 0isize),
},
|| kv.delete(&nskey),
);
}
}
impl host_counter::Host for HostState {
fn increment(&mut self, key: String, delta: i64) -> i64 {
let nskey = self.ns_key(TAG_COUNTER, &key);
if delta == 0 {
return self.kv.increment(&nskey, 0);
}
let kv = &self.kv;
let key_len = key.len();
self.quota
.charge_and_apply(
&self.kv_prefix,
&nskey,
|| {
if kv.get(&nskey).is_none() {
(1isize, (key_len + 8) as isize)
} else {
(0isize, 0isize)
}
},
|| kv.increment(&nskey, delta),
)
.unwrap_or(0)
}
fn get(&mut self, key: String) -> i64 {
self.kv.increment(&self.ns_key(TAG_COUNTER, &key), 0)
}
}
impl host_ratelimit::Host for HostState {
fn try_acquire(&mut self, key: String, cost: u64) -> host_ratelimit::Acquire {
let Some(spec) = self.ratelimit_bucket else {
return host_ratelimit::Acquire {
allowed: false,
remaining: 0,
retry_after_ms: 0,
};
};
let nskey = self.ns_key(TAG_RATELIMIT, &key);
let kv = &self.kv;
let key_len = key.len();
let now_ms = self.now_ms;
let result = self.quota.charge_and_apply(
&self.kv_prefix,
&nskey,
|| {
if kv.get(&nskey).is_none() {
(1isize, (key_len + 16) as isize)
} else {
(0isize, 0isize)
}
},
|| kv.try_acquire(&nskey, cost, spec, now_ms),
);
match result {
Some(r) => host_ratelimit::Acquire {
allowed: r.allowed,
remaining: r.remaining,
retry_after_ms: r.retry_after_ms,
},
None => host_ratelimit::Acquire {
allowed: false,
remaining: 0,
retry_after_ms: 0,
},
}
}
}
impl host_config::Host for HostState {
fn get(&mut self, key: String) -> Option<String> {
self.config.get(&key).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MemoryBackend;
use crate::options::DEFAULT_MAX_MEMORY_BYTES;
use host_clock::Host as ClockHost;
use host_config::Host as ConfigHost;
use host_counter::Host as CounterHost;
use host_kv::Host as KvHost;
use host_log::Host as LogHost;
fn init_for(prefix: &str) -> HostStateInit {
HostStateInit {
kv: Arc::new(MemoryBackend::default()),
kv_prefix: prefix.to_string(),
max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES,
ratelimit_bucket: None,
quota: Arc::new(KvQuota::new()),
config: Arc::new(BTreeMap::new()),
#[cfg(feature = "fat-guest")]
wasi_minimal: false,
}
}
fn state(prefix: &str) -> HostState {
HostState::new(
init_for(prefix),
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
)
}
#[test]
fn config_get_is_a_readonly_passthrough_of_declared_keys() {
let mut s = HostState::new(
HostStateInit {
config: Arc::new(BTreeMap::from([(
"on_backend_error".to_string(),
"deny".to_string(),
)])),
..init_for("cfg\u{1f}")
},
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
);
assert_eq!(
ConfigHost::get(&mut s, "on_backend_error".into()),
Some("deny".to_string())
);
assert_eq!(ConfigHost::get(&mut s, "window_secs".into()), None);
}
#[test]
fn kv_get_set_delete_roundtrip() {
let mut s = state("test\u{1f}");
assert_eq!(KvHost::get(&mut s, "k".into()), None);
KvHost::set(&mut s, "k".into(), b"v".to_vec());
assert_eq!(KvHost::get(&mut s, "k".into()), Some(b"v".to_vec()));
KvHost::delete(&mut s, "k".into());
assert_eq!(KvHost::get(&mut s, "k".into()), None);
}
#[test]
fn kv_is_namespaced_per_filter() {
let shared: Arc<dyn KvBackend> = Arc::new(MemoryBackend::default());
let mut a = HostState::new(
HostStateInit {
kv: shared.clone(),
..init_for("filter-a\u{1f}")
},
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
);
let mut b = HostState::new(
HostStateInit {
kv: shared.clone(),
..init_for("filter-b\u{1f}")
},
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
);
KvHost::set(&mut a, "count".into(), b"1".to_vec());
assert_eq!(
KvHost::get(&mut b, "count".into()),
None,
"b must not see a"
);
assert_eq!(KvHost::get(&mut a, "count".into()), Some(b"1".to_vec()));
KvHost::set(&mut a, format!("x{}count", '\u{1f}'), b"evil".to_vec());
assert_eq!(KvHost::get(&mut b, "count".into()), None);
}
#[test]
fn counter_is_namespaced_per_filter() {
let shared: Arc<dyn KvBackend> = Arc::new(MemoryBackend::default());
let mut a = HostState::new(
HostStateInit {
kv: shared.clone(),
..init_for("filter-a\u{1f}")
},
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
);
let mut b = HostState::new(
HostStateInit {
kv: shared.clone(),
..init_for("filter-b\u{1f}")
},
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
);
assert_eq!(CounterHost::increment(&mut a, "hits".into(), 5), 5);
assert_eq!(
CounterHost::get(&mut b, "hits".into()),
0,
"b must not observe a's counter"
);
assert_eq!(
CounterHost::increment(&mut b, "hits".into(), 1),
1,
"b's counter is independent of a's"
);
assert_eq!(
CounterHost::get(&mut a, "hits".into()),
5,
"a's counter is untouched by b"
);
}
#[test]
fn ratelimit_bucket_is_namespaced_per_filter() {
use host_ratelimit::Host as RateLimitHost;
fn one_token_no_refill() -> Bucket {
Bucket {
capacity: 1,
refill_tokens: 0,
refill_interval_ms: 0,
}
}
let shared: Arc<dyn KvBackend> = Arc::new(MemoryBackend::default());
let mut a = HostState::new(
HostStateInit {
kv: shared.clone(),
ratelimit_bucket: Some(one_token_no_refill()),
..init_for("filter-a\u{1f}")
},
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
);
let mut b = HostState::new(
HostStateInit {
kv: shared.clone(),
ratelimit_bucket: Some(one_token_no_refill()),
..init_for("filter-b\u{1f}")
},
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
);
assert!(RateLimitHost::try_acquire(&mut a, "k".into(), 1).allowed);
assert!(
!RateLimitHost::try_acquire(&mut a, "k".into(), 1).allowed,
"a's bucket is now empty"
);
assert!(
RateLimitHost::try_acquire(&mut b, "k".into(), 1).allowed,
"b's limiter must not share a's drained bucket"
);
}
#[test]
fn kv_and_counter_do_not_collide() {
let mut s = state("f\u{1f}");
KvHost::set(&mut s, "x".into(), b"bytes".to_vec());
assert_eq!(CounterHost::increment(&mut s, "x".into(), 7), 7);
assert_eq!(KvHost::get(&mut s, "x".into()), Some(b"bytes".to_vec()));
assert_eq!(CounterHost::get(&mut s, "x".into()), 7);
}
#[test]
fn counter_increment_and_read() {
let mut s = state("f\u{1f}");
assert_eq!(CounterHost::get(&mut s, "n".into()), 0);
assert_eq!(CounterHost::increment(&mut s, "n".into(), 1), 1);
assert_eq!(CounterHost::increment(&mut s, "n".into(), 2), 3);
assert_eq!(CounterHost::get(&mut s, "n".into()), 3);
}
#[test]
fn log_captures_lines() {
let mut s = state("test\u{1f}");
LogHost::log(&mut s, LogLevel::Info, "hello".into());
assert_eq!(s.logs.len(), 1);
assert_eq!(s.logs[0].message, "hello");
}
#[test]
fn begin_request_resets_logs_keeps_namespace() {
let mut s = state("test\u{1f}");
LogHost::log(&mut s, LogLevel::Info, "first".into());
s.begin_request();
assert!(s.logs.is_empty(), "logs reset for the next request");
}
#[test]
fn clock_returns_nonzero_wall_time() {
let mut s = state("test\u{1f}");
assert!(ClockHost::now_ms(&mut s) > 0);
}
#[test]
fn kv_value_over_cap_is_dropped_fail_closed() {
let mut s = state("f\u{1f}");
KvHost::set(&mut s, "big".into(), vec![0u8; MAX_KV_VALUE_BYTES + 1]);
assert_eq!(
KvHost::get(&mut s, "big".into()),
None,
"an over-cap value is dropped"
);
KvHost::set(&mut s, "ok".into(), vec![0u8; 128]);
assert_eq!(KvHost::get(&mut s, "ok".into()), Some(vec![0u8; 128]));
}
#[test]
fn concurrent_delete_of_the_same_key_releases_quota_exactly_once() {
use std::sync::Barrier;
use std::thread;
let shared: Arc<dyn KvBackend> = Arc::new(MemoryBackend::default());
let quota = Arc::new(KvQuota::new());
let prefix = "f\u{1f}".to_string();
let mut seed = HostState::new(
HostStateInit {
kv: shared.clone(),
quota: quota.clone(),
..init_for(&prefix)
},
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
);
KvHost::set(&mut seed, "k1".into(), vec![0u8; 100]);
KvHost::set(&mut seed, "k2".into(), vec![0u8; 100]);
assert_eq!(
quota.usage_for_test(&prefix),
(2, 2 * (2 + 100)),
"both keys charged once each"
);
const RACERS: usize = 8;
let barrier = Arc::new(Barrier::new(RACERS));
let handles: Vec<_> = (0..RACERS)
.map(|_| {
let kv = shared.clone();
let q = quota.clone();
let p = prefix.clone();
let b = barrier.clone();
thread::spawn(move || {
let mut s = HostState::new(
HostStateInit {
kv,
quota: q,
..init_for(&p)
},
#[cfg(feature = "outbound-http")]
outbound_http::PlectoHttpHooks::deny_all(),
#[cfg(feature = "outbound-tcp")]
crate::outbound_tcp::TcpGuard::deny_all(),
);
b.wait();
KvHost::delete(&mut s, "k1".into());
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(
quota.usage_for_test(&prefix),
(1, 2 + 100),
"k1's single release must not consume k2's untouched budget"
);
}
#[test]
fn host_log_is_capped_and_sanitized() {
let mut s = state("f\u{1f}");
LogHost::log(&mut s, LogLevel::Info, "a\r\nInjected: x\u{1b}[31m".into());
assert!(
!s.logs[0].message.contains('\r') && !s.logs[0].message.contains('\n'),
"CR/LF are neutralized (no log-line injection)"
);
assert!(
!s.logs[0].message.contains('\u{1b}'),
"the ANSI escape is neutralized"
);
let mut s2 = state("f\u{1f}");
LogHost::log(&mut s2, LogLevel::Info, "x".repeat(MAX_LOG_MSG_BYTES * 2));
assert!(s2.logs[0].message.len() <= MAX_LOG_MSG_BYTES);
let mut s3 = state("f\u{1f}");
for i in 0..(MAX_LOG_LINES_PER_REQUEST + 50) {
LogHost::log(&mut s3, LogLevel::Info, format!("line {i}"));
}
assert_eq!(s3.logs.len(), MAX_LOG_LINES_PER_REQUEST);
assert!(
s3.logs.last().unwrap().message.contains("truncated"),
"the final retained line is the truncation marker"
);
}
}