1use std::borrow::Cow;
21
22use base64::Engine as _;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use sha2::{Digest, Sha256};
26
27use super::StoreData;
28use super::decision::{CallTicket, SourceLine};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum CallOutcome {
34 Returned,
36 Failed,
38 Unfinished,
40}
41
42impl PayloadRecord {
43 pub(crate) fn digest_only(&self) -> Self {
46 Self {
47 meta: Value::Null,
48 body: None,
49 digest: self.digest.clone(),
50 bytes: self.bytes,
51 truncated: self.truncated,
52 masked_headers: Vec::new(),
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case", tag = "encoding", content = "data")]
60pub enum BodyCopy {
61 Text(String),
62 Base64(String),
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PayloadRecord {
68 pub meta: Value,
71 pub body: Option<BodyCopy>,
73 pub digest: String,
75 pub bytes: u64,
78 pub truncated: bool,
80 pub masked_headers: Vec<String>,
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub struct ModelUsage {
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub input_tokens: Option<u64>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub output_tokens: Option<u64>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct CallRecord {
96 pub call_index: u64,
98 pub caller: String,
99 pub capability: String,
100 pub started_micros: u64,
102 pub ended_micros: Option<u64>,
104 pub outcome: Option<CallOutcome>,
105 pub line: Option<SourceLine>,
107 pub request: Option<Box<PayloadRecord>>,
109 pub response: Option<Box<PayloadRecord>>,
111 pub usage: Option<ModelUsage>,
112}
113
114impl CallRecord {
115 pub fn without_bodies(&self) -> Self {
119 let strip = |side: &Option<Box<PayloadRecord>>| {
120 side.as_ref().map(|payload| Box::new(payload.digest_only()))
121 };
122 Self {
123 call_index: self.call_index,
124 caller: self.caller.clone(),
125 capability: self.capability.clone(),
126 started_micros: self.started_micros,
127 ended_micros: self.ended_micros,
128 outcome: self.outcome,
129 line: self.line,
130 request: strip(&self.request),
131 response: strip(&self.response),
132 usage: self.usage,
133 }
134 }
135}
136
137pub struct Payload<'a> {
139 pub meta: Value,
140 pub body: Option<Cow<'a, [u8]>>,
141 pub bytes: Option<u64>,
144 pub masked_headers: Vec<String>,
145}
146
147impl<'a> Payload<'a> {
148 pub fn meta(meta: Value) -> Self {
149 Self {
150 meta,
151 body: None,
152 bytes: None,
153 masked_headers: Vec::new(),
154 }
155 }
156
157 pub fn with_body(mut self, body: &'a [u8]) -> Self {
158 self.body = Some(Cow::Borrowed(body));
159 self
160 }
161
162 pub fn with_owned_body(mut self, body: Vec<u8>) -> Self {
163 self.body = Some(Cow::Owned(body));
164 self
165 }
166
167 pub fn with_size(mut self, bytes: u64) -> Self {
168 self.bytes = Some(bytes);
169 self
170 }
171
172 pub fn with_masked(mut self, masked: Vec<String>) -> Self {
173 self.masked_headers = masked;
174 self
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum Side {
181 Request,
182 Response,
183}
184
185pub const MASKED: &str = "[masked]";
187
188pub fn is_sensitive_header(name: &str) -> bool {
192 let name = name.to_ascii_lowercase();
193 matches!(name.as_str(), "cookie" | "set-cookie")
194 || [
195 "key",
196 "token",
197 "secret",
198 "auth",
199 "password",
200 "passwd",
201 "signature",
202 "credential",
203 ]
204 .iter()
205 .any(|word| name.contains(word))
206}
207
208pub fn mask_headers(headers: &[(String, String)]) -> (Value, Vec<String>) {
211 let mut masked = Vec::new();
212 let pairs = headers
213 .iter()
214 .map(|(name, value)| {
215 if is_sensitive_header(name) {
216 masked.push(name.clone());
217 Value::from(vec![Value::from(name.as_str()), Value::from(MASKED)])
218 } else {
219 Value::from(vec![
220 Value::from(name.as_str()),
221 Value::from(value.as_str()),
222 ])
223 }
224 })
225 .collect();
226 (Value::Array(pairs), masked)
227}
228
229pub fn mask_url(url: &str) -> String {
233 let Ok(parsed) = url::Url::parse(url) else {
234 return unparsable_url(url);
235 };
236 let mut base = parsed.clone();
237 base.set_query(None);
238 base.set_fragment(None);
239 let Some(mut text) = with_masked_userinfo(base) else {
240 return unparsable_url(url);
241 };
242 if let Some(query) = parsed.query() {
243 text.push('?');
244 text.push_str(&mask_query(query));
245 }
246 text
247}
248
249fn with_masked_userinfo(mut url: url::Url) -> Option<String> {
255 let has_userinfo = !url.username().is_empty() || url.password().is_some();
256 if !has_userinfo || !url.has_authority() {
257 return Some(url.into());
258 }
259 let at = url.scheme().len().saturating_add("://".len());
260 url.set_username("").ok()?;
261 url.set_password(None).ok()?;
262 let whole = url.as_str();
263 let head = whole.get(..at)?;
264 let tail = whole.get(at..)?;
265 let authority = tail.split('/').next().unwrap_or_default();
267 if authority.contains('@') {
268 return None;
269 }
270 Some(format!("{head}{MASKED}@{tail}"))
271}
272
273fn mask_query(query: &str) -> String {
276 query
277 .split_inclusive(['&', ';'])
278 .map(|segment| {
279 let (pair, separator) = match segment.char_indices().last() {
280 Some((at, '&' | ';')) => segment.split_at(at),
281 _ => (segment, ""),
282 };
283 let name = pair.split_once('=').map_or(pair, |(name, _)| name);
284 if is_sensitive_param(name) {
285 format!("{name}={MASKED}{separator}")
286 } else {
287 segment.to_owned()
288 }
289 })
290 .collect()
291}
292
293fn is_sensitive_param(raw_name: &str) -> bool {
295 let name = percent_encoding::percent_decode_str(raw_name).decode_utf8_lossy();
296 is_sensitive_header(&name)
297}
298
299const UNPARSABLE_URL: &str = "[unparsable url]";
300
301fn unparsable_url(url: &str) -> String {
304 let scheme = url
305 .split_once("://")
306 .map(|(scheme, _)| scheme)
307 .filter(|scheme| {
308 let mut chars = scheme.chars();
309 chars
310 .next()
311 .is_some_and(|first| first.is_ascii_alphabetic())
312 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
313 });
314 match scheme {
315 Some(scheme) => format!("{scheme}://{UNPARSABLE_URL}"),
316 None => UNPARSABLE_URL.to_owned(),
317 }
318}
319
320const MAX_MASKED_HEADERS: usize = 64;
322
323pub(crate) const DIGEST_HEX_BYTES: u64 = 64;
325
326pub(crate) const DIGEST_ONLY_COST: u64 = DIGEST_HEX_BYTES + 2 * "null".len() as u64;
329
330pub(crate) fn capture(
334 payload: &Payload<'_>,
335 max_meta: usize,
336 room: u64,
337 max_body: usize,
338) -> PayloadRecord {
339 let meta_text = serde_json::to_vec(&payload.meta).unwrap_or_default();
340 let body = payload.body.as_deref();
341 let digest = digest_of(&meta_text, body);
342 let (meta, mut truncated) = super::decision::cap_context(&payload.meta, max_meta);
343 if payload.masked_headers.len() > MAX_MASKED_HEADERS {
344 truncated = true;
345 }
346 let masked_headers: Vec<String> = payload
347 .masked_headers
348 .iter()
349 .take(MAX_MASKED_HEADERS)
350 .map(|name| super::decision::cap_text(name, max_meta, &mut truncated))
351 .collect();
352 let full = payload
353 .bytes
354 .unwrap_or_else(|| body.map_or(meta_text.len(), <[u8]>::len) as u64);
355 let body = body.and_then(|body| {
356 let overhead = record_overhead(&meta, &masked_headers);
358 let max_body = body_allowance(body, overhead, room, max_body);
359 if max_body == 0 && !body.is_empty() {
360 truncated = true;
362 return None;
363 }
364 let kept = body.get(..max_body.min(body.len())).unwrap_or_default();
365 let cut = kept.len() < body.len();
366 truncated |= cut;
367 Some(copy_body(kept, cut))
368 });
369 PayloadRecord {
370 meta,
371 body,
372 digest,
373 bytes: full,
374 truncated,
375 masked_headers,
376 }
377}
378
379fn digest_of(meta_text: &[u8], body: Option<&[u8]>) -> String {
383 let mut hasher = Sha256::new();
384 hasher.update((meta_text.len() as u64).to_le_bytes());
385 hasher.update(meta_text);
386 if let Some(body) = body {
387 hasher.update(body);
388 }
389 format!("{:x}", hasher.finalize())
390}
391
392impl Payload<'_> {
393 pub fn digest(&self) -> String {
397 let meta_text = serde_json::to_vec(&self.meta).unwrap_or_default();
398 digest_of(&meta_text, self.body.as_deref())
399 }
400}
401
402enum BodyForm {
404 Text {
406 valid: usize,
407 },
408 Base64,
409}
410
411fn body_form(bytes: &[u8], cut: bool) -> BodyForm {
416 match std::str::from_utf8(bytes) {
417 Ok(_) => BodyForm::Text { valid: bytes.len() },
418 Err(error) if cut && error.error_len().is_none() && error.valid_up_to() > 0 => {
419 BodyForm::Text {
420 valid: error.valid_up_to(),
421 }
422 }
423 Err(_) => BodyForm::Base64,
424 }
425}
426
427fn copy_body(bytes: &[u8], cut: bool) -> BodyCopy {
428 match body_form(bytes, cut) {
429 BodyForm::Text { valid } => {
430 let text = bytes.get(..valid).unwrap_or_default();
431 BodyCopy::Text(String::from_utf8_lossy(text).into_owned())
432 }
433 BodyForm::Base64 => {
434 BodyCopy::Base64(base64::engine::general_purpose::STANDARD.encode(bytes))
435 }
436 }
437}
438
439fn record_overhead(meta: &Value, masked: &[String]) -> u64 {
443 let meta = serde_json::to_string(meta).map_or(0, |text| text.len()) as u64;
444 let masked: u64 = masked
445 .iter()
446 .map(|name| name.len().saturating_add(std::mem::size_of::<String>()) as u64)
447 .fold(0, u64::saturating_add);
448 meta.saturating_mul(2).saturating_add(masked)
449}
450
451pub(crate) fn payload_cost(record: &PayloadRecord) -> u64 {
453 let body = match &record.body {
454 Some(BodyCopy::Text(text) | BodyCopy::Base64(text)) => text.len() as u64,
455 None => 0,
456 };
457 record_overhead(&record.meta, &record.masked_headers)
458 .saturating_add(body)
459 .saturating_add(record.digest.len() as u64)
460}
461
462fn body_allowance(body: &[u8], overhead: u64, room: u64, max_body: usize) -> usize {
468 let available = room
469 .saturating_sub(overhead)
470 .saturating_sub(DIGEST_HEX_BYTES);
471 let available = usize::try_from(available).unwrap_or(usize::MAX);
472 let as_text = available.min(max_body).min(body.len());
473 let probe = body.get(..as_text).unwrap_or_default();
474 if matches!(
475 body_form(probe, as_text < body.len()),
476 BodyForm::Text { .. }
477 ) {
478 return as_text;
479 }
480 (available / 4).saturating_mul(3).min(max_body)
482}
483
484pub(crate) fn record_payload<'a>(
487 store: &impl wasmtime::AsContext<Data = StoreData>,
488 ticket: Option<CallTicket>,
489 side: Side,
490 payload: impl FnOnce() -> Payload<'a>,
491) {
492 let Some(ticket) = ticket else {
493 return;
494 };
495 if let Some(recorder) = store.as_context().data().security_check.recorder()
496 && recorder.wants_payload(ticket.call_index)
497 {
498 recorder.call_payload(ticket.call_index, side, payload());
499 }
500}
501
502pub(crate) fn record_usage(
504 store: &impl wasmtime::AsContext<Data = StoreData>,
505 ticket: Option<CallTicket>,
506 usage: ModelUsage,
507) {
508 let Some(ticket) = ticket else {
509 return;
510 };
511 if let Some(recorder) = store.as_context().data().security_check.recorder() {
512 recorder.call_usage(ticket.call_index, usage);
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 const REQUEST_AND_RESPONSE: &[&str] = &[
528 "http.get",
529 "http.post",
530 "http.put",
531 "http.patch",
532 "http.delete",
533 "http.head",
534 "http.options",
535 "http.download",
536 "llm.call",
537 "embedding.embed",
538 "mcp.<server>",
539 "agent.run",
540 "skill.load",
541 ];
542 const RESPONSE_ONLY: &[&str] = &["fs.read", "session.read", "session.list"];
545 const TIMING_ONLY: &[&str] = &[
550 "fs.stat",
551 "fs.write",
552 "fs.list",
553 "fs.mkdir",
554 "fs.remove",
555 "fs.move",
556 "fs.copy",
557 "git.init",
558 "git.clone",
559 "git.fetch",
560 "git.commit",
561 "secrets.get",
562 "session.write",
563 "session.remove",
564 ];
565
566 #[test]
567 fn every_capability_has_a_recording_class() {
568 for group in crate::stdlib::capabilities::all_groups() {
569 for capability in group.capabilities {
570 let classes = [REQUEST_AND_RESPONSE, RESPONSE_ONLY, TIMING_ONLY]
571 .iter()
572 .filter(|class| class.contains(&capability.name))
573 .count();
574 assert_eq!(
575 classes, 1,
576 "{} must be in exactly one recording class",
577 capability.name
578 );
579 }
580 }
581 }
582
583 #[test]
584 fn every_gating_module_ends_the_calls_it_begins() {
585 for group in crate::stdlib::capabilities::all_groups() {
586 let module = match group.module {
589 "@mcp" => crate::runtime::mcp::MCP_MODULE_NAME,
590 module => module,
591 };
592 assert!(
593 crate::runtime::host::begins_calls(module),
594 "{module} gates capabilities, so its host calls must end their calls"
595 );
596 }
597 }
598
599 #[test]
602 fn gating_modules_outside_the_catalog_end_their_calls_too() {
603 for module in [
604 crate::stdlib::code::MODULE_NAME,
605 crate::stdlib::security::MODULE_NAME,
606 ] {
607 assert!(
608 crate::runtime::host::begins_calls(module),
609 "{module} gates, so its host calls must end their calls"
610 );
611 }
612 assert!(!crate::runtime::host::begins_calls("submilli:test"));
613 }
614
615 const CALLING_FILES: &[(&str, Option<&str>)] = &[
619 (
620 "src/stdlib/agents/mod.rs",
621 Some(crate::stdlib::agents::MODULE_NAME),
622 ),
623 (
624 "src/stdlib/skills/mod.rs",
625 Some(crate::stdlib::skills::MODULE_NAME),
626 ),
627 (
628 "src/stdlib/code/mod.rs",
629 Some(crate::stdlib::code::MODULE_NAME),
630 ),
631 (
632 "src/stdlib/embedding/mod.rs",
633 Some(crate::stdlib::embedding::MODULE_NAME),
634 ),
635 ("src/stdlib/fs/mod.rs", Some(crate::stdlib::fs::MODULE_NAME)),
636 (
637 "src/stdlib/git/mod.rs",
638 Some(crate::stdlib::git::MODULE_NAME),
639 ),
640 (
641 "src/stdlib/http/mod.rs",
642 Some(crate::stdlib::http::MODULE_NAME),
643 ),
644 (
645 "src/stdlib/http/redirect_guard.rs",
646 Some(crate::stdlib::http::MODULE_NAME),
647 ),
648 (
649 "src/stdlib/llm/mod.rs",
650 Some(crate::stdlib::llm::MODULE_NAME),
651 ),
652 (
653 "src/stdlib/secrets.rs",
654 Some(crate::stdlib::secrets::MODULE_NAME),
655 ),
656 (
657 "src/stdlib/security.rs",
658 Some(crate::stdlib::security::MODULE_NAME),
659 ),
660 (
661 "src/stdlib/session/mod.rs",
662 Some(crate::stdlib::session::MODULE_NAME),
663 ),
664 (
665 "src/runtime/mcp.rs",
666 Some(crate::runtime::mcp::MCP_MODULE_NAME),
667 ),
668 ("src/stdlib/shared.rs", None),
669 ];
670
671 fn rust_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
672 let Ok(entries) = std::fs::read_dir(dir) else {
673 return;
674 };
675 for entry in entries.flatten() {
676 let path = entry.path();
677 if path.is_dir() {
678 rust_files(&path, files);
679 } else if path.extension().is_some_and(|ext| ext == "rs") {
680 files.push(path);
681 }
682 }
683 }
684
685 #[test]
689 fn every_file_that_begins_calls_belongs_to_a_call_module() {
690 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
691 let mut files = Vec::new();
692 rust_files(&root.join("src/stdlib"), &mut files);
693 files.push(root.join("src/runtime/mcp.rs"));
694 let needles = [
695 "check_security_call(",
696 "check_security(",
697 "begin_call(",
698 "begin_recorded_call(",
699 "audit_entry_denial(",
700 "audit_denial_in(",
701 "authorize_capability(",
702 "check_and_audit(",
703 ];
704 let mut found = Vec::new();
705 for path in files {
706 let text = std::fs::read_to_string(&path).unwrap_or_default();
707 if needles.iter().any(|needle| text.contains(needle)) {
708 let relative = path
709 .strip_prefix(root)
710 .unwrap_or(&path)
711 .to_string_lossy()
712 .replace('\\', "/");
713 found.push(relative);
714 }
715 }
716 found.sort();
717 let mut table: Vec<&str> = CALLING_FILES.iter().map(|(file, _)| *file).collect();
718 table.sort_unstable();
719 assert_eq!(
720 found, table,
721 "a file that gates or begins calls must be listed in CALLING_FILES with its module"
722 );
723 for (file, module) in CALLING_FILES {
724 if let Some(module) = module {
725 assert!(
726 crate::runtime::host::begins_calls(module),
727 "{file} gates through {module}, which is missing from CALL_MODULES"
728 );
729 }
730 }
731 }
732
733 #[test]
734 fn url_credentials_are_masked_before_recording() {
735 let masked =
736 mask_url("https://user:pw@api.test:8443/v1/x?q=1&api_key=abc&Token=t&flag#frag");
737 assert_eq!(
738 masked,
739 "https://[masked]@api.test:8443/v1/x?q=1&api_key=[masked]&Token=[masked]&flag"
740 );
741 let plain = mask_url("https://api.test/a?page=2");
742 assert_eq!(plain, "https://api.test/a?page=2");
743 let encoded = mask_url("https://api.test/?access%5Ftoken=abc&x=1");
744 assert!(!encoded.contains("abc"), "{encoded}");
745 let signed =
746 mask_url("https://s3.test/o?X-Amz-Signature=sig&X-Amz-Credential=id&password=pw");
747 assert_eq!(
748 signed,
749 "https://s3.test/o?X-Amz-Signature=[masked]&X-Amz-Credential=[masked]&password=[masked]"
750 );
751 let only_user = mask_url("https://tok@api.test/");
752 assert_eq!(only_user, "https://[masked]@api.test/");
753 }
754
755 #[test]
756 fn an_unparsable_url_keeps_only_its_scheme() {
757 assert_eq!(
758 mask_url("http://u:p@exa mple.test/p?token=abc"),
759 "http://[unparsable url]"
760 );
761 assert_eq!(
763 mask_url("https://user:abc/def@host/"),
764 "https://[unparsable url]"
765 );
766 assert_eq!(mask_url("notaurl?api_key=SECRET://x"), "[unparsable url]");
767 assert_eq!(mask_url("not a url token=abc"), "[unparsable url]");
768 assert_eq!(mask_url(""), "[unparsable url]");
769 }
770
771 #[test]
772 fn query_parameters_separate_at_semicolons_too() {
773 assert_eq!(
774 mask_url("https://h.test/?q=1;token=SECRET&x=2;api_key=K"),
775 "https://h.test/?q=1;token=[masked]&x=2;api_key=[masked]"
776 );
777 }
778
779 #[test]
780 fn masked_header_names_are_capped_in_number_and_length() {
781 let names: Vec<String> = (0..200).map(|index| format!("x-token-{index}")).collect();
782 let long = "k".repeat(10_000);
783 let payload = Payload::meta(Value::Null)
784 .with_masked(names.iter().cloned().chain([long.clone()]).collect());
785 let record = capture(&payload, 32, u64::MAX, 16);
786 assert_eq!(record.masked_headers.len(), MAX_MASKED_HEADERS);
787 assert!(record.truncated);
788 let long_record = capture(
789 &Payload::meta(Value::Null).with_masked(vec![long]),
790 32,
791 u64::MAX,
792 16,
793 );
794 assert!(long_record.masked_headers[0].len() < 200);
795 assert!(
796 payload_cost(&record)
797 >= record.masked_headers.iter().map(String::len).sum::<usize>() as u64,
798 "the names are charged"
799 );
800 }
801
802 #[test]
803 fn a_body_that_ends_mid_character_is_binary_unless_it_was_cut() {
804 let payload = Payload::meta(Value::Null).with_body(b"a\xC3");
805 let whole = capture(&payload, 16, u64::MAX, 16);
806 assert!(matches!(whole.body, Some(BodyCopy::Base64(_))));
807 assert!(!whole.truncated);
808 let longer = Payload::meta(Value::Null).with_body(b"a\xC3b");
809 let cut = capture(&longer, 16, u64::MAX, 2);
810 assert!(cut.truncated);
811 assert_eq!(cut.body, Some(BodyCopy::Text("a".into())));
812 }
813
814 #[test]
815 fn credential_headers_are_masked_by_name() {
816 let headers = [
817 ("Authorization", "Bearer t"),
818 ("x-api-key", "k"),
819 ("X-OAuth-Scopes", "repo"),
820 ("cookie", "c"),
821 ("Accept", "application/json"),
822 ]
823 .map(|(name, value)| (name.to_owned(), value.to_owned()));
824 let (value, masked) = mask_headers(&headers);
825 assert_eq!(
826 masked,
827 ["Authorization", "x-api-key", "X-OAuth-Scopes", "cookie"]
828 );
829 let text = value.to_string();
830 assert!(!text.contains("Bearer t") && !text.contains("\"k\"") && !text.contains("repo"));
831 assert!(text.contains("application/json"));
832 }
833
834 #[test]
835 fn a_capped_body_keeps_the_full_size_and_digest() {
836 let body = vec![b'a'; 100];
837 let payload =
838 Payload::meta(serde_json::json!({ "url": "https://x.test" })).with_body(&body);
839 let record = capture(&payload, 1024, u64::MAX, 10);
840 assert!(record.truncated);
841 assert_eq!(record.body, Some(BodyCopy::Text("a".repeat(10))));
842 let whole = capture(&payload, 1024, u64::MAX, 1000);
843 assert_eq!(
844 record.digest, whole.digest,
845 "the digest covers the full body"
846 );
847 assert_eq!(record.bytes, 100);
848 assert_eq!(whole.bytes, 100);
849 }
850
851 #[test]
852 fn binary_bodies_are_kept_as_base64() {
853 let payload = Payload::meta(Value::Null).with_body(&[0xff, 0x00, 0xfe]);
854 assert!(matches!(
855 capture(&payload, 16, u64::MAX, 16).body,
856 Some(BodyCopy::Base64(_))
857 ));
858 }
859
860 fn assert_a_kept_body_fits_its_room(payload: &Payload<'_>, max_meta: usize, rooms: u64) {
863 let mut kept_somewhere = false;
864 for room in 0..rooms {
865 let record = capture(payload, max_meta, room, usize::MAX);
866 if record.body.is_some() {
867 kept_somewhere = true;
868 assert!(
869 payload_cost(&record) <= room,
870 "room {room}: the record costs {}",
871 payload_cost(&record)
872 );
873 }
874 }
875 assert!(
876 kept_somewhere,
877 "the sweep must reach rooms that keep a body"
878 );
879 }
880
881 #[test]
882 fn a_whole_body_ending_mid_character_is_sized_as_base64() {
883 let payload = Payload::meta(Value::Null).with_body(b"abcdefgh\xC3");
884 assert_a_kept_body_fits_its_room(&payload, 16, 200);
885 }
886
887 #[test]
888 fn a_cut_inside_the_first_character_is_sized_as_base64() {
889 let payload = Payload::meta(Value::Null).with_body("éééééééé".as_bytes());
890 assert_a_kept_body_fits_its_room(&payload, 16, 200);
891 }
892
893 #[test]
894 fn masked_names_are_sized_as_capped_not_as_given() {
895 let payload = Payload::meta(Value::Null)
896 .with_body(&[b'x'; 200])
897 .with_masked(vec!["k".repeat(10_000), "x-token".into()]);
898 assert_a_kept_body_fits_its_room(&payload, 32, 500);
899 }
900
901 #[test]
902 fn a_meta_string_just_over_its_cap_is_sized_as_capped() {
903 let payload =
904 Payload::meta(serde_json::json!({ "u": "a".repeat(33) })).with_body(&[b'x'; 200]);
905 assert_a_kept_body_fits_its_room(&payload, 32, 500);
906 }
907
908 #[test]
909 fn the_raw_body_cap_is_not_shrunk_by_the_base64_expansion() {
910 let body = vec![0xffu8; 900 * 1024];
911 let payload = Payload::meta(Value::Null).with_body(&body);
912 let record = capture(&payload, 16, 8 * 1024 * 1024, 1024 * 1024);
913 assert!(!record.truncated);
914 let Some(BodyCopy::Base64(text)) = &record.body else {
915 panic!("a binary body is kept as base64: {:?}", record.body);
916 };
917 let kept = base64::engine::general_purpose::STANDARD.decode(text);
918 assert_eq!(kept.map(|bytes| bytes.len()).ok(), Some(body.len()));
919 let cut = capture(&payload, 16, 8 * 1024 * 1024, 1024);
920 assert!(cut.truncated, "the cap still bounds the raw copy");
921 }
922
923 #[test]
924 fn a_digest_only_record_costs_what_the_constant_says() {
925 let record = capture(
926 &Payload::meta(serde_json::json!({ "a": 1 })).with_body(b"body"),
927 16,
928 u64::MAX,
929 16,
930 );
931 let bare = record.digest_only();
932 assert!(bare.body.is_none() && bare.masked_headers.is_empty());
933 assert_eq!(payload_cost(&bare), DIGEST_ONLY_COST);
934 }
935
936 #[test]
937 fn a_cut_inside_a_character_keeps_the_text_before_it() {
938 let text = "aé".as_bytes();
939 let payload = Payload::meta(Value::Null).with_body(text);
940 assert_eq!(
941 capture(&payload, 16, u64::MAX, 2).body,
942 Some(BodyCopy::Text("a".into()))
943 );
944 }
945}