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
259fn splice_str(s: &str) -> Option<String> {
272 if !s.contains(EXPAND_OPEN) {
273 return None;
274 }
275 let mut out = String::with_capacity(s.len());
276 let mut rest = s;
277 let mut changed = false;
278 while let Some(pos) = rest.find(EXPAND_OPEN) {
279 let after = &rest[pos + EXPAND_OPEN.len()..];
280 match after.find(EXPAND_CLOSE) {
281 Some(end) => {
282 let hash = &after[..end];
283 if let Some(original) = recover(hash) {
284 out.push_str(&rest[..pos]);
285 out.push_str(&original);
286 rest = &after[end + EXPAND_CLOSE.len_utf8()..];
287 changed = true;
288 } else {
289 out.push_str(&rest[..pos + EXPAND_OPEN.len()]);
292 rest = after;
293 }
294 }
295 None => break,
297 }
298 }
299 out.push_str(rest);
300 changed.then_some(out)
301}
302
303pub(crate) fn splice_inband_in_place(value: &mut Value) -> bool {
312 match value {
313 Value::String(s) => {
314 if let Some(spliced) = splice_str(s) {
315 *s = spliced;
316 true
317 } else {
318 false
319 }
320 }
321 Value::Array(items) => {
322 let mut changed = false;
323 for item in items {
324 changed |= splice_inband_in_place(item);
325 }
326 changed
327 }
328 Value::Object(map) => {
329 let mut changed = false;
330 for (_, v) in map.iter_mut() {
331 changed |= splice_inband_in_place(v);
332 }
333 changed
334 }
335 _ => false,
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 fn big(seed: &str) -> String {
344 format!("{seed}\n").repeat(40)
345 }
346
347 #[test]
348 fn handle_is_content_addressed_and_deterministic() {
349 let _lock = crate::core::data_dir::test_env_lock();
350 let content = big("file body line");
351 let a = persist(&content).expect("persisted");
352 let b = persist(&content).expect("persisted again");
353 assert_eq!(
354 a, b,
355 "same content must map to the same handle (cache-safe)"
356 );
357 assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
358
359 let other = persist(&big("different body")).expect("persisted");
360 assert_ne!(a, other, "different content must get a different handle");
361 }
362
363 #[test]
364 fn persisted_original_is_recoverable() {
365 let _lock = crate::core::data_dir::test_env_lock();
366 let content = big("recoverable verbatim line");
367 let handle = persist(&content).expect("persisted");
368 let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
369 assert!(
370 on_disk.contains("recoverable verbatim line"),
371 "the verbatim original must be retrievable from the handle"
372 );
373 }
374
375 #[test]
376 fn small_content_gets_no_handle() {
377 let _lock = crate::core::data_dir::test_env_lock();
378 assert!(
379 persist("too small to bother").is_none(),
380 "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
381 );
382 }
383
384 #[test]
385 fn resolve_tee_accepts_every_stub_form() {
386 let _lock = crate::core::data_dir::test_env_lock();
387 let content = big("resolvable tee body");
388 let handle = persist(&content).expect("persisted");
389 let hash = crate::core::hasher::hash_short(&content);
390
391 for form in [
394 handle.clone(),
395 format!("proxy_{hash}.log"),
396 format!("proxy_{hash}"),
397 hash.clone(),
398 ] {
399 let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
400 assert_eq!(
401 resolved.to_string_lossy(),
402 handle,
403 "form {form} -> {handle}"
404 );
405 }
406 }
407
408 #[test]
409 fn resolve_tee_rejects_nontee_and_traversal_ids() {
410 let _lock = crate::core::data_dir::test_env_lock();
411 assert!(resolve_tee("/etc/passwd").is_none());
414 assert!(resolve_tee("../../secret").is_none());
415 assert!(resolve_tee("proxy_nothex0000000.log").is_none());
416 assert!(resolve_tee("deadbeefdeadbeef").is_none());
418 }
419
420 #[test]
421 fn persist_json_is_distinct_prefix_and_resolvable() {
422 let _lock = crate::core::data_dir::test_env_lock();
423 let content = big("json crusher original");
424 let proxy = persist(&content).expect("proxy persisted");
425 let json = persist_json(&content).expect("json persisted");
426 assert!(
427 json.contains("json_"),
428 "json handle uses json_ prefix: {json}"
429 );
430 assert_ne!(
431 proxy, json,
432 "same content gets distinct files per producer prefix"
433 );
434
435 let hash = crate::core::hasher::hash_short(&content);
438 for form in [
439 json.clone(),
440 format!("json_{hash}.log"),
441 format!("json_{hash}"),
442 ] {
443 assert_eq!(
444 resolve_tee(&form)
445 .expect("json form resolves")
446 .to_string_lossy(),
447 json,
448 "json form {form} -> {json}"
449 );
450 }
451 }
452
453 #[test]
454 fn persist_tabular_is_distinct_prefix_and_resolvable() {
455 let _lock = crate::core::data_dir::test_env_lock();
456 let content = big("tabular crusher original");
457 let json = persist_json(&content).expect("json persisted");
458 let tbl = persist_tabular(&content).expect("tbl persisted");
459 assert!(
460 tbl.contains("tbl_"),
461 "tabular handle uses tbl_ prefix: {tbl}"
462 );
463 assert_ne!(
464 json, tbl,
465 "same content gets distinct files per producer prefix"
466 );
467
468 let hash = crate::core::hasher::hash_short(&content);
469 for form in [
470 tbl.clone(),
471 format!("tbl_{hash}.log"),
472 format!("tbl_{hash}"),
473 ] {
474 assert_eq!(
475 resolve_tee(&form)
476 .expect("tbl form resolves")
477 .to_string_lossy(),
478 tbl,
479 "tbl form {form} -> {tbl}"
480 );
481 }
482 }
483
484 #[test]
485 fn persist_yaml_is_distinct_prefix_and_resolvable() {
486 let _lock = crate::core::data_dir::test_env_lock();
487 let content = big("yaml crusher original");
488 let tbl = persist_tabular(&content).expect("tbl persisted");
489 let yaml = persist_yaml(&content).expect("yaml persisted");
490 assert!(
491 yaml.contains("yaml_"),
492 "yaml handle uses yaml_ prefix: {yaml}"
493 );
494 assert_ne!(
495 tbl, yaml,
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 yaml.clone(),
502 format!("yaml_{hash}.log"),
503 format!("yaml_{hash}"),
504 ] {
505 assert_eq!(
506 resolve_tee(&form)
507 .expect("yaml form resolves")
508 .to_string_lossy(),
509 yaml,
510 "yaml form {form} -> {yaml}"
511 );
512 }
513 }
514
515 #[test]
516 fn resolve_tee_resolves_shell_tee_with_underscored_slug() {
517 let _lock = crate::core::data_dir::test_env_lock();
518 let path = crate::shell::save_tee("gh api /repos/foo/bar", &big("api row"))
522 .expect("shell tee saved");
523 let name = std::path::Path::new(&path)
524 .file_name()
525 .and_then(|n| n.to_str())
526 .unwrap()
527 .to_string();
528 assert!(
529 is_shell_tee_name(&name),
530 "save_tee name must be recognized as a shell tee: {name}"
531 );
532 for form in [path.clone(), name] {
533 assert_eq!(
534 resolve_tee(&form)
535 .expect("shell tee form resolves")
536 .to_string_lossy(),
537 path,
538 "shell tee form -> {path}"
539 );
540 }
541 }
542
543 #[test]
544 fn resolve_tee_does_not_capture_reference_ids() {
545 let _lock = crate::core::data_dir::test_env_lock();
546 assert!(resolve_tee("ref_deadbeefcafef00d").is_none());
550 assert!(resolve_tee("0123456789abcdef").is_none());
552 }
553
554 #[test]
555 fn inband_marker_is_derived_from_handle() {
556 let _lock = crate::core::data_dir::test_env_lock();
557 let content = big("inband marker body");
558 let handle = persist(&content).expect("persisted");
559 let hash = crate::core::hasher::hash_short(&content);
560 assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
563 assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
565 }
566
567 #[test]
568 fn splice_replaces_marker_with_verbatim_original() {
569 let _lock = crate::core::data_dir::test_env_lock();
570 let content = big("the historical verbatim line");
571 let handle = persist(&content).expect("persisted");
572 let marker = inband_marker(&handle).expect("marker");
573
574 let mut doc = serde_json::json!({
575 "messages": [{ "role": "assistant", "content": format!("recall {marker} please") }]
576 });
577 assert!(splice_inband_in_place(&mut doc), "a marker must splice");
578 let spliced = doc["messages"][0]["content"].as_str().unwrap();
579 assert!(
580 spliced.contains("the historical verbatim line"),
581 "verbatim original must be spliced in: {spliced}"
582 );
583 assert!(
584 !spliced.contains("<lc_expand:"),
585 "the marker must be consumed, not left behind"
586 );
587 }
588
589 #[test]
590 fn splice_is_byte_identical_no_op_without_marker() {
591 let _lock = crate::core::data_dir::test_env_lock();
592 let mut doc = serde_json::json!({
593 "messages": [{ "role": "user", "content": "no marker here" }],
594 "system": "plain"
595 });
596 let before = doc.clone();
597 assert!(
598 !splice_inband_in_place(&mut doc),
599 "no marker → must report no change"
600 );
601 assert_eq!(
602 doc, before,
603 "marker-less body must stay byte-identical (cache-safe)"
604 );
605 }
606
607 #[test]
608 fn splice_keeps_unresolvable_marker_verbatim() {
609 let _lock = crate::core::data_dir::test_env_lock();
610 let mut doc = serde_json::json!({ "t": "before <lc_expand:deadbeefdeadbeef> after" });
613 assert!(!splice_inband_in_place(&mut doc));
614 assert_eq!(
615 doc["t"].as_str().unwrap(),
616 "before <lc_expand:deadbeefdeadbeef> after"
617 );
618 }
619
620 #[test]
621 fn splice_recurses_and_handles_multiple_markers() {
622 let _lock = crate::core::data_dir::test_env_lock();
623 let a = big("first recovered body");
624 let b = big("second recovered body");
625 let ma = inband_marker(&persist(&a).unwrap()).unwrap();
626 let mb = inband_marker(&persist(&b).unwrap()).unwrap();
627
628 let mut doc = serde_json::json!({
630 "contents": [
631 { "parts": [{ "text": format!("{ma} and {mb}") }] }
632 ]
633 });
634 assert!(splice_inband_in_place(&mut doc));
635 let text = doc["contents"][0]["parts"][0]["text"].as_str().unwrap();
636 assert!(text.contains("first recovered body"));
637 assert!(text.contains("second recovered body"));
638 assert!(!text.contains("<lc_expand:"));
639 }
640}