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
103fn persist_with(content: &str, prefix: &str) -> Option<String> {
104 if content.len() < MIN_TEE_BYTES {
105 return None;
106 }
107 let path = tee_path(content, prefix)?;
108 let handle = path.to_string_lossy().to_string();
109
110 if !path.exists() {
111 if let Some(dir) = path.parent()
112 && std::fs::create_dir_all(dir).is_ok()
113 {
114 maybe_cleanup(dir);
115 }
116 let masked = crate::core::redaction::redact_text(content);
119 let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
120 if std::fs::write(&path, redacted).is_ok() {
121 #[cfg(unix)]
122 {
123 use std::os::unix::fs::PermissionsExt;
124 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
125 }
126 }
127 }
128 Some(handle)
129}
130
131fn is_hex(s: &str, len: usize) -> bool {
132 s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit())
133}
134
135fn canonical_tee_name(name: &str) -> Option<String> {
139 let stem = name.strip_suffix(".log").unwrap_or(name);
140 if let Some(hash) = stem.strip_prefix("proxy_") {
141 return is_hex(hash, TEE_HASH_LEN).then(|| format!("proxy_{hash}.log"));
142 }
143 if let Some(hash) = stem.strip_prefix("json_") {
144 return is_hex(hash, TEE_HASH_LEN).then(|| format!("json_{hash}.log"));
145 }
146 is_hex(stem, TEE_HASH_LEN).then(|| format!("proxy_{stem}.log"))
147}
148
149fn is_shell_tee_name(name: &str) -> bool {
154 let Some(stem) = name.strip_suffix(".log") else {
155 return false;
156 };
157 if !stem
158 .bytes()
159 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
160 {
161 return false;
162 }
163 match stem.rsplit_once('_') {
164 Some((slug, hash)) => !slug.is_empty() && is_hex(hash, SHELL_TEE_HASH_LEN),
165 None => false,
166 }
167}
168
169pub(crate) fn resolve_tee(id: &str) -> Option<PathBuf> {
184 let name = Path::new(id)
185 .file_name()
186 .and_then(|n| n.to_str())
187 .unwrap_or(id);
188 let canon =
189 canonical_tee_name(name).or_else(|| is_shell_tee_name(name).then(|| name.to_string()))?;
190 let path = crate::core::paths::state_dir()
191 .ok()?
192 .join("tee")
193 .join(canon);
194 path.is_file().then_some(path)
195}
196
197pub(crate) fn inband_marker(handle: &str) -> Option<String> {
204 let name = Path::new(handle).file_name().and_then(|n| n.to_str())?;
205 let hash = name.strip_prefix("proxy_")?.strip_suffix(".log")?;
206 (hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
207 .then(|| format!("{EXPAND_OPEN}{hash}{EXPAND_CLOSE}"))
208}
209
210pub(crate) fn inband_locator(handle: &str) -> Option<String> {
218 crate::core::config::Config::load()
219 .proxy
220 .ccr_inband_enabled()
221 .then(|| inband_marker(handle))
222 .flatten()
223}
224
225fn recover(hash: &str) -> Option<String> {
228 if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
229 return None;
230 }
231 std::fs::read_to_string(resolve_tee(hash)?).ok()
232}
233
234fn splice_str(s: &str) -> Option<String> {
247 if !s.contains(EXPAND_OPEN) {
248 return None;
249 }
250 let mut out = String::with_capacity(s.len());
251 let mut rest = s;
252 let mut changed = false;
253 while let Some(pos) = rest.find(EXPAND_OPEN) {
254 let after = &rest[pos + EXPAND_OPEN.len()..];
255 match after.find(EXPAND_CLOSE) {
256 Some(end) => {
257 let hash = &after[..end];
258 if let Some(original) = recover(hash) {
259 out.push_str(&rest[..pos]);
260 out.push_str(&original);
261 rest = &after[end + EXPAND_CLOSE.len_utf8()..];
262 changed = true;
263 } else {
264 out.push_str(&rest[..pos + EXPAND_OPEN.len()]);
267 rest = after;
268 }
269 }
270 None => break,
272 }
273 }
274 out.push_str(rest);
275 changed.then_some(out)
276}
277
278pub(crate) fn splice_inband_in_place(value: &mut Value) -> bool {
287 match value {
288 Value::String(s) => {
289 if let Some(spliced) = splice_str(s) {
290 *s = spliced;
291 true
292 } else {
293 false
294 }
295 }
296 Value::Array(items) => {
297 let mut changed = false;
298 for item in items {
299 changed |= splice_inband_in_place(item);
300 }
301 changed
302 }
303 Value::Object(map) => {
304 let mut changed = false;
305 for (_, v) in map.iter_mut() {
306 changed |= splice_inband_in_place(v);
307 }
308 changed
309 }
310 _ => false,
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn big(seed: &str) -> String {
319 format!("{seed}\n").repeat(40)
320 }
321
322 #[test]
323 fn handle_is_content_addressed_and_deterministic() {
324 let _lock = crate::core::data_dir::test_env_lock();
325 let content = big("file body line");
326 let a = persist(&content).expect("persisted");
327 let b = persist(&content).expect("persisted again");
328 assert_eq!(
329 a, b,
330 "same content must map to the same handle (cache-safe)"
331 );
332 assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
333
334 let other = persist(&big("different body")).expect("persisted");
335 assert_ne!(a, other, "different content must get a different handle");
336 }
337
338 #[test]
339 fn persisted_original_is_recoverable() {
340 let _lock = crate::core::data_dir::test_env_lock();
341 let content = big("recoverable verbatim line");
342 let handle = persist(&content).expect("persisted");
343 let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
344 assert!(
345 on_disk.contains("recoverable verbatim line"),
346 "the verbatim original must be retrievable from the handle"
347 );
348 }
349
350 #[test]
351 fn small_content_gets_no_handle() {
352 let _lock = crate::core::data_dir::test_env_lock();
353 assert!(
354 persist("too small to bother").is_none(),
355 "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
356 );
357 }
358
359 #[test]
360 fn resolve_tee_accepts_every_stub_form() {
361 let _lock = crate::core::data_dir::test_env_lock();
362 let content = big("resolvable tee body");
363 let handle = persist(&content).expect("persisted");
364 let hash = crate::core::hasher::hash_short(&content);
365
366 for form in [
369 handle.clone(),
370 format!("proxy_{hash}.log"),
371 format!("proxy_{hash}"),
372 hash.clone(),
373 ] {
374 let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
375 assert_eq!(
376 resolved.to_string_lossy(),
377 handle,
378 "form {form} -> {handle}"
379 );
380 }
381 }
382
383 #[test]
384 fn resolve_tee_rejects_nontee_and_traversal_ids() {
385 let _lock = crate::core::data_dir::test_env_lock();
386 assert!(resolve_tee("/etc/passwd").is_none());
389 assert!(resolve_tee("../../secret").is_none());
390 assert!(resolve_tee("proxy_nothex0000000.log").is_none());
391 assert!(resolve_tee("deadbeefdeadbeef").is_none());
393 }
394
395 #[test]
396 fn persist_json_is_distinct_prefix_and_resolvable() {
397 let _lock = crate::core::data_dir::test_env_lock();
398 let content = big("json crusher original");
399 let proxy = persist(&content).expect("proxy persisted");
400 let json = persist_json(&content).expect("json persisted");
401 assert!(
402 json.contains("json_"),
403 "json handle uses json_ prefix: {json}"
404 );
405 assert_ne!(
406 proxy, json,
407 "same content gets distinct files per producer prefix"
408 );
409
410 let hash = crate::core::hasher::hash_short(&content);
413 for form in [
414 json.clone(),
415 format!("json_{hash}.log"),
416 format!("json_{hash}"),
417 ] {
418 assert_eq!(
419 resolve_tee(&form)
420 .expect("json form resolves")
421 .to_string_lossy(),
422 json,
423 "json form {form} -> {json}"
424 );
425 }
426 }
427
428 #[test]
429 fn resolve_tee_resolves_shell_tee_with_underscored_slug() {
430 let _lock = crate::core::data_dir::test_env_lock();
431 let path = crate::shell::save_tee("gh api /repos/foo/bar", &big("api row"))
435 .expect("shell tee saved");
436 let name = std::path::Path::new(&path)
437 .file_name()
438 .and_then(|n| n.to_str())
439 .unwrap()
440 .to_string();
441 assert!(
442 is_shell_tee_name(&name),
443 "save_tee name must be recognized as a shell tee: {name}"
444 );
445 for form in [path.clone(), name] {
446 assert_eq!(
447 resolve_tee(&form)
448 .expect("shell tee form resolves")
449 .to_string_lossy(),
450 path,
451 "shell tee form -> {path}"
452 );
453 }
454 }
455
456 #[test]
457 fn resolve_tee_does_not_capture_reference_ids() {
458 let _lock = crate::core::data_dir::test_env_lock();
459 assert!(resolve_tee("ref_deadbeefcafef00d").is_none());
463 assert!(resolve_tee("0123456789abcdef").is_none());
465 }
466
467 #[test]
468 fn inband_marker_is_derived_from_handle() {
469 let _lock = crate::core::data_dir::test_env_lock();
470 let content = big("inband marker body");
471 let handle = persist(&content).expect("persisted");
472 let hash = crate::core::hasher::hash_short(&content);
473 assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
476 assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
478 }
479
480 #[test]
481 fn splice_replaces_marker_with_verbatim_original() {
482 let _lock = crate::core::data_dir::test_env_lock();
483 let content = big("the historical verbatim line");
484 let handle = persist(&content).expect("persisted");
485 let marker = inband_marker(&handle).expect("marker");
486
487 let mut doc = serde_json::json!({
488 "messages": [{ "role": "assistant", "content": format!("recall {marker} please") }]
489 });
490 assert!(splice_inband_in_place(&mut doc), "a marker must splice");
491 let spliced = doc["messages"][0]["content"].as_str().unwrap();
492 assert!(
493 spliced.contains("the historical verbatim line"),
494 "verbatim original must be spliced in: {spliced}"
495 );
496 assert!(
497 !spliced.contains("<lc_expand:"),
498 "the marker must be consumed, not left behind"
499 );
500 }
501
502 #[test]
503 fn splice_is_byte_identical_no_op_without_marker() {
504 let _lock = crate::core::data_dir::test_env_lock();
505 let mut doc = serde_json::json!({
506 "messages": [{ "role": "user", "content": "no marker here" }],
507 "system": "plain"
508 });
509 let before = doc.clone();
510 assert!(
511 !splice_inband_in_place(&mut doc),
512 "no marker → must report no change"
513 );
514 assert_eq!(
515 doc, before,
516 "marker-less body must stay byte-identical (cache-safe)"
517 );
518 }
519
520 #[test]
521 fn splice_keeps_unresolvable_marker_verbatim() {
522 let _lock = crate::core::data_dir::test_env_lock();
523 let mut doc = serde_json::json!({ "t": "before <lc_expand:deadbeefdeadbeef> after" });
526 assert!(!splice_inband_in_place(&mut doc));
527 assert_eq!(
528 doc["t"].as_str().unwrap(),
529 "before <lc_expand:deadbeefdeadbeef> after"
530 );
531 }
532
533 #[test]
534 fn splice_recurses_and_handles_multiple_markers() {
535 let _lock = crate::core::data_dir::test_env_lock();
536 let a = big("first recovered body");
537 let b = big("second recovered body");
538 let ma = inband_marker(&persist(&a).unwrap()).unwrap();
539 let mb = inband_marker(&persist(&b).unwrap()).unwrap();
540
541 let mut doc = serde_json::json!({
543 "contents": [
544 { "parts": [{ "text": format!("{ma} and {mb}") }] }
545 ]
546 });
547 assert!(splice_inband_in_place(&mut doc));
548 let text = doc["contents"][0]["parts"][0]["text"].as_str().unwrap();
549 assert!(text.contains("first recovered body"));
550 assert!(text.contains("second recovered body"));
551 assert!(!text.contains("<lc_expand:"));
552 }
553}