Skip to main content

submilli_engine/runtime/
call_log.rs

1//! Call records: each host call a program made, when it ran, and, for a call that
2//! reaches outside the program, what it sent and what came back.
3//!
4//! The [`DecisionLog`](super::DecisionLog) keeps these beside its decisions. Every gated
5//! host call is a call: it starts at its gate and ends when the host function returns,
6//! so its timing and outcome need nothing from the host function itself. A host function
7//! whose call fetches or sends something also hands the recorder its request and
8//! response ([`record_payload`]): a capped copy, a digest of the full
9//! payload, and its full size.
10//!
11//! What is never kept: a secret's value (a `secrets.get` call keeps its timing only),
12//! and the values of credential-bearing headers, which are masked before the copy and the
13//! digest are taken (see [`mask_headers`]). Matching known secret values inside bodies is
14//! the embedder's job when it stores a record; the runtime does not know them all.
15//!
16//! Hashing a payload walks bytes the program already paid fuel to send or receive, so the
17//! digest adds no unbounded work of its own; like the rest of the recorder it is not
18//! charged to guest fuel (see `DecisionLogConfig::max_line_capture_frames`).
19
20use std::borrow::Cow;
21
22use base64::Engine as _;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use sha2::{Digest, Sha256};
26
27use super::StoreData;
28use super::decision::{CallTicket, SourceLine};
29
30/// How a call ended.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum CallOutcome {
34    /// The host function returned a value.
35    Returned,
36    /// The host function failed: a denial, a thrown error, a trap.
37    Failed,
38    /// The run ended while the call was still in progress (a timeout or a cancellation).
39    Unfinished,
40}
41
42impl PayloadRecord {
43    /// This record without its meta, body, and masked names: the digest, the size, and the
44    /// flags.
45    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/// A copy of a payload body, kept as text when it is UTF-8.
58#[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/// One side of a call: what it sent, or what came back.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PayloadRecord {
68    /// The structured part: a method and URL, a tool, a model, a status. Capped like a
69    /// decision's context, with credential-bearing headers masked.
70    pub meta: Value,
71    /// A copy of the body, capped at `DecisionLogConfig::max_payload_bytes`.
72    pub body: Option<BodyCopy>,
73    /// SHA-256, hex, of the full meta and body (masked headers excluded).
74    pub digest: String,
75    /// The payload's full size, counted before any cap or masking: the body's bytes, or
76    /// the meta's when there is no body.
77    pub bytes: u64,
78    /// The meta or body copy was cut, or dropped for the recorder's byte budget.
79    pub truncated: bool,
80    /// Header names whose values were masked: at most 64, each capped like a meta string.
81    pub masked_headers: Vec<String>,
82}
83
84/// Token counts a model provider reported for one call. Absent when not reported.
85#[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/// One host call. Times are microseconds measured from the recorder's start.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct CallRecord {
96    /// Run-wide, in call order; the same index the call's decisions carry.
97    pub call_index: u64,
98    pub caller: String,
99    pub capability: String,
100    /// When the call began, at its security gate.
101    pub started_micros: u64,
102    /// `None` while the call runs, and for an [`CallOutcome::Unfinished`] call.
103    pub ended_micros: Option<u64>,
104    pub outcome: Option<CallOutcome>,
105    /// The program line that led to the call, when known.
106    pub line: Option<SourceLine>,
107    /// What the call sent, when it reached outside the program.
108    pub request: Option<Box<PayloadRecord>>,
109    /// What came back, or the failure that came instead.
110    pub response: Option<Box<PayloadRecord>>,
111    pub usage: Option<ModelUsage>,
112}
113
114impl CallRecord {
115    /// A copy without the payload bodies and meta, keeping each side's digest, size, and
116    /// flags: what an observer that reports sizes needs, without copying up to
117    /// `max_payload_bytes` per side.
118    pub fn without_bodies(&self) -> Self {
119        let strip = |side: &Option<Box<PayloadRecord>>| {
120            side.as_ref().map(|payload| Box::new(payload.digest_only()))
121        };
122        Self {
123            call_index: self.call_index,
124            caller: self.caller.clone(),
125            capability: self.capability.clone(),
126            started_micros: self.started_micros,
127            ended_micros: self.ended_micros,
128            outcome: self.outcome,
129            line: self.line,
130            request: strip(&self.request),
131            response: strip(&self.response),
132            usage: self.usage,
133        }
134    }
135}
136
137/// What a host function hands the recorder for one side of a call.
138pub struct Payload<'a> {
139    pub meta: Value,
140    pub body: Option<Cow<'a, [u8]>>,
141    /// The full size, when it differs from the body handed over: a download written to
142    /// disk, or a body the host function received in another form.
143    pub bytes: Option<u64>,
144    pub masked_headers: Vec<String>,
145}
146
147impl<'a> Payload<'a> {
148    pub fn meta(meta: Value) -> Self {
149        Self {
150            meta,
151            body: None,
152            bytes: None,
153            masked_headers: Vec::new(),
154        }
155    }
156
157    pub fn with_body(mut self, body: &'a [u8]) -> Self {
158        self.body = Some(Cow::Borrowed(body));
159        self
160    }
161
162    pub fn with_owned_body(mut self, body: Vec<u8>) -> Self {
163        self.body = Some(Cow::Owned(body));
164        self
165    }
166
167    pub fn with_size(mut self, bytes: u64) -> Self {
168        self.bytes = Some(bytes);
169        self
170    }
171
172    pub fn with_masked(mut self, masked: Vec<String>) -> Self {
173        self.masked_headers = masked;
174        self
175    }
176}
177
178/// Which side of a call a payload belongs to.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum Side {
181    Request,
182    Response,
183}
184
185/// The value a masked header carries in a record.
186pub const MASKED: &str = "[masked]";
187
188/// Whether a header's value is a credential: `Authorization`, `Proxy-Authorization`,
189/// `Cookie`, `Set-Cookie`, and any name containing `key`, `token`, `secret`, `auth`,
190/// `password`, `passwd`, `signature`, or `credential`.
191pub fn is_sensitive_header(name: &str) -> bool {
192    let name = name.to_ascii_lowercase();
193    matches!(name.as_str(), "cookie" | "set-cookie")
194        || [
195            "key",
196            "token",
197            "secret",
198            "auth",
199            "password",
200            "passwd",
201            "signature",
202            "credential",
203        ]
204        .iter()
205        .any(|word| name.contains(word))
206}
207
208/// Headers as a recordable JSON array of `[name, value]` pairs with credential values
209/// masked, and the names that were masked.
210pub fn mask_headers(headers: &[(String, String)]) -> (Value, Vec<String>) {
211    let mut masked = Vec::new();
212    let pairs = headers
213        .iter()
214        .map(|(name, value)| {
215            if is_sensitive_header(name) {
216                masked.push(name.clone());
217                Value::from(vec![Value::from(name.as_str()), Value::from(MASKED)])
218            } else {
219                Value::from(vec![
220                    Value::from(name.as_str()),
221                    Value::from(value.as_str()),
222                ])
223            }
224        })
225        .collect();
226    (Value::Array(pairs), masked)
227}
228
229/// A URL as a record keeps it: the userinfo and the values of credential-named query
230/// parameters masked, and the fragment dropped. A URL that does not parse keeps nothing
231/// of its authority, path, or query, so a credential in it cannot survive.
232pub fn mask_url(url: &str) -> String {
233    let Ok(parsed) = url::Url::parse(url) else {
234        return unparsable_url(url);
235    };
236    let mut base = parsed.clone();
237    base.set_query(None);
238    base.set_fragment(None);
239    let Some(mut text) = with_masked_userinfo(base) else {
240        return unparsable_url(url);
241    };
242    if let Some(query) = parsed.query() {
243        text.push('?');
244        text.push_str(&mask_query(query));
245    }
246    text
247}
248
249/// `url` as text with its userinfo, if it has any, replaced by [`MASKED`].
250///
251/// The mask is spliced into the text: the `url` crate percent-encodes the brackets of
252/// [`MASKED`] when it is set as a username. `None` when the text is not shaped as the
253/// splice expects.
254fn with_masked_userinfo(mut url: url::Url) -> Option<String> {
255    let has_userinfo = !url.username().is_empty() || url.password().is_some();
256    if !has_userinfo || !url.has_authority() {
257        return Some(url.into());
258    }
259    let at = url.scheme().len().saturating_add("://".len());
260    url.set_username("").ok()?;
261    url.set_password(None).ok()?;
262    let whole = url.as_str();
263    let head = whole.get(..at)?;
264    let tail = whole.get(at..)?;
265    // Userinfo that survived the clearing would leak through the splice.
266    let authority = tail.split('/').next().unwrap_or_default();
267    if authority.contains('@') {
268        return None;
269    }
270    Some(format!("{head}{MASKED}@{tail}"))
271}
272
273/// A query with the values of credential-named parameters masked. Parameters separate at
274/// `&` and at `;`, and each keeps its own separator.
275fn mask_query(query: &str) -> String {
276    query
277        .split_inclusive(['&', ';'])
278        .map(|segment| {
279            let (pair, separator) = match segment.char_indices().last() {
280                Some((at, '&' | ';')) => segment.split_at(at),
281                _ => (segment, ""),
282            };
283            let name = pair.split_once('=').map_or(pair, |(name, _)| name);
284            if is_sensitive_param(name) {
285                format!("{name}={MASKED}{separator}")
286            } else {
287                segment.to_owned()
288            }
289        })
290        .collect()
291}
292
293/// Whether a query parameter's value is a credential, by the rule for header names.
294fn is_sensitive_param(raw_name: &str) -> bool {
295    let name = percent_encoding::percent_decode_str(raw_name).decode_utf8_lossy();
296    is_sensitive_header(&name)
297}
298
299const UNPARSABLE_URL: &str = "[unparsable url]";
300
301/// What a URL that does not parse is recorded as: its scheme, when it plainly has one,
302/// and a placeholder. Everything after the scheme could hold a credential.
303fn unparsable_url(url: &str) -> String {
304    let scheme = url
305        .split_once("://")
306        .map(|(scheme, _)| scheme)
307        .filter(|scheme| {
308            let mut chars = scheme.chars();
309            chars
310                .next()
311                .is_some_and(|first| first.is_ascii_alphabetic())
312                && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
313        });
314    match scheme {
315        Some(scheme) => format!("{scheme}://{UNPARSABLE_URL}"),
316        None => UNPARSABLE_URL.to_owned(),
317    }
318}
319
320/// Most masked header names a payload record keeps.
321const MAX_MASKED_HEADERS: usize = 64;
322
323/// The length of a payload digest as [`payload_cost`] charges it: SHA-256 as hex.
324pub(crate) const DIGEST_HEX_BYTES: u64 = 64;
325
326/// What [`payload_cost`] charges a [`PayloadRecord::digest_only`] record: its digest and a
327/// `null` meta.
328pub(crate) const DIGEST_ONLY_COST: u64 = DIGEST_HEX_BYTES + 2 * "null".len() as u64;
329
330/// Builds a payload record. `max_meta` caps meta strings. The body copy is sized to the
331/// `room` the recorder has left for the whole record and capped at `max_body` raw bytes;
332/// see [`body_allowance`].
333pub(crate) fn capture(
334    payload: &Payload<'_>,
335    max_meta: usize,
336    room: u64,
337    max_body: usize,
338) -> PayloadRecord {
339    let meta_text = serde_json::to_vec(&payload.meta).unwrap_or_default();
340    let body = payload.body.as_deref();
341    let digest = digest_of(&meta_text, body);
342    let (meta, mut truncated) = super::decision::cap_context(&payload.meta, max_meta);
343    if payload.masked_headers.len() > MAX_MASKED_HEADERS {
344        truncated = true;
345    }
346    let masked_headers: Vec<String> = payload
347        .masked_headers
348        .iter()
349        .take(MAX_MASKED_HEADERS)
350        .map(|name| super::decision::cap_text(name, max_meta, &mut truncated))
351        .collect();
352    let full = payload
353        .bytes
354        .unwrap_or_else(|| body.map_or(meta_text.len(), <[u8]>::len) as u64);
355    let body = body.and_then(|body| {
356        // Sized from the capped forms the record will be charged for.
357        let overhead = record_overhead(&meta, &masked_headers);
358        let max_body = body_allowance(body, overhead, room, max_body);
359        if max_body == 0 && !body.is_empty() {
360            // No room for any of it: no copy is made.
361            truncated = true;
362            return None;
363        }
364        let kept = body.get(..max_body.min(body.len())).unwrap_or_default();
365        let cut = kept.len() < body.len();
366        truncated |= cut;
367        Some(copy_body(kept, cut))
368    });
369    PayloadRecord {
370        meta,
371        body,
372        digest,
373        bytes: full,
374        truncated,
375        masked_headers,
376    }
377}
378
379/// SHA-256, hex, of a payload: the meta's JSON text, then the body. The length prefix
380/// marks where the meta ends and the body begins, so two payloads that split the same
381/// bytes differently do not share a digest.
382fn digest_of(meta_text: &[u8], body: Option<&[u8]>) -> String {
383    let mut hasher = Sha256::new();
384    hasher.update((meta_text.len() as u64).to_le_bytes());
385    hasher.update(meta_text);
386    if let Some(body) = body {
387        hasher.update(body);
388    }
389    format!("{:x}", hasher.finalize())
390}
391
392impl Payload<'_> {
393    /// The digest a record of this payload carries ([`PayloadRecord::digest`]), whatever
394    /// the recorder's caps keep of it. Lets a transport compute what the call log
395    /// recorded for a request.
396    pub fn digest(&self) -> String {
397        let meta_text = serde_json::to_vec(&self.meta).unwrap_or_default();
398        digest_of(&meta_text, self.body.as_deref())
399    }
400}
401
402/// How a body copy is kept.
403enum BodyForm {
404    /// As text: the first `valid` bytes.
405    Text {
406        valid: usize,
407    },
408    Base64,
409}
410
411/// The form a copy of `bytes` takes: text when it is UTF-8, else base64. A copy that was
412/// `cut` may end inside a character; the text before it is kept. A body that was not cut
413/// and is not UTF-8 is binary, however it ends. A cut inside the first character has no
414/// text before it, so it is binary too.
415fn body_form(bytes: &[u8], cut: bool) -> BodyForm {
416    match std::str::from_utf8(bytes) {
417        Ok(_) => BodyForm::Text { valid: bytes.len() },
418        Err(error) if cut && error.error_len().is_none() && error.valid_up_to() > 0 => {
419            BodyForm::Text {
420                valid: error.valid_up_to(),
421            }
422        }
423        Err(_) => BodyForm::Base64,
424    }
425}
426
427fn copy_body(bytes: &[u8], cut: bool) -> BodyCopy {
428    match body_form(bytes, cut) {
429        BodyForm::Text { valid } => {
430            let text = bytes.get(..valid).unwrap_or_default();
431            BodyCopy::Text(String::from_utf8_lossy(text).into_owned())
432        }
433        BodyForm::Base64 => {
434            BodyCopy::Base64(base64::engine::general_purpose::STANDARD.encode(bytes))
435        }
436    }
437}
438
439/// Bytes a record holds besides its body and digest, for the recorder's budget: the meta
440/// (parsed JSON costs several times its text, charged generously as decision payloads
441/// are) and the masked names.
442fn record_overhead(meta: &Value, masked: &[String]) -> u64 {
443    let meta = serde_json::to_string(meta).map_or(0, |text| text.len()) as u64;
444    let masked: u64 = masked
445        .iter()
446        .map(|name| name.len().saturating_add(std::mem::size_of::<String>()) as u64)
447        .fold(0, u64::saturating_add);
448    meta.saturating_mul(2).saturating_add(masked)
449}
450
451/// Bytes a payload record holds, for the recorder's budget.
452pub(crate) fn payload_cost(record: &PayloadRecord) -> u64 {
453    let body = match &record.body {
454        Some(BodyCopy::Text(text) | BodyCopy::Base64(text)) => text.len() as u64,
455        None => 0,
456    };
457    record_overhead(&record.meta, &record.masked_headers)
458        .saturating_add(body)
459        .saturating_add(record.digest.len() as u64)
460}
461
462/// How many raw bytes of `body` to copy, given the `room` the recorder has left for the
463/// whole record, the record's `overhead` besides its digest ([`record_overhead`] of the
464/// capped meta and names), and the `max_body` cap on the copy. A cut body costs what it
465/// keeps, so the copy is sized to leave room for the rest of the record and, for a body
466/// kept as base64, its expansion. Zero when nothing fits: the body is not copied at all.
467fn body_allowance(body: &[u8], overhead: u64, room: u64, max_body: usize) -> usize {
468    let available = room
469        .saturating_sub(overhead)
470        .saturating_sub(DIGEST_HEX_BYTES);
471    let available = usize::try_from(available).unwrap_or(usize::MAX);
472    let as_text = available.min(max_body).min(body.len());
473    let probe = body.get(..as_text).unwrap_or_default();
474    if matches!(
475        body_form(probe, as_text < body.len()),
476        BodyForm::Text { .. }
477    ) {
478        return as_text;
479    }
480    // Whole base64 groups: four characters per three bytes, rounded down.
481    (available / 4).saturating_mul(3).min(max_body)
482}
483
484/// Hands one side of the call `ticket` names to the recorder, when one is installed and
485/// would keep it. `payload` runs only then, so building it costs nothing otherwise.
486pub(crate) fn record_payload<'a>(
487    store: &impl wasmtime::AsContext<Data = StoreData>,
488    ticket: Option<CallTicket>,
489    side: Side,
490    payload: impl FnOnce() -> Payload<'a>,
491) {
492    let Some(ticket) = ticket else {
493        return;
494    };
495    if let Some(recorder) = store.as_context().data().security_check.recorder()
496        && recorder.wants_payload(ticket.call_index)
497    {
498        recorder.call_payload(ticket.call_index, side, payload());
499    }
500}
501
502/// Hands the model usage of the call `ticket` names to the recorder.
503pub(crate) fn record_usage(
504    store: &impl wasmtime::AsContext<Data = StoreData>,
505    ticket: Option<CallTicket>,
506    usage: ModelUsage,
507) {
508    let Some(ticket) = ticket else {
509        return;
510    };
511    if let Some(recorder) = store.as_context().data().security_check.recorder() {
512        recorder.call_usage(ticket.call_index, usage);
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    /// What each catalog capability's calls keep beyond their timing and outcome. A
521    /// capability added to the catalog fails [`every_capability_has_a_recording_class`]
522    /// until it is placed here, so a new kind of call cannot skip the call log unnoticed.
523    ///
524    /// A redirect hop (`redirect_guard`) begins its own `http.<verb>` call with no
525    /// payloads: hops are timing-only calls whose decisions carry the hop, so the class
526    /// of `http.*` holds for the request's own call, not for its hops.
527    const REQUEST_AND_RESPONSE: &[&str] = &[
528        "http.get",
529        "http.post",
530        "http.put",
531        "http.patch",
532        "http.delete",
533        "http.head",
534        "http.options",
535        "http.download",
536        "llm.call",
537        "embedding.embed",
538        "mcp.<server>",
539        "agent.run",
540        "skill.load",
541    ];
542    /// The request is the decision's context; the response is kept for whole reads and
543    /// session reads.
544    const RESPONSE_ONLY: &[&str] = &["fs.read", "session.read", "session.list"];
545    /// Writes, git, secrets, and the reads whose results are deferred to playback
546    /// (`stat`, `peek`, `list`, and the streaming reads): timing and outcome only. A
547    /// secret's value is never kept. `fs.exists` and `fs.size` gate as `fs.stat` and do
548    /// keep their answer, which no class requires.
549    const TIMING_ONLY: &[&str] = &[
550        "fs.stat",
551        "fs.write",
552        "fs.list",
553        "fs.mkdir",
554        "fs.remove",
555        "fs.move",
556        "fs.copy",
557        "git.init",
558        "git.clone",
559        "git.fetch",
560        "git.commit",
561        "secrets.get",
562        "session.write",
563        "session.remove",
564    ];
565
566    #[test]
567    fn every_capability_has_a_recording_class() {
568        for group in crate::stdlib::capabilities::all_groups() {
569            for capability in group.capabilities {
570                let classes = [REQUEST_AND_RESPONSE, RESPONSE_ONLY, TIMING_ONLY]
571                    .iter()
572                    .filter(|class| class.contains(&capability.name))
573                    .count();
574                assert_eq!(
575                    classes, 1,
576                    "{} must be in exactly one recording class",
577                    capability.name
578                );
579            }
580        }
581    }
582
583    #[test]
584    fn every_gating_module_ends_the_calls_it_begins() {
585        for group in crate::stdlib::capabilities::all_groups() {
586            // The catalog names `@mcp/<server>` imports; their calls dispatch through the
587            // internal MCP module.
588            let module = match group.module {
589                "@mcp" => crate::runtime::mcp::MCP_MODULE_NAME,
590                module => module,
591            };
592            assert!(
593                crate::runtime::host::begins_calls(module),
594                "{module} gates capabilities, so its host calls must end their calls"
595            );
596        }
597    }
598
599    /// Modules that gate without being a catalog group: `submilli:code` reads gate as
600    /// `fs.read`, and `submilli:security` gates a package's own `security.check`.
601    #[test]
602    fn gating_modules_outside_the_catalog_end_their_calls_too() {
603        for module in [
604            crate::stdlib::code::MODULE_NAME,
605            crate::stdlib::security::MODULE_NAME,
606        ] {
607            assert!(
608                crate::runtime::host::begins_calls(module),
609                "{module} gates, so its host calls must end their calls"
610            );
611        }
612        assert!(!crate::runtime::host::begins_calls("submilli:test"));
613    }
614
615    /// Every source file that gates a capability or begins a call, with the module whose
616    /// host functions run it. `None` marks the shared helpers: they run inside whichever
617    /// module calls them.
618    const CALLING_FILES: &[(&str, Option<&str>)] = &[
619        (
620            "src/stdlib/agents/mod.rs",
621            Some(crate::stdlib::agents::MODULE_NAME),
622        ),
623        (
624            "src/stdlib/skills/mod.rs",
625            Some(crate::stdlib::skills::MODULE_NAME),
626        ),
627        (
628            "src/stdlib/code/mod.rs",
629            Some(crate::stdlib::code::MODULE_NAME),
630        ),
631        (
632            "src/stdlib/embedding/mod.rs",
633            Some(crate::stdlib::embedding::MODULE_NAME),
634        ),
635        ("src/stdlib/fs/mod.rs", Some(crate::stdlib::fs::MODULE_NAME)),
636        (
637            "src/stdlib/git/mod.rs",
638            Some(crate::stdlib::git::MODULE_NAME),
639        ),
640        (
641            "src/stdlib/http/mod.rs",
642            Some(crate::stdlib::http::MODULE_NAME),
643        ),
644        (
645            "src/stdlib/http/redirect_guard.rs",
646            Some(crate::stdlib::http::MODULE_NAME),
647        ),
648        (
649            "src/stdlib/llm/mod.rs",
650            Some(crate::stdlib::llm::MODULE_NAME),
651        ),
652        (
653            "src/stdlib/secrets.rs",
654            Some(crate::stdlib::secrets::MODULE_NAME),
655        ),
656        (
657            "src/stdlib/security.rs",
658            Some(crate::stdlib::security::MODULE_NAME),
659        ),
660        (
661            "src/stdlib/session/mod.rs",
662            Some(crate::stdlib::session::MODULE_NAME),
663        ),
664        (
665            "src/runtime/mcp.rs",
666            Some(crate::runtime::mcp::MCP_MODULE_NAME),
667        ),
668        ("src/stdlib/shared.rs", None),
669    ];
670
671    fn rust_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
672        let Ok(entries) = std::fs::read_dir(dir) else {
673            return;
674        };
675        for entry in entries.flatten() {
676            let path = entry.path();
677            if path.is_dir() {
678                rust_files(&path, files);
679            } else if path.extension().is_some_and(|ext| ext == "rs") {
680                files.push(path);
681            }
682        }
683    }
684
685    /// A file that begins calls but whose module is not in `CALL_MODULES` would leave
686    /// its calls open until the run ends. The scan finds such a file even when it is
687    /// new; the table must then name it and its module.
688    #[test]
689    fn every_file_that_begins_calls_belongs_to_a_call_module() {
690        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
691        let mut files = Vec::new();
692        rust_files(&root.join("src/stdlib"), &mut files);
693        files.push(root.join("src/runtime/mcp.rs"));
694        let needles = [
695            "check_security_call(",
696            "check_security(",
697            "begin_call(",
698            "begin_recorded_call(",
699            "audit_entry_denial(",
700            "audit_denial_in(",
701            "authorize_capability(",
702            "check_and_audit(",
703        ];
704        let mut found = Vec::new();
705        for path in files {
706            let text = std::fs::read_to_string(&path).unwrap_or_default();
707            if needles.iter().any(|needle| text.contains(needle)) {
708                let relative = path
709                    .strip_prefix(root)
710                    .unwrap_or(&path)
711                    .to_string_lossy()
712                    .replace('\\', "/");
713                found.push(relative);
714            }
715        }
716        found.sort();
717        let mut table: Vec<&str> = CALLING_FILES.iter().map(|(file, _)| *file).collect();
718        table.sort_unstable();
719        assert_eq!(
720            found, table,
721            "a file that gates or begins calls must be listed in CALLING_FILES with its module"
722        );
723        for (file, module) in CALLING_FILES {
724            if let Some(module) = module {
725                assert!(
726                    crate::runtime::host::begins_calls(module),
727                    "{file} gates through {module}, which is missing from CALL_MODULES"
728                );
729            }
730        }
731    }
732
733    #[test]
734    fn url_credentials_are_masked_before_recording() {
735        let masked =
736            mask_url("https://user:pw@api.test:8443/v1/x?q=1&api_key=abc&Token=t&flag#frag");
737        assert_eq!(
738            masked,
739            "https://[masked]@api.test:8443/v1/x?q=1&api_key=[masked]&Token=[masked]&flag"
740        );
741        let plain = mask_url("https://api.test/a?page=2");
742        assert_eq!(plain, "https://api.test/a?page=2");
743        let encoded = mask_url("https://api.test/?access%5Ftoken=abc&x=1");
744        assert!(!encoded.contains("abc"), "{encoded}");
745        let signed =
746            mask_url("https://s3.test/o?X-Amz-Signature=sig&X-Amz-Credential=id&password=pw");
747        assert_eq!(
748            signed,
749            "https://s3.test/o?X-Amz-Signature=[masked]&X-Amz-Credential=[masked]&password=[masked]"
750        );
751        let only_user = mask_url("https://tok@api.test/");
752        assert_eq!(only_user, "https://[masked]@api.test/");
753    }
754
755    #[test]
756    fn an_unparsable_url_keeps_only_its_scheme() {
757        assert_eq!(
758            mask_url("http://u:p@exa mple.test/p?token=abc"),
759            "http://[unparsable url]"
760        );
761        // An invalid port: a fallback that kept the text before the last `@` would leak.
762        assert_eq!(
763            mask_url("https://user:abc/def@host/"),
764            "https://[unparsable url]"
765        );
766        assert_eq!(mask_url("notaurl?api_key=SECRET://x"), "[unparsable url]");
767        assert_eq!(mask_url("not a url token=abc"), "[unparsable url]");
768        assert_eq!(mask_url(""), "[unparsable url]");
769    }
770
771    #[test]
772    fn query_parameters_separate_at_semicolons_too() {
773        assert_eq!(
774            mask_url("https://h.test/?q=1;token=SECRET&x=2;api_key=K"),
775            "https://h.test/?q=1;token=[masked]&x=2;api_key=[masked]"
776        );
777    }
778
779    #[test]
780    fn masked_header_names_are_capped_in_number_and_length() {
781        let names: Vec<String> = (0..200).map(|index| format!("x-token-{index}")).collect();
782        let long = "k".repeat(10_000);
783        let payload = Payload::meta(Value::Null)
784            .with_masked(names.iter().cloned().chain([long.clone()]).collect());
785        let record = capture(&payload, 32, u64::MAX, 16);
786        assert_eq!(record.masked_headers.len(), MAX_MASKED_HEADERS);
787        assert!(record.truncated);
788        let long_record = capture(
789            &Payload::meta(Value::Null).with_masked(vec![long]),
790            32,
791            u64::MAX,
792            16,
793        );
794        assert!(long_record.masked_headers[0].len() < 200);
795        assert!(
796            payload_cost(&record)
797                >= record.masked_headers.iter().map(String::len).sum::<usize>() as u64,
798            "the names are charged"
799        );
800    }
801
802    #[test]
803    fn a_body_that_ends_mid_character_is_binary_unless_it_was_cut() {
804        let payload = Payload::meta(Value::Null).with_body(b"a\xC3");
805        let whole = capture(&payload, 16, u64::MAX, 16);
806        assert!(matches!(whole.body, Some(BodyCopy::Base64(_))));
807        assert!(!whole.truncated);
808        let longer = Payload::meta(Value::Null).with_body(b"a\xC3b");
809        let cut = capture(&longer, 16, u64::MAX, 2);
810        assert!(cut.truncated);
811        assert_eq!(cut.body, Some(BodyCopy::Text("a".into())));
812    }
813
814    #[test]
815    fn credential_headers_are_masked_by_name() {
816        let headers = [
817            ("Authorization", "Bearer t"),
818            ("x-api-key", "k"),
819            ("X-OAuth-Scopes", "repo"),
820            ("cookie", "c"),
821            ("Accept", "application/json"),
822        ]
823        .map(|(name, value)| (name.to_owned(), value.to_owned()));
824        let (value, masked) = mask_headers(&headers);
825        assert_eq!(
826            masked,
827            ["Authorization", "x-api-key", "X-OAuth-Scopes", "cookie"]
828        );
829        let text = value.to_string();
830        assert!(!text.contains("Bearer t") && !text.contains("\"k\"") && !text.contains("repo"));
831        assert!(text.contains("application/json"));
832    }
833
834    #[test]
835    fn a_capped_body_keeps_the_full_size_and_digest() {
836        let body = vec![b'a'; 100];
837        let payload =
838            Payload::meta(serde_json::json!({ "url": "https://x.test" })).with_body(&body);
839        let record = capture(&payload, 1024, u64::MAX, 10);
840        assert!(record.truncated);
841        assert_eq!(record.body, Some(BodyCopy::Text("a".repeat(10))));
842        let whole = capture(&payload, 1024, u64::MAX, 1000);
843        assert_eq!(
844            record.digest, whole.digest,
845            "the digest covers the full body"
846        );
847        assert_eq!(record.bytes, 100);
848        assert_eq!(whole.bytes, 100);
849    }
850
851    #[test]
852    fn binary_bodies_are_kept_as_base64() {
853        let payload = Payload::meta(Value::Null).with_body(&[0xff, 0x00, 0xfe]);
854        assert!(matches!(
855            capture(&payload, 16, u64::MAX, 16).body,
856            Some(BodyCopy::Base64(_))
857        ));
858    }
859
860    /// Whatever the room, a body the allowance lets through is charged within it: no copy
861    /// is made only to be dropped for the budget.
862    fn assert_a_kept_body_fits_its_room(payload: &Payload<'_>, max_meta: usize, rooms: u64) {
863        let mut kept_somewhere = false;
864        for room in 0..rooms {
865            let record = capture(payload, max_meta, room, usize::MAX);
866            if record.body.is_some() {
867                kept_somewhere = true;
868                assert!(
869                    payload_cost(&record) <= room,
870                    "room {room}: the record costs {}",
871                    payload_cost(&record)
872                );
873            }
874        }
875        assert!(
876            kept_somewhere,
877            "the sweep must reach rooms that keep a body"
878        );
879    }
880
881    #[test]
882    fn a_whole_body_ending_mid_character_is_sized_as_base64() {
883        let payload = Payload::meta(Value::Null).with_body(b"abcdefgh\xC3");
884        assert_a_kept_body_fits_its_room(&payload, 16, 200);
885    }
886
887    #[test]
888    fn a_cut_inside_the_first_character_is_sized_as_base64() {
889        let payload = Payload::meta(Value::Null).with_body("éééééééé".as_bytes());
890        assert_a_kept_body_fits_its_room(&payload, 16, 200);
891    }
892
893    #[test]
894    fn masked_names_are_sized_as_capped_not_as_given() {
895        let payload = Payload::meta(Value::Null)
896            .with_body(&[b'x'; 200])
897            .with_masked(vec!["k".repeat(10_000), "x-token".into()]);
898        assert_a_kept_body_fits_its_room(&payload, 32, 500);
899    }
900
901    #[test]
902    fn a_meta_string_just_over_its_cap_is_sized_as_capped() {
903        let payload =
904            Payload::meta(serde_json::json!({ "u": "a".repeat(33) })).with_body(&[b'x'; 200]);
905        assert_a_kept_body_fits_its_room(&payload, 32, 500);
906    }
907
908    #[test]
909    fn the_raw_body_cap_is_not_shrunk_by_the_base64_expansion() {
910        let body = vec![0xffu8; 900 * 1024];
911        let payload = Payload::meta(Value::Null).with_body(&body);
912        let record = capture(&payload, 16, 8 * 1024 * 1024, 1024 * 1024);
913        assert!(!record.truncated);
914        let Some(BodyCopy::Base64(text)) = &record.body else {
915            panic!("a binary body is kept as base64: {:?}", record.body);
916        };
917        let kept = base64::engine::general_purpose::STANDARD.decode(text);
918        assert_eq!(kept.map(|bytes| bytes.len()).ok(), Some(body.len()));
919        let cut = capture(&payload, 16, 8 * 1024 * 1024, 1024);
920        assert!(cut.truncated, "the cap still bounds the raw copy");
921    }
922
923    #[test]
924    fn a_digest_only_record_costs_what_the_constant_says() {
925        let record = capture(
926            &Payload::meta(serde_json::json!({ "a": 1 })).with_body(b"body"),
927            16,
928            u64::MAX,
929            16,
930        );
931        let bare = record.digest_only();
932        assert!(bare.body.is_none() && bare.masked_headers.is_empty());
933        assert_eq!(payload_cost(&bare), DIGEST_ONLY_COST);
934    }
935
936    #[test]
937    fn a_cut_inside_a_character_keeps_the_text_before_it() {
938        let text = "aé".as_bytes();
939        let payload = Payload::meta(Value::Null).with_body(text);
940        assert_eq!(
941            capture(&payload, 16, u64::MAX, 2).body,
942            Some(BodyCopy::Text("a".into()))
943        );
944    }
945}