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 "variables.get",
568 "session.write",
569 "session.remove",
570 "session.finalize",
571 ];
572
573 #[test]
574 fn every_capability_has_a_recording_class() {
575 for group in crate::stdlib::capabilities::all_groups() {
576 for capability in group.capabilities {
577 let classes = [REQUEST_AND_RESPONSE, RESPONSE_ONLY, TIMING_ONLY]
578 .iter()
579 .filter(|class| class.contains(&capability.name))
580 .count();
581 assert_eq!(
582 classes, 1,
583 "{} must be in exactly one recording class",
584 capability.name
585 );
586 }
587 }
588 }
589
590 #[test]
591 fn every_gating_module_ends_the_calls_it_begins() {
592 for group in crate::stdlib::capabilities::all_groups() {
593 let module = match group.module {
596 "@mcp" => crate::runtime::mcp::MCP_MODULE_NAME,
597 "@tools" => crate::runtime::tools::TOOLS_MODULE_NAME,
598 module => module,
599 };
600 assert!(
601 crate::runtime::host::begins_calls(module),
602 "{module} gates capabilities, so its host calls must end their calls"
603 );
604 }
605 }
606
607 #[test]
610 fn gating_modules_outside_the_catalog_end_their_calls_too() {
611 for module in [
612 crate::stdlib::code::MODULE_NAME,
613 crate::stdlib::security::MODULE_NAME,
614 ] {
615 assert!(
616 crate::runtime::host::begins_calls(module),
617 "{module} gates, so its host calls must end their calls"
618 );
619 }
620 assert!(!crate::runtime::host::begins_calls("submilli:test"));
621 }
622
623 const CALLING_FILES: &[(&str, Option<&str>)] = &[
627 (
628 "src/stdlib/agents/mod.rs",
629 Some(crate::stdlib::agents::MODULE_NAME),
630 ),
631 (
632 "src/stdlib/skills/mod.rs",
633 Some(crate::stdlib::skills::MODULE_NAME),
634 ),
635 (
636 "src/stdlib/code/mod.rs",
637 Some(crate::stdlib::code::MODULE_NAME),
638 ),
639 (
640 "src/stdlib/embedding/mod.rs",
641 Some(crate::stdlib::embedding::MODULE_NAME),
642 ),
643 ("src/stdlib/fs/mod.rs", Some(crate::stdlib::fs::MODULE_NAME)),
644 (
645 "src/stdlib/git/mod.rs",
646 Some(crate::stdlib::git::MODULE_NAME),
647 ),
648 (
649 "src/stdlib/http/mod.rs",
650 Some(crate::stdlib::http::MODULE_NAME),
651 ),
652 (
653 "src/stdlib/http/redirect_guard.rs",
654 Some(crate::stdlib::http::MODULE_NAME),
655 ),
656 (
657 "src/stdlib/llm/mod.rs",
658 Some(crate::stdlib::llm::MODULE_NAME),
659 ),
660 (
661 "src/stdlib/secrets.rs",
662 Some(crate::stdlib::secrets::MODULE_NAME),
663 ),
664 (
665 "src/stdlib/variables.rs",
666 Some(crate::stdlib::variables::MODULE_NAME),
667 ),
668 (
669 "src/stdlib/security.rs",
670 Some(crate::stdlib::security::MODULE_NAME),
671 ),
672 (
673 "src/stdlib/session/mod.rs",
674 Some(crate::stdlib::session::MODULE_NAME),
675 ),
676 (
677 "src/stdlib/session/finalizers.rs",
678 Some(crate::stdlib::session::MODULE_NAME),
679 ),
680 (
681 "src/runtime/mcp.rs",
682 Some(crate::runtime::mcp::MCP_MODULE_NAME),
683 ),
684 (
685 "src/runtime/tools.rs",
686 Some(crate::runtime::tools::TOOLS_MODULE_NAME),
687 ),
688 ("src/stdlib/shared.rs", None),
689 ];
690
691 fn rust_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
692 let Ok(entries) = std::fs::read_dir(dir) else {
693 return;
694 };
695 for entry in entries.flatten() {
696 let path = entry.path();
697 if path.is_dir() {
698 rust_files(&path, files);
699 } else if path.extension().is_some_and(|ext| ext == "rs") {
700 files.push(path);
701 }
702 }
703 }
704
705 #[test]
709 fn every_file_that_begins_calls_belongs_to_a_call_module() {
710 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
711 let mut files = Vec::new();
712 rust_files(&root.join("src/stdlib"), &mut files);
713 files.push(root.join("src/runtime/mcp.rs"));
714 files.push(root.join("src/runtime/tools.rs"));
715 let needles = [
716 "check_security_call(",
717 "check_security(",
718 "begin_call(",
719 "begin_recorded_call(",
720 "audit_entry_denial(",
721 "audit_denial_in(",
722 "authorize_capability(",
723 "check_and_audit(",
724 ];
725 let mut found = Vec::new();
726 for path in files {
727 let text = std::fs::read_to_string(&path).unwrap_or_default();
728 if needles.iter().any(|needle| text.contains(needle)) {
729 let relative = path
730 .strip_prefix(root)
731 .unwrap_or(&path)
732 .to_string_lossy()
733 .replace('\\', "/");
734 found.push(relative);
735 }
736 }
737 found.sort();
738 let mut table: Vec<&str> = CALLING_FILES.iter().map(|(file, _)| *file).collect();
739 table.sort_unstable();
740 assert_eq!(
741 found, table,
742 "a file that gates or begins calls must be listed in CALLING_FILES with its module"
743 );
744 for (file, module) in CALLING_FILES {
745 if let Some(module) = module {
746 assert!(
747 crate::runtime::host::begins_calls(module),
748 "{file} gates through {module}, which is missing from CALL_MODULES"
749 );
750 }
751 }
752 }
753
754 #[test]
755 fn url_credentials_are_masked_before_recording() {
756 let masked =
757 mask_url("https://user:pw@api.test:8443/v1/x?q=1&api_key=abc&Token=t&flag#frag");
758 assert_eq!(
759 masked,
760 "https://[masked]@api.test:8443/v1/x?q=1&api_key=[masked]&Token=[masked]&flag"
761 );
762 let plain = mask_url("https://api.test/a?page=2");
763 assert_eq!(plain, "https://api.test/a?page=2");
764 let encoded = mask_url("https://api.test/?access%5Ftoken=abc&x=1");
765 assert!(!encoded.contains("abc"), "{encoded}");
766 let signed =
767 mask_url("https://s3.test/o?X-Amz-Signature=sig&X-Amz-Credential=id&password=pw");
768 assert_eq!(
769 signed,
770 "https://s3.test/o?X-Amz-Signature=[masked]&X-Amz-Credential=[masked]&password=[masked]"
771 );
772 let only_user = mask_url("https://tok@api.test/");
773 assert_eq!(only_user, "https://[masked]@api.test/");
774 }
775
776 #[test]
777 fn an_unparsable_url_keeps_only_its_scheme() {
778 assert_eq!(
779 mask_url("http://u:p@exa mple.test/p?token=abc"),
780 "http://[unparsable url]"
781 );
782 assert_eq!(
784 mask_url("https://user:abc/def@host/"),
785 "https://[unparsable url]"
786 );
787 assert_eq!(mask_url("notaurl?api_key=SECRET://x"), "[unparsable url]");
788 assert_eq!(mask_url("not a url token=abc"), "[unparsable url]");
789 assert_eq!(mask_url(""), "[unparsable url]");
790 }
791
792 #[test]
793 fn query_parameters_separate_at_semicolons_too() {
794 assert_eq!(
795 mask_url("https://h.test/?q=1;token=SECRET&x=2;api_key=K"),
796 "https://h.test/?q=1;token=[masked]&x=2;api_key=[masked]"
797 );
798 }
799
800 #[test]
801 fn masked_header_names_are_capped_in_number_and_length() {
802 let names: Vec<String> = (0..200).map(|index| format!("x-token-{index}")).collect();
803 let long = "k".repeat(10_000);
804 let payload = Payload::meta(Value::Null)
805 .with_masked(names.iter().cloned().chain([long.clone()]).collect());
806 let record = capture(&payload, 32, u64::MAX, 16);
807 assert_eq!(record.masked_headers.len(), MAX_MASKED_HEADERS);
808 assert!(record.truncated);
809 let long_record = capture(
810 &Payload::meta(Value::Null).with_masked(vec![long]),
811 32,
812 u64::MAX,
813 16,
814 );
815 assert!(long_record.masked_headers[0].len() < 200);
816 assert!(
817 payload_cost(&record)
818 >= record.masked_headers.iter().map(String::len).sum::<usize>() as u64,
819 "the names are charged"
820 );
821 }
822
823 #[test]
824 fn a_body_that_ends_mid_character_is_binary_unless_it_was_cut() {
825 let payload = Payload::meta(Value::Null).with_body(b"a\xC3");
826 let whole = capture(&payload, 16, u64::MAX, 16);
827 assert!(matches!(whole.body, Some(BodyCopy::Base64(_))));
828 assert!(!whole.truncated);
829 let longer = Payload::meta(Value::Null).with_body(b"a\xC3b");
830 let cut = capture(&longer, 16, u64::MAX, 2);
831 assert!(cut.truncated);
832 assert_eq!(cut.body, Some(BodyCopy::Text("a".into())));
833 }
834
835 #[test]
836 fn credential_headers_are_masked_by_name() {
837 let headers = [
838 ("Authorization", "Bearer t"),
839 ("x-api-key", "k"),
840 ("X-OAuth-Scopes", "repo"),
841 ("cookie", "c"),
842 ("Accept", "application/json"),
843 ]
844 .map(|(name, value)| (name.to_owned(), value.to_owned()));
845 let (value, masked) = mask_headers(&headers);
846 assert_eq!(
847 masked,
848 ["Authorization", "x-api-key", "X-OAuth-Scopes", "cookie"]
849 );
850 let text = value.to_string();
851 assert!(!text.contains("Bearer t") && !text.contains("\"k\"") && !text.contains("repo"));
852 assert!(text.contains("application/json"));
853 }
854
855 #[test]
856 fn a_capped_body_keeps_the_full_size_and_digest() {
857 let body = vec![b'a'; 100];
858 let payload =
859 Payload::meta(serde_json::json!({ "url": "https://x.test" })).with_body(&body);
860 let record = capture(&payload, 1024, u64::MAX, 10);
861 assert!(record.truncated);
862 assert_eq!(record.body, Some(BodyCopy::Text("a".repeat(10))));
863 let whole = capture(&payload, 1024, u64::MAX, 1000);
864 assert_eq!(
865 record.digest, whole.digest,
866 "the digest covers the full body"
867 );
868 assert_eq!(record.bytes, 100);
869 assert_eq!(whole.bytes, 100);
870 }
871
872 #[test]
873 fn binary_bodies_are_kept_as_base64() {
874 let payload = Payload::meta(Value::Null).with_body(&[0xff, 0x00, 0xfe]);
875 assert!(matches!(
876 capture(&payload, 16, u64::MAX, 16).body,
877 Some(BodyCopy::Base64(_))
878 ));
879 }
880
881 fn assert_a_kept_body_fits_its_room(payload: &Payload<'_>, max_meta: usize, rooms: u64) {
884 let mut kept_somewhere = false;
885 for room in 0..rooms {
886 let record = capture(payload, max_meta, room, usize::MAX);
887 if record.body.is_some() {
888 kept_somewhere = true;
889 assert!(
890 payload_cost(&record) <= room,
891 "room {room}: the record costs {}",
892 payload_cost(&record)
893 );
894 }
895 }
896 assert!(
897 kept_somewhere,
898 "the sweep must reach rooms that keep a body"
899 );
900 }
901
902 #[test]
903 fn a_whole_body_ending_mid_character_is_sized_as_base64() {
904 let payload = Payload::meta(Value::Null).with_body(b"abcdefgh\xC3");
905 assert_a_kept_body_fits_its_room(&payload, 16, 200);
906 }
907
908 #[test]
909 fn a_cut_inside_the_first_character_is_sized_as_base64() {
910 let payload = Payload::meta(Value::Null).with_body("éééééééé".as_bytes());
911 assert_a_kept_body_fits_its_room(&payload, 16, 200);
912 }
913
914 #[test]
915 fn masked_names_are_sized_as_capped_not_as_given() {
916 let payload = Payload::meta(Value::Null)
917 .with_body(&[b'x'; 200])
918 .with_masked(vec!["k".repeat(10_000), "x-token".into()]);
919 assert_a_kept_body_fits_its_room(&payload, 32, 500);
920 }
921
922 #[test]
923 fn a_meta_string_just_over_its_cap_is_sized_as_capped() {
924 let payload =
925 Payload::meta(serde_json::json!({ "u": "a".repeat(33) })).with_body(&[b'x'; 200]);
926 assert_a_kept_body_fits_its_room(&payload, 32, 500);
927 }
928
929 #[test]
930 fn the_raw_body_cap_is_not_shrunk_by_the_base64_expansion() {
931 let body = vec![0xffu8; 900 * 1024];
932 let payload = Payload::meta(Value::Null).with_body(&body);
933 let record = capture(&payload, 16, 8 * 1024 * 1024, 1024 * 1024);
934 assert!(!record.truncated);
935 let Some(BodyCopy::Base64(text)) = &record.body else {
936 panic!("a binary body is kept as base64: {:?}", record.body);
937 };
938 let kept = base64::engine::general_purpose::STANDARD.decode(text);
939 assert_eq!(kept.map(|bytes| bytes.len()).ok(), Some(body.len()));
940 let cut = capture(&payload, 16, 8 * 1024 * 1024, 1024);
941 assert!(cut.truncated, "the cap still bounds the raw copy");
942 }
943
944 #[test]
945 fn a_digest_only_record_costs_what_the_constant_says() {
946 let record = capture(
947 &Payload::meta(serde_json::json!({ "a": 1 })).with_body(b"body"),
948 16,
949 u64::MAX,
950 16,
951 );
952 let bare = record.digest_only();
953 assert!(bare.body.is_none() && bare.masked_headers.is_empty());
954 assert_eq!(payload_cost(&bare), DIGEST_ONLY_COST);
955 }
956
957 #[test]
958 fn a_cut_inside_a_character_keeps_the_text_before_it() {
959 let text = "aé".as_bytes();
960 let payload = Payload::meta(Value::Null).with_body(text);
961 assert_eq!(
962 capture(&payload, 16, u64::MAX, 2).body,
963 Some(BodyCopy::Text("a".into()))
964 );
965 }
966}