1use crate::artifacts::{hex_preview, ArtifactStore};
18use base64::Engine;
19use serde_json::{json, Map, Value};
20use std::sync::atomic::{AtomicUsize, Ordering};
21
22#[derive(Debug, Clone)]
24pub struct SanitizeLimits {
25 pub max_observation_chars: usize,
27 pub preview_bytes: usize,
29 pub min_base64_len: usize,
31 pub budget_chars: usize,
34}
35
36impl Default for SanitizeLimits {
37 fn default() -> Self {
38 SanitizeLimits {
39 max_observation_chars: 0,
42 preview_bytes: 64,
43 min_base64_len: 256,
44 budget_chars: 16_384,
45 }
46 }
47}
48
49#[derive(Debug)]
51pub struct PreviewBudget(AtomicUsize);
52
53impl PreviewBudget {
54 pub fn new(chars: usize) -> Self {
55 PreviewBudget(AtomicUsize::new(chars))
56 }
57 pub fn default_for(limits: &SanitizeLimits) -> Self {
58 PreviewBudget::new(limits.budget_chars)
59 }
60 fn take(&self, want: usize) -> usize {
62 self.0
63 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
64 Some(cur.saturating_sub(want))
65 })
66 .map(|prev| want.min(prev))
67 .unwrap_or(0)
68 }
69}
70
71fn is_base64_alphabet(c: char) -> bool {
72 c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' || c == '-' || c == '_'
73}
74
75pub fn is_probably_base64(s: &str, min_len: usize) -> bool {
77 let t = s.trim();
78 if t.len() < min_len {
79 return false;
80 }
81 if t.contains(' ') {
82 return false;
83 }
84 let core: String = t.chars().filter(|c| !c.is_whitespace()).collect();
85 if core.len() < min_len || core.len() % 4 != 0 {
86 return false;
87 }
88 core.chars().all(is_base64_alphabet)
89}
90
91fn unique_chars(s: &str) -> usize {
92 let mut set = std::collections::HashSet::new();
93 for c in s.chars() {
94 if !c.is_whitespace() {
95 set.insert(c);
96 }
97 }
98 set.len()
99}
100
101fn looks_like_b64_blob(s: &str, min_len: usize) -> bool {
105 is_probably_base64(s, min_len)
106 && (s.contains('+') || s.contains('/') || s.contains('='))
107 && unique_chars(s) >= 12
108}
109
110fn key_is_binary(k: &str) -> bool {
111 let k = k.to_ascii_lowercase();
112 k == "base64"
113 || k == "b64"
114 || k == "data"
115 || k == "blob"
116 || k == "image"
117 || k == "audio"
118 || k == "video"
119 || k.ends_with("_base64")
120 || k.ends_with("_b64")
121 || k.contains("base64")
122}
123
124fn decode_data_uri(s: &str) -> Option<(String, Vec<u8>)> {
125 let rest = s.strip_prefix("data:")?;
126 let (meta, b64) = rest.split_once(',')?;
127 let mime = meta.split(';').next().unwrap_or("").to_string();
128 if !meta.contains("base64") {
129 return None;
131 }
132 let bytes = base64::engine::general_purpose::STANDARD
133 .decode(b64.trim())
134 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(b64.trim()))
135 .ok()?;
136 Some((
137 if mime.is_empty() {
138 "application/octet-stream".to_string()
139 } else {
140 mime
141 },
142 bytes,
143 ))
144}
145
146fn looks_binary(bytes: &[u8]) -> bool {
147 if bytes.is_empty() {
148 return false;
149 }
150 if bytes.contains(&0) {
151 return true;
152 }
153 let nontext = bytes
154 .iter()
155 .filter(|&&b| b < 0x09 || (b > 0x0d && b < 0x20) || b >= 0x7f)
156 .count();
157 nontext * 100 / bytes.len() >= 10
158}
159
160fn take_preview(
161 store: &ArtifactStore,
162 budget: &PreviewBudget,
163 limits: &SanitizeLimits,
164 bytes: &[u8],
165) -> Option<String> {
166 let (kind, _) = ArtifactStore::sniff(bytes);
167 let want_bytes = kind
168 .default_preview_bytes()
169 .min(limits.preview_bytes)
170 .max(1);
171 let want_chars = want_bytes * 3; let granted = budget.take(want_chars);
173 if granted == 0 {
174 return None;
175 }
176 let grant_bytes = (granted / 3).max(1);
177 Some(hex_preview(bytes, grant_bytes))
178}
179
180fn blob_to_ref_json(
182 store: &ArtifactStore,
183 budget: &PreviewBudget,
184 limits: &SanitizeLimits,
185 bytes: &[u8],
186 mime_hint: Option<&str>,
187) -> Value {
188 match store.put_bytes(bytes, mime_hint, "obs") {
189 Ok(r) => {
190 let preview = take_preview(store, budget, limits, bytes);
191 r.to_model_json(preview.as_deref())
192 }
193 Err(_) => {
194 let preview = hex_preview(bytes, limits.preview_bytes);
196 json!({"binary_omitted": true, "bytes": bytes.len(), "preview_hex": preview})
197 }
198 }
199}
200
201fn sanitize_string_value(
203 s: &str,
204 key: Option<&str>,
205 store: &ArtifactStore,
206 budget: &PreviewBudget,
207 limits: &SanitizeLimits,
208) -> Value {
209 if let Some((mime, bytes)) = decode_data_uri(s) {
210 if !bytes.is_empty() {
211 return blob_to_ref_json(store, budget, limits, &bytes, Some(&mime));
212 }
213 }
214 let key_binary = key.map(key_is_binary).unwrap_or(false);
215 let maybe_b64 = key_binary || looks_like_b64_blob(s, limits.min_base64_len);
216 if maybe_b64 {
217 let core: String = s.chars().filter(|c| !c.is_whitespace()).collect();
218 let decoded = base64::engine::general_purpose::STANDARD
219 .decode(core.as_bytes())
220 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(core.as_bytes()))
221 .ok();
222 if let Some(bytes) = decoded {
223 if key_binary || looks_binary(&bytes) {
226 return blob_to_ref_json(store, budget, limits, &bytes, None);
227 }
228 }
229 }
230 Value::String(s.to_string())
231}
232
233fn sanitize_value(
234 v: &Value,
235 key: Option<&str>,
236 store: &ArtifactStore,
237 budget: &PreviewBudget,
238 limits: &SanitizeLimits,
239) -> Value {
240 match v {
241 Value::String(s) => sanitize_string_value(s, key, store, budget, limits),
242 Value::Array(a) => Value::Array(
243 a.iter()
244 .map(|x| sanitize_value(x, None, store, budget, limits))
245 .collect(),
246 ),
247 Value::Object(o) => {
248 let mut m = Map::new();
249 for (k, val) in o {
250 m.insert(
251 k.clone(),
252 sanitize_value(val, Some(k), store, budget, limits),
253 );
254 }
255 Value::Object(m)
256 }
257 other => other.clone(),
258 }
259}
260
261fn cap_text(s: String, max: usize) -> String {
262 if max == 0 || s.chars().count() <= max {
263 return s;
264 }
265 let head: String = s.chars().take(max).collect();
266 format!("{head}\n…[truncated, {} chars total]", s.chars().count())
267}
268
269pub fn sanitize(
271 raw: &str,
272 store: &ArtifactStore,
273 budget: &PreviewBudget,
274 limits: &SanitizeLimits,
275) -> String {
276 if let Ok(v) = serde_json::from_str::<Value>(raw) {
278 let has_token = raw.contains("base64")
280 || raw.contains("data:")
281 || raw.contains("b64")
282 || raw.len() > limits.min_base64_len;
283 if !has_token {
284 return cap_text(raw.to_string(), limits.max_observation_chars);
285 }
286 let out = sanitize_value(&v, None, store, budget, limits);
287 if out == v {
290 return cap_text(raw.to_string(), limits.max_observation_chars);
291 }
292 let s = serde_json::to_string(&out).unwrap_or_else(|_| raw.to_string());
293 return cap_text(s, limits.max_observation_chars);
294 }
295
296 if let Some((mime, bytes)) = decode_data_uri(raw.trim()) {
298 if !bytes.is_empty() {
299 let j = blob_to_ref_json(store, budget, limits, &bytes, Some(&mime));
300 return j.to_string();
301 }
302 }
303 let trimmed = raw.trim();
304 if looks_like_b64_blob(trimmed, limits.min_base64_len) {
305 let core: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
306 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(core.as_bytes()) {
307 if looks_binary(&bytes) {
308 let j = blob_to_ref_json(store, budget, limits, &bytes, None);
309 return j.to_string();
310 }
311 }
312 }
313 cap_text(raw.to_string(), limits.max_observation_chars)
314}
315
316fn count_artifacts(v: &Value) -> usize {
317 match v {
318 Value::Object(m) => {
319 let here = usize::from(m.contains_key("artifact"));
320 here + m.values().map(count_artifacts).sum::<usize>()
321 }
322 Value::Array(a) => a.iter().map(count_artifacts).sum(),
323 _ => 0,
324 }
325}
326
327pub fn redact_binary(value: &Value, store: &ArtifactStore) -> (Value, usize) {
332 let budget = PreviewBudget::new(0);
333 let limits = SanitizeLimits::default();
334 let out = sanitize_value(value, None, store, &budget, &limits);
335 let n = count_artifacts(&out);
336 (out, n)
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use std::path::PathBuf;
343
344 fn store() -> (ArtifactStore, PathBuf) {
345 let dir = std::env::temp_dir().join(format!("aacode_obs_{}", uuid::Uuid::new_v4()));
346 std::fs::create_dir_all(&dir).unwrap();
347 (ArtifactStore::new(&dir), dir)
348 }
349
350 fn lim() -> SanitizeLimits {
351 SanitizeLimits::default()
352 }
353
354 #[test]
355 fn inline_base64_field_becomes_artifact_ref() {
356 let (st, _d) = store();
357 let budget = PreviewBudget::new(lim().budget_chars);
358 let raws: Vec<u8> = (0..100_000u32).map(|i| (i * 7 % 256) as u8).collect();
360 let b64 = base64::engine::general_purpose::STANDARD.encode(&raws);
361 let raw = json!({"result": {"base64": b64, "format": "rgba", "width": 400, "height": 800}, "success": true}).to_string();
362 let out = sanitize(&raw, &st, &budget, &lim());
363 assert!(out.len() < 2_000, "sanitized len={}", out.len());
364 assert!(out.contains("artifact"), "out={out}");
365 assert!(out.contains("preview_hex"), "out={out}");
366 assert!(!out.contains(&"A".repeat(200)), "no bulk base64 remains");
367 let v: Value = serde_json::from_str(&out).unwrap();
368 assert_eq!(v["result"]["base64"]["bytes"], 100_000);
369 }
370
371 #[test]
372 fn data_uri_image_is_detected_and_extracted() {
373 let (st, _d) = store();
374 let budget = PreviewBudget::new(lim().budget_chars);
375 let png: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4];
376 let uri = format!(
377 "data:image/png;base64,{}",
378 base64::engine::general_purpose::STANDARD.encode(&png)
379 );
380 let raw = json!({"img": uri}).to_string();
381 let out = sanitize(&raw, &st, &budget, &lim());
382 assert!(out.contains("image/png"), "out={out}");
383 assert!(out.contains("artifact"), "out={out}");
384 }
385
386 #[test]
387 fn budget_limits_repeated_previews() {
388 let (st, _d) = store();
389 let mut l = lim();
390 l.preview_bytes = 64;
391 let budget = PreviewBudget::new(9); let blob = base64::engine::general_purpose::STANDARD.encode(vec![0u8; 4096]);
393 let raw = json!({"a": {"base64": blob.clone()}, "b": {"base64": blob}}).to_string();
394 let out = sanitize(&raw, &st, &budget, &l);
395 let count = out.matches("preview_hex").count();
397 assert!(count <= 1, "preview budget not enforced: {out}");
398 }
399
400 #[test]
401 fn plain_text_not_truncated_by_default() {
402 let (st, _d) = store();
403 let budget = PreviewBudget::new(lim().budget_chars);
404 let raw = "line of text\n".repeat(20_000); let out = sanitize(&raw, &st, &budget, &lim());
406 assert_eq!(
407 out.len(),
408 raw.len(),
409 "plain text must pass through unbounded"
410 );
411 }
412
413 #[test]
414 fn plain_text_is_only_capped() {
415 let (st, _d) = store();
416 let budget = PreviewBudget::new(lim().budget_chars);
417 let raw = "hello world\n".repeat(10);
418 let out = sanitize(&raw, &st, &budget, &lim());
419 assert!(out.starts_with("hello world"));
420 }
421
422 #[test]
423 fn long_text_is_truncated() {
424 let (st, _d) = store();
425 let budget = PreviewBudget::new(lim().budget_chars);
426 let mut l = lim();
427 l.max_observation_chars = 100;
428 let raw = "x".repeat(5000);
429 let out = sanitize(&raw, &st, &budget, &l);
430 assert!(out.len() < 200, "len={}", out.len());
431 assert!(out.contains("truncated"));
432 }
433}