1use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use serde_json::Value;
30
31const EXPAND_OPEN: &str = "<lc_expand:";
33const EXPAND_CLOSE: char = '>';
35
36pub(crate) const MIN_TEE_BYTES: usize = 512;
39
40const CLEANUP_INTERVAL_SECS: u64 = 600;
44
45const TEE_HASH_LEN: usize = 16;
47const SHELL_TEE_HASH_LEN: usize = 8;
49
50fn tee_path(content: &str, prefix: &str) -> Option<PathBuf> {
57 let dir = crate::core::paths::state_dir().ok()?.join("tee");
58 let hash = crate::core::hasher::hash_short(content);
59 Some(dir.join(format!("{prefix}_{hash}.log")))
60}
61
62fn maybe_cleanup(tee_dir: &Path) {
64 static LAST: AtomicU64 = AtomicU64::new(0);
65 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
66 return;
67 };
68 let now = now.as_secs();
69 let last = LAST.load(Ordering::Relaxed);
70 if now.saturating_sub(last) < CLEANUP_INTERVAL_SECS {
71 return;
72 }
73 if LAST
75 .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
76 .is_ok()
77 {
78 crate::shell::cleanup_old_tee_logs(tee_dir);
79 }
80}
81
82pub(crate) fn persist(content: &str) -> Option<String> {
90 persist_with(content, "proxy")
91}
92
93pub(crate) fn persist_json(content: &str) -> Option<String> {
100 persist_with(content, "json")
101}
102
103pub(crate) fn persist_tabular(content: &str) -> Option<String> {
109 persist_with(content, "tbl")
110}
111
112pub(crate) fn persist_yaml(content: &str) -> Option<String> {
118 persist_with(content, "yaml")
119}
120
121fn persist_with(content: &str, prefix: &str) -> Option<String> {
122 if content.len() < MIN_TEE_BYTES {
123 return None;
124 }
125 let path = tee_path(content, prefix)?;
126 let handle = path.to_string_lossy().to_string();
127
128 if !path.exists() {
129 if let Some(dir) = path.parent()
130 && std::fs::create_dir_all(dir).is_ok()
131 {
132 maybe_cleanup(dir);
133 }
134 let masked = crate::core::redaction::redact_text(content);
137 let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
138 if std::fs::write(&path, redacted).is_ok() {
139 #[cfg(unix)]
140 {
141 use std::os::unix::fs::PermissionsExt;
142 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
143 }
144 }
145 }
146 Some(handle)
147}
148
149fn is_hex(s: &str, len: usize) -> bool {
150 s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit())
151}
152
153fn canonical_tee_name(name: &str) -> Option<String> {
157 let stem = name.strip_suffix(".log").unwrap_or(name);
158 if let Some(hash) = stem.strip_prefix("proxy_") {
159 return is_hex(hash, TEE_HASH_LEN).then(|| format!("proxy_{hash}.log"));
160 }
161 if let Some(hash) = stem.strip_prefix("json_") {
162 return is_hex(hash, TEE_HASH_LEN).then(|| format!("json_{hash}.log"));
163 }
164 if let Some(hash) = stem.strip_prefix("tbl_") {
165 return is_hex(hash, TEE_HASH_LEN).then(|| format!("tbl_{hash}.log"));
166 }
167 if let Some(hash) = stem.strip_prefix("yaml_") {
168 return is_hex(hash, TEE_HASH_LEN).then(|| format!("yaml_{hash}.log"));
169 }
170 is_hex(stem, TEE_HASH_LEN).then(|| format!("proxy_{stem}.log"))
171}
172
173fn is_shell_tee_name(name: &str) -> bool {
178 let Some(stem) = name.strip_suffix(".log") else {
179 return false;
180 };
181 if !stem
182 .bytes()
183 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
184 {
185 return false;
186 }
187 match stem.rsplit_once('_') {
188 Some((slug, hash)) => !slug.is_empty() && is_hex(hash, SHELL_TEE_HASH_LEN),
189 None => false,
190 }
191}
192
193pub(crate) fn resolve_tee(id: &str) -> Option<PathBuf> {
209 let name = Path::new(id)
210 .file_name()
211 .and_then(|n| n.to_str())
212 .unwrap_or(id);
213 let canon =
214 canonical_tee_name(name).or_else(|| is_shell_tee_name(name).then(|| name.to_string()))?;
215 let path = crate::core::paths::state_dir()
216 .ok()?
217 .join("tee")
218 .join(canon);
219 path.is_file().then_some(path)
220}
221
222pub(crate) fn inband_marker(handle: &str) -> Option<String> {
229 let name = Path::new(handle).file_name().and_then(|n| n.to_str())?;
230 let hash = name.strip_prefix("proxy_")?.strip_suffix(".log")?;
231 (hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
232 .then(|| format!("{EXPAND_OPEN}{hash}{EXPAND_CLOSE}"))
233}
234
235pub(crate) fn inband_locator(handle: &str) -> Option<String> {
243 crate::core::config::Config::load()
244 .proxy
245 .ccr_inband_enabled()
246 .then(|| inband_marker(handle))
247 .flatten()
248}
249
250fn recover(hash: &str) -> Option<String> {
253 if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
254 return None;
255 }
256 std::fs::read_to_string(resolve_tee(hash)?).ok()
257}
258
259pub(crate) const LITELLM_HASH_LEN: usize = 24;
263
264pub(crate) fn litellm_hash(content: &str) -> String {
271 blake3::hash(content.as_bytes()).to_hex()[..LITELLM_HASH_LEN].to_string()
272}
273
274pub(crate) fn retrieve_litellm(hash: &str) -> Option<String> {
280 if hash.len() != LITELLM_HASH_LEN
281 || !hash
282 .bytes()
283 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
284 {
285 return None;
286 }
287 std::fs::read_to_string(resolve_tee(&hash[..TEE_HASH_LEN])?).ok()
288}
289
290fn splice_str(s: &str) -> Option<String> {
303 if !s.contains(EXPAND_OPEN) {
304 return None;
305 }
306 let mut out = String::with_capacity(s.len());
307 let mut rest = s;
308 let mut changed = false;
309 while let Some(pos) = rest.find(EXPAND_OPEN) {
310 let after = &rest[pos + EXPAND_OPEN.len()..];
311 match after.find(EXPAND_CLOSE) {
312 Some(end) => {
313 let hash = &after[..end];
314 if let Some(original) = recover(hash) {
315 out.push_str(&rest[..pos]);
316 out.push_str(&original);
317 rest = &after[end + EXPAND_CLOSE.len_utf8()..];
318 changed = true;
319 } else {
320 out.push_str(&rest[..pos + EXPAND_OPEN.len()]);
323 rest = after;
324 }
325 }
326 None => break,
328 }
329 }
330 out.push_str(rest);
331 changed.then_some(out)
332}
333
334pub(crate) fn splice_inband_in_place(value: &mut Value) -> bool {
343 match value {
344 Value::String(s) => {
345 if let Some(spliced) = splice_str(s) {
346 *s = spliced;
347 true
348 } else {
349 false
350 }
351 }
352 Value::Array(items) => {
353 let mut changed = false;
354 for item in items {
355 changed |= splice_inband_in_place(item);
356 }
357 changed
358 }
359 Value::Object(map) => {
360 let mut changed = false;
361 for (_, v) in map.iter_mut() {
362 changed |= splice_inband_in_place(v);
363 }
364 changed
365 }
366 _ => false,
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn big(seed: &str) -> String {
375 format!("{seed}\n").repeat(40)
376 }
377
378 #[test]
379 fn handle_is_content_addressed_and_deterministic() {
380 let _lock = crate::core::data_dir::test_env_lock();
381 let content = big("file body line");
382 let a = persist(&content).expect("persisted");
383 let b = persist(&content).expect("persisted again");
384 assert_eq!(
385 a, b,
386 "same content must map to the same handle (cache-safe)"
387 );
388 assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
389
390 let other = persist(&big("different body")).expect("persisted");
391 assert_ne!(a, other, "different content must get a different handle");
392 }
393
394 #[test]
395 fn persisted_original_is_recoverable() {
396 let _lock = crate::core::data_dir::test_env_lock();
397 let content = big("recoverable verbatim line");
398 let handle = persist(&content).expect("persisted");
399 let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
400 assert!(
401 on_disk.contains("recoverable verbatim line"),
402 "the verbatim original must be retrievable from the handle"
403 );
404 }
405
406 #[test]
407 fn small_content_gets_no_handle() {
408 let _lock = crate::core::data_dir::test_env_lock();
409 assert!(
410 persist("too small to bother").is_none(),
411 "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
412 );
413 }
414
415 #[test]
416 fn resolve_tee_accepts_every_stub_form() {
417 let _lock = crate::core::data_dir::test_env_lock();
418 let content = big("resolvable tee body");
419 let handle = persist(&content).expect("persisted");
420 let hash = crate::core::hasher::hash_short(&content);
421
422 for form in [
425 handle.clone(),
426 format!("proxy_{hash}.log"),
427 format!("proxy_{hash}"),
428 hash.clone(),
429 ] {
430 let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
431 assert_eq!(
432 resolved.to_string_lossy(),
433 handle,
434 "form {form} -> {handle}"
435 );
436 }
437 }
438
439 #[test]
440 fn resolve_tee_rejects_nontee_and_traversal_ids() {
441 let _lock = crate::core::data_dir::test_env_lock();
442 assert!(resolve_tee("/etc/passwd").is_none());
445 assert!(resolve_tee("../../secret").is_none());
446 assert!(resolve_tee("proxy_nothex0000000.log").is_none());
447 assert!(resolve_tee("deadbeefdeadbeef").is_none());
449 }
450
451 #[test]
452 fn persist_json_is_distinct_prefix_and_resolvable() {
453 let _lock = crate::core::data_dir::test_env_lock();
454 let content = big("json crusher original");
455 let proxy = persist(&content).expect("proxy persisted");
456 let json = persist_json(&content).expect("json persisted");
457 assert!(
458 json.contains("json_"),
459 "json handle uses json_ prefix: {json}"
460 );
461 assert_ne!(
462 proxy, json,
463 "same content gets distinct files per producer prefix"
464 );
465
466 let hash = crate::core::hasher::hash_short(&content);
469 for form in [
470 json.clone(),
471 format!("json_{hash}.log"),
472 format!("json_{hash}"),
473 ] {
474 assert_eq!(
475 resolve_tee(&form)
476 .expect("json form resolves")
477 .to_string_lossy(),
478 json,
479 "json form {form} -> {json}"
480 );
481 }
482 }
483
484 #[test]
485 fn persist_tabular_is_distinct_prefix_and_resolvable() {
486 let _lock = crate::core::data_dir::test_env_lock();
487 let content = big("tabular crusher original");
488 let json = persist_json(&content).expect("json persisted");
489 let tbl = persist_tabular(&content).expect("tbl persisted");
490 assert!(
491 tbl.contains("tbl_"),
492 "tabular handle uses tbl_ prefix: {tbl}"
493 );
494 assert_ne!(
495 json, tbl,
496 "same content gets distinct files per producer prefix"
497 );
498
499 let hash = crate::core::hasher::hash_short(&content);
500 for form in [
501 tbl.clone(),
502 format!("tbl_{hash}.log"),
503 format!("tbl_{hash}"),
504 ] {
505 assert_eq!(
506 resolve_tee(&form)
507 .expect("tbl form resolves")
508 .to_string_lossy(),
509 tbl,
510 "tbl form {form} -> {tbl}"
511 );
512 }
513 }
514
515 #[test]
516 fn persist_yaml_is_distinct_prefix_and_resolvable() {
517 let _lock = crate::core::data_dir::test_env_lock();
518 let content = big("yaml crusher original");
519 let tbl = persist_tabular(&content).expect("tbl persisted");
520 let yaml = persist_yaml(&content).expect("yaml persisted");
521 assert!(
522 yaml.contains("yaml_"),
523 "yaml handle uses yaml_ prefix: {yaml}"
524 );
525 assert_ne!(
526 tbl, yaml,
527 "same content gets distinct files per producer prefix"
528 );
529
530 let hash = crate::core::hasher::hash_short(&content);
531 for form in [
532 yaml.clone(),
533 format!("yaml_{hash}.log"),
534 format!("yaml_{hash}"),
535 ] {
536 assert_eq!(
537 resolve_tee(&form)
538 .expect("yaml form resolves")
539 .to_string_lossy(),
540 yaml,
541 "yaml form {form} -> {yaml}"
542 );
543 }
544 }
545
546 #[test]
547 fn resolve_tee_resolves_shell_tee_with_underscored_slug() {
548 let _lock = crate::core::data_dir::test_env_lock();
549 let path = crate::shell::save_tee("gh api /repos/foo/bar", &big("api row"))
553 .expect("shell tee saved");
554 let name = std::path::Path::new(&path)
555 .file_name()
556 .and_then(|n| n.to_str())
557 .unwrap()
558 .to_string();
559 assert!(
560 is_shell_tee_name(&name),
561 "save_tee name must be recognized as a shell tee: {name}"
562 );
563 for form in [path.clone(), name] {
564 assert_eq!(
565 resolve_tee(&form)
566 .expect("shell tee form resolves")
567 .to_string_lossy(),
568 path,
569 "shell tee form -> {path}"
570 );
571 }
572 }
573
574 #[test]
575 fn resolve_tee_does_not_capture_reference_ids() {
576 let _lock = crate::core::data_dir::test_env_lock();
577 assert!(resolve_tee("ref_deadbeefcafef00d").is_none());
581 assert!(resolve_tee("0123456789abcdef").is_none());
583 }
584
585 #[test]
586 fn litellm_hash_is_24_lowercase_hex_and_extends_tee_hash() {
587 let content = big("gateway retrieval body");
588 let hash = litellm_hash(&content);
589 assert_eq!(hash.len(), LITELLM_HASH_LEN);
590 assert!(
591 hash.bytes()
592 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
593 "must match LiteLLM's [a-f0-9]{{24}} class: {hash}"
594 );
595 assert_eq!(hash[..16], crate::core::hasher::hash_short(&content));
598 assert_eq!(hash, litellm_hash(&content));
600 }
601
602 #[test]
603 fn retrieve_litellm_resolves_persisted_content() {
604 let _lock = crate::core::data_dir::test_env_lock();
605 let content = big("litellm retrievable original");
606 persist(&content).expect("persisted");
607 let recovered =
608 retrieve_litellm(&litellm_hash(&content)).expect("24-hex hash must resolve");
609 assert!(recovered.contains("litellm retrievable original"));
610 }
611
612 #[test]
613 fn retrieve_litellm_is_shape_locked_to_the_guardrail_regex() {
614 let _lock = crate::core::data_dir::test_env_lock();
615 let content = big("shape locked body");
616 persist(&content).expect("persisted");
617 let hash = litellm_hash(&content);
618
619 assert!(retrieve_litellm(&hash[..16]).is_none(), "16-hex rejected");
622 assert!(retrieve_litellm(&hash[..23]).is_none(), "23-hex rejected");
623 assert!(
624 retrieve_litellm(&format!("{hash}0")).is_none(),
625 "25-hex rejected"
626 );
627 assert!(
628 retrieve_litellm(&hash.to_uppercase()).is_none(),
629 "uppercase rejected (regex class is [a-f0-9])"
630 );
631 assert!(
632 retrieve_litellm("zzzzzzzzzzzzzzzzzzzzzzzz").is_none(),
633 "non-hex rejected"
634 );
635 assert!(retrieve_litellm("../../etc/passwd00000000").is_none());
637 assert!(retrieve_litellm("0123456789abcdef01234567").is_none());
639 }
640
641 #[test]
642 fn inband_marker_is_derived_from_handle() {
643 let _lock = crate::core::data_dir::test_env_lock();
644 let content = big("inband marker body");
645 let handle = persist(&content).expect("persisted");
646 let hash = crate::core::hasher::hash_short(&content);
647 assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
650 assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
652 }
653
654 #[test]
655 fn splice_replaces_marker_with_verbatim_original() {
656 let _lock = crate::core::data_dir::test_env_lock();
657 let content = big("the historical verbatim line");
658 let handle = persist(&content).expect("persisted");
659 let marker = inband_marker(&handle).expect("marker");
660
661 let mut doc = serde_json::json!({
662 "messages": [{ "role": "assistant", "content": format!("recall {marker} please") }]
663 });
664 assert!(splice_inband_in_place(&mut doc), "a marker must splice");
665 let spliced = doc["messages"][0]["content"].as_str().unwrap();
666 assert!(
667 spliced.contains("the historical verbatim line"),
668 "verbatim original must be spliced in: {spliced}"
669 );
670 assert!(
671 !spliced.contains("<lc_expand:"),
672 "the marker must be consumed, not left behind"
673 );
674 }
675
676 #[test]
677 fn splice_is_byte_identical_no_op_without_marker() {
678 let _lock = crate::core::data_dir::test_env_lock();
679 let mut doc = serde_json::json!({
680 "messages": [{ "role": "user", "content": "no marker here" }],
681 "system": "plain"
682 });
683 let before = doc.clone();
684 assert!(
685 !splice_inband_in_place(&mut doc),
686 "no marker → must report no change"
687 );
688 assert_eq!(
689 doc, before,
690 "marker-less body must stay byte-identical (cache-safe)"
691 );
692 }
693
694 #[test]
695 fn splice_keeps_unresolvable_marker_verbatim() {
696 let _lock = crate::core::data_dir::test_env_lock();
697 let mut doc = serde_json::json!({ "t": "before <lc_expand:deadbeefdeadbeef> after" });
700 assert!(!splice_inband_in_place(&mut doc));
701 assert_eq!(
702 doc["t"].as_str().unwrap(),
703 "before <lc_expand:deadbeefdeadbeef> after"
704 );
705 }
706
707 #[test]
708 fn splice_recurses_and_handles_multiple_markers() {
709 let _lock = crate::core::data_dir::test_env_lock();
710 let a = big("first recovered body");
711 let b = big("second recovered body");
712 let ma = inband_marker(&persist(&a).unwrap()).unwrap();
713 let mb = inband_marker(&persist(&b).unwrap()).unwrap();
714
715 let mut doc = serde_json::json!({
717 "contents": [
718 { "parts": [{ "text": format!("{ma} and {mb}") }] }
719 ]
720 });
721 assert!(splice_inband_in_place(&mut doc));
722 let text = doc["contents"][0]["parts"][0]["text"].as_str().unwrap();
723 assert!(text.contains("first recovered body"));
724 assert!(text.contains("second recovered body"));
725 assert!(!text.contains("<lc_expand:"));
726 }
727}