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, PackageLine, 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    /// The line inside a package that made the call, when the program reached it through one.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub package_line: Option<PackageLine>,
110    /// What the call sent, when it reached outside the program.
111    pub request: Option<Box<PayloadRecord>>,
112    /// What came back, or the failure that came instead.
113    pub response: Option<Box<PayloadRecord>>,
114    pub usage: Option<ModelUsage>,
115}
116
117impl CallRecord {
118    /// A copy without the payload bodies and meta, keeping each side's digest, size, and
119    /// flags: what an observer that reports sizes needs, without copying up to
120    /// `max_payload_bytes` per side.
121    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
141/// What a host function hands the recorder for one side of a call.
142pub struct Payload<'a> {
143    pub meta: Value,
144    pub body: Option<Cow<'a, [u8]>>,
145    /// The full size, when it differs from the body handed over: a download written to
146    /// disk, or a body the host function received in another form.
147    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/// Which side of a call a payload belongs to.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum Side {
185    Request,
186    Response,
187}
188
189/// The value a masked header carries in a record.
190pub const MASKED: &str = "[masked]";
191
192/// Whether a header's value is a credential: `Authorization`, `Proxy-Authorization`,
193/// `Cookie`, `Set-Cookie`, and any name containing `key`, `token`, `secret`, `auth`,
194/// `password`, `passwd`, `signature`, or `credential`.
195pub 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
212/// Headers as a recordable JSON array of `[name, value]` pairs with credential values
213/// masked, and the names that were masked.
214pub 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
233/// A URL as a record keeps it: the userinfo and the values of credential-named query
234/// parameters masked, and the fragment dropped. A URL that does not parse keeps nothing
235/// of its authority, path, or query, so a credential in it cannot survive.
236pub 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
253/// `url` as text with its userinfo, if it has any, replaced by [`MASKED`].
254///
255/// The mask is spliced into the text: the `url` crate percent-encodes the brackets of
256/// [`MASKED`] when it is set as a username. `None` when the text is not shaped as the
257/// splice expects.
258fn 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    // Userinfo that survived the clearing would leak through the splice.
270    let authority = tail.split('/').next().unwrap_or_default();
271    if authority.contains('@') {
272        return None;
273    }
274    Some(format!("{head}{MASKED}@{tail}"))
275}
276
277/// A query with the values of credential-named parameters masked. Parameters separate at
278/// `&` and at `;`, and each keeps its own separator.
279fn 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
297/// Whether a query parameter's value is a credential, by the rule for header names.
298fn 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
305/// What a URL that does not parse is recorded as: its scheme, when it plainly has one,
306/// and a placeholder. Everything after the scheme could hold a credential.
307fn 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
324/// Most masked header names a payload record keeps.
325const MAX_MASKED_HEADERS: usize = 64;
326
327/// The length of a payload digest as [`payload_cost`] charges it: SHA-256 as hex.
328pub(crate) const DIGEST_HEX_BYTES: u64 = 64;
329
330/// What [`payload_cost`] charges a [`PayloadRecord::digest_only`] record: its digest and a
331/// `null` meta.
332pub(crate) const DIGEST_ONLY_COST: u64 = DIGEST_HEX_BYTES + 2 * "null".len() as u64;
333
334/// Builds a payload record. `max_meta` caps meta strings. The body copy is sized to the
335/// `room` the recorder has left for the whole record and capped at `max_body` raw bytes;
336/// see [`body_allowance`].
337pub(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        // Sized from the capped forms the record will be charged for.
361        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            // No room for any of it: no copy is made.
365            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
383/// SHA-256, hex, of a payload: the meta's JSON text, then the body. The length prefix
384/// marks where the meta ends and the body begins, so two payloads that split the same
385/// bytes differently do not share a digest.
386fn 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    /// The digest a record of this payload carries ([`PayloadRecord::digest`]), whatever
398    /// the recorder's caps keep of it. Lets a transport compute what the call log
399    /// recorded for a request.
400    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
406/// How a body copy is kept.
407enum BodyForm {
408    /// As text: the first `valid` bytes.
409    Text {
410        valid: usize,
411    },
412    Base64,
413}
414
415/// The form a copy of `bytes` takes: text when it is UTF-8, else base64. A copy that was
416/// `cut` may end inside a character; the text before it is kept. A body that was not cut
417/// and is not UTF-8 is binary, however it ends. A cut inside the first character has no
418/// text before it, so it is binary too.
419fn 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
443/// Bytes a record holds besides its body and digest, for the recorder's budget: the meta
444/// (parsed JSON costs several times its text, charged generously as decision payloads
445/// are) and the masked names.
446fn 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
455/// Bytes a payload record holds, for the recorder's budget.
456pub(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
466/// How many raw bytes of `body` to copy, given the `room` the recorder has left for the
467/// whole record, the record's `overhead` besides its digest ([`record_overhead`] of the
468/// capped meta and names), and the `max_body` cap on the copy. A cut body costs what it
469/// keeps, so the copy is sized to leave room for the rest of the record and, for a body
470/// kept as base64, its expansion. Zero when nothing fits: the body is not copied at all.
471fn 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    // Whole base64 groups: four characters per three bytes, rounded down.
485    (available / 4).saturating_mul(3).min(max_body)
486}
487
488/// Hands one side of the call `ticket` names to the recorder, when one is installed and
489/// would keep it. `payload` runs only then, so building it costs nothing otherwise.
490pub(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
506/// Hands the model usage of the call `ticket` names to the recorder.
507pub(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    /// What each catalog capability's calls keep beyond their timing and outcome. A
525    /// capability added to the catalog fails [`every_capability_has_a_recording_class`]
526    /// until it is placed here, so a new kind of call cannot skip the call log unnoticed.
527    ///
528    /// A redirect hop (`redirect_guard`) begins its own `http.<verb>` call with no
529    /// payloads: hops are timing-only calls whose decisions carry the hop, so the class
530    /// of `http.*` holds for the request's own call, not for its hops.
531    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    /// The request is the decision's context; the response is kept for whole reads and
548    /// session reads.
549    const RESPONSE_ONLY: &[&str] = &["fs.read", "session.read", "session.list"];
550    /// Writes, git, secrets, and the reads whose results are deferred to playback
551    /// (`stat`, `peek`, `list`, and the streaming reads): timing and outcome only. A
552    /// secret's value is never kept. `fs.exists` and `fs.size` gate as `fs.stat` and do
553    /// keep their answer, which no class requires.
554    const TIMING_ONLY: &[&str] = &[
555        "fs.stat",
556        "fs.write",
557        "fs.list",
558        "fs.mkdir",
559        "fs.remove",
560        "fs.move",
561        "fs.copy",
562        "git.init",
563        "git.clone",
564        "git.fetch",
565        "git.commit",
566        "secrets.get",
567        "session.write",
568        "session.remove",
569    ];
570
571    #[test]
572    fn every_capability_has_a_recording_class() {
573        for group in crate::stdlib::capabilities::all_groups() {
574            for capability in group.capabilities {
575                let classes = [REQUEST_AND_RESPONSE, RESPONSE_ONLY, TIMING_ONLY]
576                    .iter()
577                    .filter(|class| class.contains(&capability.name))
578                    .count();
579                assert_eq!(
580                    classes, 1,
581                    "{} must be in exactly one recording class",
582                    capability.name
583                );
584            }
585        }
586    }
587
588    #[test]
589    fn every_gating_module_ends_the_calls_it_begins() {
590        for group in crate::stdlib::capabilities::all_groups() {
591            // The catalog names `@mcp/<server>` and `@tools` imports; their calls
592            // dispatch through the internal virtual-package modules.
593            let module = match group.module {
594                "@mcp" => crate::runtime::mcp::MCP_MODULE_NAME,
595                "@tools" => crate::runtime::tools::TOOLS_MODULE_NAME,
596                module => module,
597            };
598            assert!(
599                crate::runtime::host::begins_calls(module),
600                "{module} gates capabilities, so its host calls must end their calls"
601            );
602        }
603    }
604
605    /// Modules that gate without being a catalog group: `submilli:code` reads gate as
606    /// `fs.read`, and `submilli:security` gates a package's own `security.check`.
607    #[test]
608    fn gating_modules_outside_the_catalog_end_their_calls_too() {
609        for module in [
610            crate::stdlib::code::MODULE_NAME,
611            crate::stdlib::security::MODULE_NAME,
612        ] {
613            assert!(
614                crate::runtime::host::begins_calls(module),
615                "{module} gates, so its host calls must end their calls"
616            );
617        }
618        assert!(!crate::runtime::host::begins_calls("submilli:test"));
619    }
620
621    /// Every source file that gates a capability or begins a call, with the module whose
622    /// host functions run it. `None` marks the shared helpers: they run inside whichever
623    /// module calls them.
624    const CALLING_FILES: &[(&str, Option<&str>)] = &[
625        (
626            "src/stdlib/agents/mod.rs",
627            Some(crate::stdlib::agents::MODULE_NAME),
628        ),
629        (
630            "src/stdlib/skills/mod.rs",
631            Some(crate::stdlib::skills::MODULE_NAME),
632        ),
633        (
634            "src/stdlib/code/mod.rs",
635            Some(crate::stdlib::code::MODULE_NAME),
636        ),
637        (
638            "src/stdlib/embedding/mod.rs",
639            Some(crate::stdlib::embedding::MODULE_NAME),
640        ),
641        ("src/stdlib/fs/mod.rs", Some(crate::stdlib::fs::MODULE_NAME)),
642        (
643            "src/stdlib/git/mod.rs",
644            Some(crate::stdlib::git::MODULE_NAME),
645        ),
646        (
647            "src/stdlib/http/mod.rs",
648            Some(crate::stdlib::http::MODULE_NAME),
649        ),
650        (
651            "src/stdlib/http/redirect_guard.rs",
652            Some(crate::stdlib::http::MODULE_NAME),
653        ),
654        (
655            "src/stdlib/llm/mod.rs",
656            Some(crate::stdlib::llm::MODULE_NAME),
657        ),
658        (
659            "src/stdlib/secrets.rs",
660            Some(crate::stdlib::secrets::MODULE_NAME),
661        ),
662        (
663            "src/stdlib/security.rs",
664            Some(crate::stdlib::security::MODULE_NAME),
665        ),
666        (
667            "src/stdlib/session/mod.rs",
668            Some(crate::stdlib::session::MODULE_NAME),
669        ),
670        (
671            "src/runtime/mcp.rs",
672            Some(crate::runtime::mcp::MCP_MODULE_NAME),
673        ),
674        (
675            "src/runtime/tools.rs",
676            Some(crate::runtime::tools::TOOLS_MODULE_NAME),
677        ),
678        ("src/stdlib/shared.rs", None),
679    ];
680
681    fn rust_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
682        let Ok(entries) = std::fs::read_dir(dir) else {
683            return;
684        };
685        for entry in entries.flatten() {
686            let path = entry.path();
687            if path.is_dir() {
688                rust_files(&path, files);
689            } else if path.extension().is_some_and(|ext| ext == "rs") {
690                files.push(path);
691            }
692        }
693    }
694
695    /// A file that begins calls but whose module is not in `CALL_MODULES` would leave
696    /// its calls open until the run ends. The scan finds such a file even when it is
697    /// new; the table must then name it and its module.
698    #[test]
699    fn every_file_that_begins_calls_belongs_to_a_call_module() {
700        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
701        let mut files = Vec::new();
702        rust_files(&root.join("src/stdlib"), &mut files);
703        files.push(root.join("src/runtime/mcp.rs"));
704        files.push(root.join("src/runtime/tools.rs"));
705        let needles = [
706            "check_security_call(",
707            "check_security(",
708            "begin_call(",
709            "begin_recorded_call(",
710            "audit_entry_denial(",
711            "audit_denial_in(",
712            "authorize_capability(",
713            "check_and_audit(",
714        ];
715        let mut found = Vec::new();
716        for path in files {
717            let text = std::fs::read_to_string(&path).unwrap_or_default();
718            if needles.iter().any(|needle| text.contains(needle)) {
719                let relative = path
720                    .strip_prefix(root)
721                    .unwrap_or(&path)
722                    .to_string_lossy()
723                    .replace('\\', "/");
724                found.push(relative);
725            }
726        }
727        found.sort();
728        let mut table: Vec<&str> = CALLING_FILES.iter().map(|(file, _)| *file).collect();
729        table.sort_unstable();
730        assert_eq!(
731            found, table,
732            "a file that gates or begins calls must be listed in CALLING_FILES with its module"
733        );
734        for (file, module) in CALLING_FILES {
735            if let Some(module) = module {
736                assert!(
737                    crate::runtime::host::begins_calls(module),
738                    "{file} gates through {module}, which is missing from CALL_MODULES"
739                );
740            }
741        }
742    }
743
744    #[test]
745    fn url_credentials_are_masked_before_recording() {
746        let masked =
747            mask_url("https://user:pw@api.test:8443/v1/x?q=1&api_key=abc&Token=t&flag#frag");
748        assert_eq!(
749            masked,
750            "https://[masked]@api.test:8443/v1/x?q=1&api_key=[masked]&Token=[masked]&flag"
751        );
752        let plain = mask_url("https://api.test/a?page=2");
753        assert_eq!(plain, "https://api.test/a?page=2");
754        let encoded = mask_url("https://api.test/?access%5Ftoken=abc&x=1");
755        assert!(!encoded.contains("abc"), "{encoded}");
756        let signed =
757            mask_url("https://s3.test/o?X-Amz-Signature=sig&X-Amz-Credential=id&password=pw");
758        assert_eq!(
759            signed,
760            "https://s3.test/o?X-Amz-Signature=[masked]&X-Amz-Credential=[masked]&password=[masked]"
761        );
762        let only_user = mask_url("https://tok@api.test/");
763        assert_eq!(only_user, "https://[masked]@api.test/");
764    }
765
766    #[test]
767    fn an_unparsable_url_keeps_only_its_scheme() {
768        assert_eq!(
769            mask_url("http://u:p@exa mple.test/p?token=abc"),
770            "http://[unparsable url]"
771        );
772        // An invalid port: a fallback that kept the text before the last `@` would leak.
773        assert_eq!(
774            mask_url("https://user:abc/def@host/"),
775            "https://[unparsable url]"
776        );
777        assert_eq!(mask_url("notaurl?api_key=SECRET://x"), "[unparsable url]");
778        assert_eq!(mask_url("not a url token=abc"), "[unparsable url]");
779        assert_eq!(mask_url(""), "[unparsable url]");
780    }
781
782    #[test]
783    fn query_parameters_separate_at_semicolons_too() {
784        assert_eq!(
785            mask_url("https://h.test/?q=1;token=SECRET&x=2;api_key=K"),
786            "https://h.test/?q=1;token=[masked]&x=2;api_key=[masked]"
787        );
788    }
789
790    #[test]
791    fn masked_header_names_are_capped_in_number_and_length() {
792        let names: Vec<String> = (0..200).map(|index| format!("x-token-{index}")).collect();
793        let long = "k".repeat(10_000);
794        let payload = Payload::meta(Value::Null)
795            .with_masked(names.iter().cloned().chain([long.clone()]).collect());
796        let record = capture(&payload, 32, u64::MAX, 16);
797        assert_eq!(record.masked_headers.len(), MAX_MASKED_HEADERS);
798        assert!(record.truncated);
799        let long_record = capture(
800            &Payload::meta(Value::Null).with_masked(vec![long]),
801            32,
802            u64::MAX,
803            16,
804        );
805        assert!(long_record.masked_headers[0].len() < 200);
806        assert!(
807            payload_cost(&record)
808                >= record.masked_headers.iter().map(String::len).sum::<usize>() as u64,
809            "the names are charged"
810        );
811    }
812
813    #[test]
814    fn a_body_that_ends_mid_character_is_binary_unless_it_was_cut() {
815        let payload = Payload::meta(Value::Null).with_body(b"a\xC3");
816        let whole = capture(&payload, 16, u64::MAX, 16);
817        assert!(matches!(whole.body, Some(BodyCopy::Base64(_))));
818        assert!(!whole.truncated);
819        let longer = Payload::meta(Value::Null).with_body(b"a\xC3b");
820        let cut = capture(&longer, 16, u64::MAX, 2);
821        assert!(cut.truncated);
822        assert_eq!(cut.body, Some(BodyCopy::Text("a".into())));
823    }
824
825    #[test]
826    fn credential_headers_are_masked_by_name() {
827        let headers = [
828            ("Authorization", "Bearer t"),
829            ("x-api-key", "k"),
830            ("X-OAuth-Scopes", "repo"),
831            ("cookie", "c"),
832            ("Accept", "application/json"),
833        ]
834        .map(|(name, value)| (name.to_owned(), value.to_owned()));
835        let (value, masked) = mask_headers(&headers);
836        assert_eq!(
837            masked,
838            ["Authorization", "x-api-key", "X-OAuth-Scopes", "cookie"]
839        );
840        let text = value.to_string();
841        assert!(!text.contains("Bearer t") && !text.contains("\"k\"") && !text.contains("repo"));
842        assert!(text.contains("application/json"));
843    }
844
845    #[test]
846    fn a_capped_body_keeps_the_full_size_and_digest() {
847        let body = vec![b'a'; 100];
848        let payload =
849            Payload::meta(serde_json::json!({ "url": "https://x.test" })).with_body(&body);
850        let record = capture(&payload, 1024, u64::MAX, 10);
851        assert!(record.truncated);
852        assert_eq!(record.body, Some(BodyCopy::Text("a".repeat(10))));
853        let whole = capture(&payload, 1024, u64::MAX, 1000);
854        assert_eq!(
855            record.digest, whole.digest,
856            "the digest covers the full body"
857        );
858        assert_eq!(record.bytes, 100);
859        assert_eq!(whole.bytes, 100);
860    }
861
862    #[test]
863    fn binary_bodies_are_kept_as_base64() {
864        let payload = Payload::meta(Value::Null).with_body(&[0xff, 0x00, 0xfe]);
865        assert!(matches!(
866            capture(&payload, 16, u64::MAX, 16).body,
867            Some(BodyCopy::Base64(_))
868        ));
869    }
870
871    /// Whatever the room, a body the allowance lets through is charged within it: no copy
872    /// is made only to be dropped for the budget.
873    fn assert_a_kept_body_fits_its_room(payload: &Payload<'_>, max_meta: usize, rooms: u64) {
874        let mut kept_somewhere = false;
875        for room in 0..rooms {
876            let record = capture(payload, max_meta, room, usize::MAX);
877            if record.body.is_some() {
878                kept_somewhere = true;
879                assert!(
880                    payload_cost(&record) <= room,
881                    "room {room}: the record costs {}",
882                    payload_cost(&record)
883                );
884            }
885        }
886        assert!(
887            kept_somewhere,
888            "the sweep must reach rooms that keep a body"
889        );
890    }
891
892    #[test]
893    fn a_whole_body_ending_mid_character_is_sized_as_base64() {
894        let payload = Payload::meta(Value::Null).with_body(b"abcdefgh\xC3");
895        assert_a_kept_body_fits_its_room(&payload, 16, 200);
896    }
897
898    #[test]
899    fn a_cut_inside_the_first_character_is_sized_as_base64() {
900        let payload = Payload::meta(Value::Null).with_body("éééééééé".as_bytes());
901        assert_a_kept_body_fits_its_room(&payload, 16, 200);
902    }
903
904    #[test]
905    fn masked_names_are_sized_as_capped_not_as_given() {
906        let payload = Payload::meta(Value::Null)
907            .with_body(&[b'x'; 200])
908            .with_masked(vec!["k".repeat(10_000), "x-token".into()]);
909        assert_a_kept_body_fits_its_room(&payload, 32, 500);
910    }
911
912    #[test]
913    fn a_meta_string_just_over_its_cap_is_sized_as_capped() {
914        let payload =
915            Payload::meta(serde_json::json!({ "u": "a".repeat(33) })).with_body(&[b'x'; 200]);
916        assert_a_kept_body_fits_its_room(&payload, 32, 500);
917    }
918
919    #[test]
920    fn the_raw_body_cap_is_not_shrunk_by_the_base64_expansion() {
921        let body = vec![0xffu8; 900 * 1024];
922        let payload = Payload::meta(Value::Null).with_body(&body);
923        let record = capture(&payload, 16, 8 * 1024 * 1024, 1024 * 1024);
924        assert!(!record.truncated);
925        let Some(BodyCopy::Base64(text)) = &record.body else {
926            panic!("a binary body is kept as base64: {:?}", record.body);
927        };
928        let kept = base64::engine::general_purpose::STANDARD.decode(text);
929        assert_eq!(kept.map(|bytes| bytes.len()).ok(), Some(body.len()));
930        let cut = capture(&payload, 16, 8 * 1024 * 1024, 1024);
931        assert!(cut.truncated, "the cap still bounds the raw copy");
932    }
933
934    #[test]
935    fn a_digest_only_record_costs_what_the_constant_says() {
936        let record = capture(
937            &Payload::meta(serde_json::json!({ "a": 1 })).with_body(b"body"),
938            16,
939            u64::MAX,
940            16,
941        );
942        let bare = record.digest_only();
943        assert!(bare.body.is_none() && bare.masked_headers.is_empty());
944        assert_eq!(payload_cost(&bare), DIGEST_ONLY_COST);
945    }
946
947    #[test]
948    fn a_cut_inside_a_character_keeps_the_text_before_it() {
949        let text = "aé".as_bytes();
950        let payload = Payload::meta(Value::Null).with_body(text);
951        assert_eq!(
952            capture(&payload, 16, u64::MAX, 2).body,
953            Some(BodyCopy::Text("a".into()))
954        );
955    }
956}