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