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