Skip to main content

systemprompt_api/services/gateway/audit/
payload.rs

1//! Capped request/response payload capture for gateway audit rows.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use bytes::Bytes;
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9
10const PAYLOAD_CAP_BYTES: usize = 1024 * 1024;
11const EXCERPT_BYTES: usize = 8 * 1024;
12
13/// What a captured request or response body contributes to an audit row.
14///
15/// `sha256` is computed over the **full** bytes regardless of truncation, so a
16/// capped capture still proves which body was sent.
17#[derive(Debug, Clone)]
18pub struct PayloadCapture {
19    pub json: Option<Value>,
20    pub excerpt: Option<String>,
21    pub truncated: bool,
22    pub byte_len: i32,
23    pub sha256: String,
24}
25
26#[must_use]
27pub fn digest_hex(bytes: &[u8]) -> String {
28    hex::encode(Sha256::digest(bytes))
29}
30
31#[must_use]
32pub fn slice_payload(bytes: &Bytes) -> PayloadCapture {
33    let len = bytes.len();
34    let byte_len = len.min(i32::MAX as usize) as i32;
35    let sha256 = digest_hex(bytes);
36    if len <= PAYLOAD_CAP_BYTES {
37        serde_json::from_slice::<Value>(bytes).map_or_else(
38            |_| PayloadCapture {
39                json: None,
40                excerpt: Some(String::from_utf8_lossy(bytes).to_string()),
41                truncated: false,
42                byte_len,
43                sha256: sha256.clone(),
44            },
45            |v| PayloadCapture {
46                json: Some(v),
47                excerpt: None,
48                truncated: false,
49                byte_len,
50                sha256: sha256.clone(),
51            },
52        )
53    } else {
54        let head_len = EXCERPT_BYTES.min(len);
55        let head = String::from_utf8_lossy(&bytes[..head_len]).to_string();
56        let tail_start = len.saturating_sub(EXCERPT_BYTES);
57        let tail_len = len - tail_start;
58        let tail = String::from_utf8_lossy(&bytes[tail_start..]).to_string();
59        let dropped = len - head_len - tail_len;
60        let excerpt = format!("{head}\n...<truncated {dropped} bytes>...\n{tail}");
61        PayloadCapture {
62            json: None,
63            excerpt: Some(excerpt),
64            truncated: true,
65            byte_len,
66            sha256,
67        }
68    }
69}
70
71pub fn truncate_for_tool_input(input: &str) -> String {
72    const TOOL_INPUT_CAP: usize = 64 * 1024;
73    if input.len() <= TOOL_INPUT_CAP {
74        input.to_owned()
75    } else {
76        let cut = systemprompt_models::text::floor_char_boundary(input, TOOL_INPUT_CAP);
77        let head = &input[..cut];
78        format!("{head}...<truncated {} bytes>", input.len() - cut)
79    }
80}