use serde_json::Value;
const MAX_BANNER_CHARS: usize = 256;
const MAX_RESULT_BYTES: usize = 1024 * 1024;
const REMOTE_TEXT_KEYS: &[&str] = &["banner", "hex_preview", "info"];
pub(super) fn cap_tool_result(mut value: Value) -> Value {
cap_remote_text(&mut value);
cap_result_size(value)
}
pub(super) fn cap_remote_text(value: &mut Value) {
match value {
Value::Array(items) => items.iter_mut().for_each(cap_remote_text),
Value::Object(map) => {
map.remove("raw");
for (key, entry) in map.iter_mut() {
if REMOTE_TEXT_KEYS.contains(&key.as_str()) {
if let Value::String(text) = entry {
truncate_chars(text, MAX_BANNER_CHARS);
}
} else {
cap_remote_text(entry);
}
}
}
_ => {}
}
}
fn truncate_chars(text: &mut String, max: usize) {
if text.chars().count() <= max {
return;
}
let kept: String = text.chars().take(max).collect();
*text = format!("{kept}… (truncated)");
}
pub(super) fn cap_result_size(value: Value) -> Value {
let encoded = serde_json::to_string(&value).map(|s| s.len()).unwrap_or(0);
if encoded <= MAX_RESULT_BYTES {
return value;
}
let Value::Array(items) = value else {
return serde_json::json!({
"error": "result too large to return",
"limit_bytes": MAX_RESULT_BYTES,
"size_bytes": encoded,
});
};
let total = items.len();
let mut kept: Vec<Value> = Vec::new();
let mut used = 0usize;
for item in items {
let size = serde_json::to_string(&item).map(|s| s.len()).unwrap_or(0) + 1;
if used + size > MAX_RESULT_BYTES {
break;
}
used += size;
kept.push(item);
}
let returned = kept.len();
serde_json::json!({
"results": kept,
"truncated": true,
"returned": returned,
"total": total,
"note": format!(
"Result exceeded {MAX_RESULT_BYTES} bytes. Narrow the port or address range to see the rest."
),
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn raw_is_dropped_and_banner_truncated() {
let mut value = json!([{
"port": 80,
"banner": "A".repeat(5000),
"raw": "B".repeat(4096),
"service": "http",
}]);
cap_remote_text(&mut value);
let entry = &value[0];
assert!(entry.get("raw").is_none(), "raw must not reach the model");
let banner = entry["banner"].as_str().unwrap();
assert!(banner.ends_with("… (truncated)"));
assert_eq!(
banner.chars().count(),
MAX_BANNER_CHARS + "… (truncated)".chars().count()
);
assert_eq!(entry["service"], "http");
assert_eq!(entry["port"], 80);
}
#[test]
fn a_short_banner_is_left_exactly_as_it_was() {
let mut value = json!([{ "banner": "SSH-2.0-OpenSSH_9.6p1" }]);
cap_remote_text(&mut value);
assert_eq!(value[0]["banner"], "SSH-2.0-OpenSSH_9.6p1");
}
#[test]
fn multibyte_banners_are_not_split_mid_character() {
let mut value = json!([{ "banner": "\u{1f600}".repeat(1000) }]);
cap_remote_text(&mut value);
assert!(value[0]["banner"]
.as_str()
.unwrap()
.starts_with('\u{1f600}'));
}
#[test]
fn an_oversized_array_is_truncated_and_says_so() {
let items: Vec<Value> = (0..40_000)
.map(|i| json!({ "port": i, "service": "x".repeat(64) }))
.collect();
let capped = cap_result_size(Value::Array(items));
assert_eq!(capped["truncated"], true);
assert_eq!(capped["total"], 40_000);
assert!(serde_json::to_string(&capped).unwrap().len() <= MAX_RESULT_BYTES * 2);
let actually_returned = capped["results"].as_array().unwrap().len();
assert_eq!(capped["returned"], actually_returned);
assert!(
actually_returned < 40_000,
"the fixture must actually truncate for this to mean anything"
);
}
#[test]
fn a_small_result_passes_through_untouched() {
let value = json!([{ "port": 22, "open": true }]);
assert_eq!(cap_result_size(value.clone()), value);
}
#[test]
fn a_pcap_job_result_is_capped_through_its_nesting() {
let packets: Vec<Value> = (0..20_000)
.map(|i| {
json!({
"index": i,
"protocol": "TCP",
"info": "X".repeat(400),
"hex_preview": "de ad be ef ".repeat(40),
})
})
.collect();
let job_result = json!({
"jobId": "pcap-1",
"status": "completed",
"result": { "packets_captured": 20_000, "packets": packets },
});
let capped = cap_tool_result(job_result);
let encoded = serde_json::to_string(&capped).unwrap();
assert!(
encoded.len() <= MAX_RESULT_BYTES,
"a job result must not exceed the cap; got {} bytes",
encoded.len()
);
assert!(
!encoded.contains(&"X".repeat(MAX_BANNER_CHARS + 1)),
"no untruncated remote `info` may survive"
);
}
}