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