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> {
58 let dir = crate::core::paths::state_dir().ok()?.join("tee");
59 let hash = crate::core::hasher::hash_short(content);
60 Some(dir.join(format!("{prefix}_{hash}.log")))
61}
62
63fn maybe_cleanup(tee_dir: &Path) {
65 static LAST: AtomicU64 = AtomicU64::new(0);
66 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
67 return;
68 };
69 let now = now.as_secs();
70 let last = LAST.load(Ordering::Relaxed);
71 if now.saturating_sub(last) < CLEANUP_INTERVAL_SECS {
72 return;
73 }
74 if LAST
76 .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
77 .is_ok()
78 {
79 crate::shell::cleanup_old_tee_logs(tee_dir);
80 }
81}
82
83pub(crate) fn persist(content: &str) -> Option<String> {
91 persist_with(content, "proxy")
92}
93
94#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn persist_conversation(content: &str) -> Option<String> {
101 let path = persist_with_min(content, "conv", 1)?;
102 Path::new(&path)
103 .file_name()
104 .and_then(|name| name.to_str())
105 .map(str::to_owned)
106}
107
108pub(crate) fn persist_json(content: &str) -> Option<String> {
115 persist_with(content, "json")
116}
117
118pub(crate) fn persist_tabular(content: &str) -> Option<String> {
124 persist_with(content, "tbl")
125}
126
127pub(crate) fn persist_yaml(content: &str) -> Option<String> {
133 persist_with(content, "yaml")
134}
135
136pub(crate) fn persist_html(content: &str) -> Option<String> {
142 persist_with(content, "html")
143}
144
145fn persist_with(content: &str, prefix: &str) -> Option<String> {
146 persist_with_min(content, prefix, MIN_TEE_BYTES)
147}
148
149fn persist_with_min(content: &str, prefix: &str, min_bytes: usize) -> Option<String> {
150 if content.len() < min_bytes {
151 return None;
152 }
153 let path = tee_path(content, prefix)?;
154 let handle = path.to_string_lossy().to_string();
155
156 if !path.exists() {
157 if let Some(dir) = path.parent()
158 && std::fs::create_dir_all(dir).is_ok()
159 {
160 maybe_cleanup(dir);
161 }
162 let masked = crate::core::redaction::redact_text(content);
165 let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
166 if std::fs::write(&path, redacted).is_ok() {
167 #[cfg(unix)]
168 {
169 use std::os::unix::fs::PermissionsExt;
170 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
171 }
172 }
173 }
174 if path.is_file() {
175 let source_tool = match prefix {
176 "json" | "tbl" | "yaml" => "ctx_read",
177 "html" => "ctx_shell",
178 _ => "proxy",
179 };
180 crate::core::relevance_tracker::register_compressed(
181 handle.clone(),
182 content,
183 source_tool,
184 crate::core::tokens::count_tokens(content),
185 0,
186 );
187 }
188 Some(handle)
189}
190
191fn is_hex(s: &str, len: usize) -> bool {
192 s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit())
193}
194
195fn canonical_tee_name(name: &str) -> Option<String> {
199 let stem = name.strip_suffix(".log").unwrap_or(name);
200 if let Some(hash) = stem.strip_prefix("proxy_") {
201 return is_hex(hash, TEE_HASH_LEN).then(|| format!("proxy_{hash}.log"));
202 }
203 if let Some(hash) = stem.strip_prefix("conv_") {
204 return is_hex(hash, TEE_HASH_LEN).then(|| format!("conv_{hash}.log"));
205 }
206 if let Some(hash) = stem.strip_prefix("json_") {
207 return is_hex(hash, TEE_HASH_LEN).then(|| format!("json_{hash}.log"));
208 }
209 if let Some(hash) = stem.strip_prefix("tbl_") {
210 return is_hex(hash, TEE_HASH_LEN).then(|| format!("tbl_{hash}.log"));
211 }
212 if let Some(hash) = stem.strip_prefix("yaml_") {
213 return is_hex(hash, TEE_HASH_LEN).then(|| format!("yaml_{hash}.log"));
214 }
215 is_hex(stem, TEE_HASH_LEN).then(|| format!("proxy_{stem}.log"))
216}
217
218fn is_shell_tee_name(name: &str) -> bool {
223 let Some(stem) = name.strip_suffix(".log") else {
224 return false;
225 };
226 if !stem
227 .bytes()
228 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
229 {
230 return false;
231 }
232 match stem.rsplit_once('_') {
233 Some((slug, hash)) => !slug.is_empty() && is_hex(hash, SHELL_TEE_HASH_LEN),
234 None => false,
235 }
236}
237
238pub(crate) fn resolve_tee(id: &str) -> Option<PathBuf> {
255 let name = Path::new(id)
256 .file_name()
257 .and_then(|n| n.to_str())
258 .unwrap_or(id);
259 let canon =
260 canonical_tee_name(name).or_else(|| is_shell_tee_name(name).then(|| name.to_string()))?;
261 let path = crate::core::paths::state_dir()
262 .ok()?
263 .join("tee")
264 .join(canon);
265 path.is_file().then_some(path)
266}
267
268pub(crate) fn inband_marker(handle: &str) -> Option<String> {
275 let name = Path::new(handle).file_name().and_then(|n| n.to_str())?;
276 let hash = name.strip_prefix("proxy_")?.strip_suffix(".log")?;
277 (hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
278 .then(|| format!("{EXPAND_OPEN}{hash}{EXPAND_CLOSE}"))
279}
280
281pub(crate) fn inband_locator(handle: &str) -> Option<String> {
289 crate::core::config::Config::load()
290 .proxy
291 .ccr_inband_enabled()
292 .then(|| inband_marker(handle))
293 .flatten()
294}
295
296fn recover(hash: &str) -> Option<String> {
299 if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
300 return None;
301 }
302 std::fs::read_to_string(resolve_tee(hash)?).ok()
303}
304
305pub(crate) const LITELLM_HASH_LEN: usize = 24;
309
310pub(crate) fn litellm_hash(content: &str) -> String {
317 blake3::hash(content.as_bytes()).to_hex()[..LITELLM_HASH_LEN].to_string()
318}
319
320pub(crate) fn retrieve_litellm(hash: &str) -> Option<String> {
326 if hash.len() != LITELLM_HASH_LEN
327 || !hash
328 .bytes()
329 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
330 {
331 return None;
332 }
333 std::fs::read_to_string(resolve_tee(&hash[..TEE_HASH_LEN])?).ok()
334}
335
336fn splice_str(s: &str) -> Option<String> {
349 if !s.contains(EXPAND_OPEN) {
350 return None;
351 }
352 let mut out = String::with_capacity(s.len());
353 let mut rest = s;
354 let mut changed = false;
355 while let Some(pos) = rest.find(EXPAND_OPEN) {
356 let after = &rest[pos + EXPAND_OPEN.len()..];
357 match after.find(EXPAND_CLOSE) {
358 Some(end) => {
359 let hash = &after[..end];
360 if let Some(original) = recover(hash) {
361 out.push_str(&rest[..pos]);
362 out.push_str(&original);
363 rest = &after[end + EXPAND_CLOSE.len_utf8()..];
364 changed = true;
365 } else {
366 out.push_str(&rest[..pos + EXPAND_OPEN.len()]);
369 rest = after;
370 }
371 }
372 None => break,
374 }
375 }
376 out.push_str(rest);
377 changed.then_some(out)
378}
379
380pub(crate) fn splice_inband_in_place(value: &mut Value) -> bool {
389 match value {
390 Value::String(s) => {
391 if let Some(spliced) = splice_str(s) {
392 *s = spliced;
393 true
394 } else {
395 false
396 }
397 }
398 Value::Array(items) => {
399 let mut changed = false;
400 for item in items {
401 changed |= splice_inband_in_place(item);
402 }
403 changed
404 }
405 Value::Object(map) => {
406 let mut changed = false;
407 for (_, v) in map.iter_mut() {
408 changed |= splice_inband_in_place(v);
409 }
410 changed
411 }
412 _ => false,
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 fn big(seed: &str) -> String {
421 format!("{seed}\n").repeat(40)
422 }
423
424 #[test]
425 fn handle_is_content_addressed_and_deterministic() {
426 let _lock = crate::core::data_dir::test_env_lock();
427 let content = big("file body line");
428 let a = persist(&content).expect("persisted");
429 let b = persist(&content).expect("persisted again");
430 assert_eq!(
431 a, b,
432 "same content must map to the same handle (cache-safe)"
433 );
434 assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
435
436 let other = persist(&big("different body")).expect("persisted");
437 assert_ne!(a, other, "different content must get a different handle");
438 }
439
440 #[test]
441 fn persisted_original_is_recoverable() {
442 let _lock = crate::core::data_dir::test_env_lock();
443 let content = big("recoverable verbatim line");
444 let handle = persist(&content).expect("persisted");
445 let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
446 assert!(
447 on_disk.contains("recoverable verbatim line"),
448 "the verbatim original must be retrievable from the handle"
449 );
450 }
451
452 #[test]
453 fn small_content_gets_no_handle() {
454 let _lock = crate::core::data_dir::test_env_lock();
455 assert!(
456 persist("too small to bother").is_none(),
457 "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
458 );
459 }
460
461 #[test]
462 #[allow(clippy::case_sensitive_file_extension_comparisons)]
463 fn conversation_handle_is_compact_and_deterministic() {
464 let _lock = crate::core::data_dir::test_env_lock();
465 let a = persist_conversation("{\"role\":\"user\",\"content\":\"hello\"}")
466 .expect("conversation handle");
467 let b = persist_conversation("{\"role\":\"user\",\"content\":\"hello\"}")
468 .expect("conversation handle");
469 assert_eq!(a, b);
470 assert!(a.starts_with("conv_") && a.ends_with(".log"));
471 }
472
473 #[test]
474 fn conversation_handle_resolves_short_messages() {
475 let _lock = crate::core::data_dir::test_env_lock();
476 let handle = persist_conversation("short conversation message").expect("handle");
477 assert!(resolve_tee(&handle).is_some());
478 }
479
480 #[test]
481 fn conversation_original_is_recoverable() {
482 let _lock = crate::core::data_dir::test_env_lock();
483 let original = "{\"role\":\"tool\",\"content\":\"recover me\"}";
484 let handle = persist_conversation(original).expect("handle");
485 let path = resolve_tee(&handle).expect("resolved handle");
486 assert_eq!(std::fs::read_to_string(path).unwrap(), original);
487 }
488
489 #[test]
490 fn resolve_tee_accepts_every_stub_form() {
491 let _lock = crate::core::data_dir::test_env_lock();
492 let content = big("resolvable tee body");
493 let handle = persist(&content).expect("persisted");
494 let hash = crate::core::hasher::hash_short(&content);
495
496 for form in [
499 handle.clone(),
500 format!("proxy_{hash}.log"),
501 format!("proxy_{hash}"),
502 hash.clone(),
503 ] {
504 let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
505 assert_eq!(
506 resolved.to_string_lossy(),
507 handle,
508 "form {form} -> {handle}"
509 );
510 }
511 }
512
513 #[test]
514 fn resolve_tee_rejects_nontee_and_traversal_ids() {
515 let _lock = crate::core::data_dir::test_env_lock();
516 assert!(resolve_tee("/etc/passwd").is_none());
519 assert!(resolve_tee("../../secret").is_none());
520 assert!(resolve_tee("proxy_nothex0000000.log").is_none());
521 assert!(resolve_tee("deadbeefdeadbeef").is_none());
523 }
524
525 #[test]
526 fn persist_json_is_distinct_prefix_and_resolvable() {
527 let _lock = crate::core::data_dir::test_env_lock();
528 let content = big("json crusher original");
529 let proxy = persist(&content).expect("proxy persisted");
530 let json = persist_json(&content).expect("json persisted");
531 assert!(
532 json.contains("json_"),
533 "json handle uses json_ prefix: {json}"
534 );
535 assert_ne!(
536 proxy, json,
537 "same content gets distinct files per producer prefix"
538 );
539
540 let hash = crate::core::hasher::hash_short(&content);
543 for form in [
544 json.clone(),
545 format!("json_{hash}.log"),
546 format!("json_{hash}"),
547 ] {
548 assert_eq!(
549 resolve_tee(&form)
550 .expect("json form resolves")
551 .to_string_lossy(),
552 json,
553 "json form {form} -> {json}"
554 );
555 }
556 }
557
558 #[test]
559 fn persist_tabular_is_distinct_prefix_and_resolvable() {
560 let _lock = crate::core::data_dir::test_env_lock();
561 let content = big("tabular crusher original");
562 let json = persist_json(&content).expect("json persisted");
563 let tbl = persist_tabular(&content).expect("tbl persisted");
564 assert!(
565 tbl.contains("tbl_"),
566 "tabular handle uses tbl_ prefix: {tbl}"
567 );
568 assert_ne!(
569 json, tbl,
570 "same content gets distinct files per producer prefix"
571 );
572
573 let hash = crate::core::hasher::hash_short(&content);
574 for form in [
575 tbl.clone(),
576 format!("tbl_{hash}.log"),
577 format!("tbl_{hash}"),
578 ] {
579 assert_eq!(
580 resolve_tee(&form)
581 .expect("tbl form resolves")
582 .to_string_lossy(),
583 tbl,
584 "tbl form {form} -> {tbl}"
585 );
586 }
587 }
588
589 #[test]
590 fn persist_yaml_is_distinct_prefix_and_resolvable() {
591 let _lock = crate::core::data_dir::test_env_lock();
592 let content = big("yaml crusher original");
593 let tbl = persist_tabular(&content).expect("tbl persisted");
594 let yaml = persist_yaml(&content).expect("yaml persisted");
595 assert!(
596 yaml.contains("yaml_"),
597 "yaml handle uses yaml_ prefix: {yaml}"
598 );
599 assert_ne!(
600 tbl, yaml,
601 "same content gets distinct files per producer prefix"
602 );
603
604 let hash = crate::core::hasher::hash_short(&content);
605 for form in [
606 yaml.clone(),
607 format!("yaml_{hash}.log"),
608 format!("yaml_{hash}"),
609 ] {
610 assert_eq!(
611 resolve_tee(&form)
612 .expect("yaml form resolves")
613 .to_string_lossy(),
614 yaml,
615 "yaml form {form} -> {yaml}"
616 );
617 }
618 }
619
620 #[test]
621 fn resolve_tee_resolves_shell_tee_with_underscored_slug() {
622 let _lock = crate::core::data_dir::test_env_lock();
623 let path = crate::shell::save_tee("gh api /repos/foo/bar", &big("api row"))
627 .expect("shell tee saved");
628 let name = std::path::Path::new(&path)
629 .file_name()
630 .and_then(|n| n.to_str())
631 .unwrap()
632 .to_string();
633 assert!(
634 is_shell_tee_name(&name),
635 "save_tee name must be recognized as a shell tee: {name}"
636 );
637 for form in [path.clone(), name] {
638 assert_eq!(
639 resolve_tee(&form)
640 .expect("shell tee form resolves")
641 .to_string_lossy(),
642 path,
643 "shell tee form -> {path}"
644 );
645 }
646 }
647
648 #[test]
649 fn resolve_tee_does_not_capture_reference_ids() {
650 let _lock = crate::core::data_dir::test_env_lock();
651 assert!(resolve_tee("ref_deadbeefcafef00d").is_none());
655 assert!(resolve_tee("0123456789abcdef").is_none());
657 }
658
659 #[test]
660 fn litellm_hash_is_24_lowercase_hex_and_extends_tee_hash() {
661 let content = big("gateway retrieval body");
662 let hash = litellm_hash(&content);
663 assert_eq!(hash.len(), LITELLM_HASH_LEN);
664 assert!(
665 hash.bytes()
666 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
667 "must match LiteLLM's [a-f0-9]{{24}} class: {hash}"
668 );
669 assert_eq!(hash[..16], crate::core::hasher::hash_short(&content));
672 assert_eq!(hash, litellm_hash(&content));
674 }
675
676 #[test]
677 fn retrieve_litellm_resolves_persisted_content() {
678 let _lock = crate::core::data_dir::test_env_lock();
679 let content = big("litellm retrievable original");
680 persist(&content).expect("persisted");
681 let recovered =
682 retrieve_litellm(&litellm_hash(&content)).expect("24-hex hash must resolve");
683 assert!(recovered.contains("litellm retrievable original"));
684 }
685
686 #[test]
687 fn retrieve_litellm_is_shape_locked_to_the_guardrail_regex() {
688 let _lock = crate::core::data_dir::test_env_lock();
689 let content = big("shape locked body");
690 persist(&content).expect("persisted");
691 let hash = litellm_hash(&content);
692
693 assert!(retrieve_litellm(&hash[..16]).is_none(), "16-hex rejected");
696 assert!(retrieve_litellm(&hash[..23]).is_none(), "23-hex rejected");
697 assert!(
698 retrieve_litellm(&format!("{hash}0")).is_none(),
699 "25-hex rejected"
700 );
701 assert!(
702 retrieve_litellm(&hash.to_uppercase()).is_none(),
703 "uppercase rejected (regex class is [a-f0-9])"
704 );
705 assert!(
706 retrieve_litellm("zzzzzzzzzzzzzzzzzzzzzzzz").is_none(),
707 "non-hex rejected"
708 );
709 assert!(retrieve_litellm("../../etc/passwd00000000").is_none());
711 assert!(retrieve_litellm("0123456789abcdef01234567").is_none());
713 }
714
715 #[test]
716 fn inband_marker_is_derived_from_handle() {
717 let _lock = crate::core::data_dir::test_env_lock();
718 let content = big("inband marker body");
719 let handle = persist(&content).expect("persisted");
720 let hash = crate::core::hasher::hash_short(&content);
721 assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
724 assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
726 }
727
728 #[test]
729 fn splice_replaces_marker_with_verbatim_original() {
730 let _lock = crate::core::data_dir::test_env_lock();
731 let content = big("the historical verbatim line");
732 let handle = persist(&content).expect("persisted");
733 let marker = inband_marker(&handle).expect("marker");
734
735 let mut doc = serde_json::json!({
736 "messages": [{ "role": "assistant", "content": format!("recall {marker} please") }]
737 });
738 assert!(splice_inband_in_place(&mut doc), "a marker must splice");
739 let spliced = doc["messages"][0]["content"].as_str().unwrap();
740 assert!(
741 spliced.contains("the historical verbatim line"),
742 "verbatim original must be spliced in: {spliced}"
743 );
744 assert!(
745 !spliced.contains("<lc_expand:"),
746 "the marker must be consumed, not left behind"
747 );
748 }
749
750 #[test]
751 fn splice_is_byte_identical_no_op_without_marker() {
752 let _lock = crate::core::data_dir::test_env_lock();
753 let mut doc = serde_json::json!({
754 "messages": [{ "role": "user", "content": "no marker here" }],
755 "system": "plain"
756 });
757 let before = doc.clone();
758 assert!(
759 !splice_inband_in_place(&mut doc),
760 "no marker → must report no change"
761 );
762 assert_eq!(
763 doc, before,
764 "marker-less body must stay byte-identical (cache-safe)"
765 );
766 }
767
768 #[test]
769 fn splice_keeps_unresolvable_marker_verbatim() {
770 let _lock = crate::core::data_dir::test_env_lock();
771 let mut doc = serde_json::json!({ "t": "before <lc_expand:deadbeefdeadbeef> after" });
774 assert!(!splice_inband_in_place(&mut doc));
775 assert_eq!(
776 doc["t"].as_str().unwrap(),
777 "before <lc_expand:deadbeefdeadbeef> after"
778 );
779 }
780
781 #[test]
782 fn splice_recurses_and_handles_multiple_markers() {
783 let _lock = crate::core::data_dir::test_env_lock();
784 let a = big("first recovered body");
785 let b = big("second recovered body");
786 let ma = inband_marker(&persist(&a).unwrap()).unwrap();
787 let mb = inband_marker(&persist(&b).unwrap()).unwrap();
788
789 let mut doc = serde_json::json!({
791 "contents": [
792 { "parts": [{ "text": format!("{ma} and {mb}") }] }
793 ]
794 });
795 assert!(splice_inband_in_place(&mut doc));
796 let text = doc["contents"][0]["parts"][0]["text"].as_str().unwrap();
797 assert!(text.contains("first recovered body"));
798 assert!(text.contains("second recovered body"));
799 assert!(!text.contains("<lc_expand:"));
800 }
801}