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
45fn tee_path(content: &str) -> Option<PathBuf> {
49 let dir = crate::core::paths::state_dir().ok()?.join("tee");
50 let hash = crate::core::hasher::hash_short(content);
51 Some(dir.join(format!("proxy_{hash}.log")))
52}
53
54fn maybe_cleanup(tee_dir: &Path) {
56 static LAST: AtomicU64 = AtomicU64::new(0);
57 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
58 return;
59 };
60 let now = now.as_secs();
61 let last = LAST.load(Ordering::Relaxed);
62 if now.saturating_sub(last) < CLEANUP_INTERVAL_SECS {
63 return;
64 }
65 if LAST
67 .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
68 .is_ok()
69 {
70 crate::shell::cleanup_old_tee_logs(tee_dir);
71 }
72}
73
74pub(crate) fn persist(content: &str) -> Option<String> {
82 if content.len() < MIN_TEE_BYTES {
83 return None;
84 }
85 let path = tee_path(content)?;
86 let handle = path.to_string_lossy().to_string();
87
88 if !path.exists() {
89 if let Some(dir) = path.parent()
90 && std::fs::create_dir_all(dir).is_ok()
91 {
92 maybe_cleanup(dir);
93 }
94 let masked = crate::core::redaction::redact_text(content);
97 let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
98 if std::fs::write(&path, redacted).is_ok() {
99 #[cfg(unix)]
100 {
101 use std::os::unix::fs::PermissionsExt;
102 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
103 }
104 }
105 }
106 Some(handle)
107}
108
109pub(crate) fn resolve_tee(id: &str) -> Option<PathBuf> {
118 let name = Path::new(id)
119 .file_name()
120 .and_then(|n| n.to_str())
121 .unwrap_or(id);
122 let hash = name.strip_prefix("proxy_").unwrap_or(name);
123 let hash = hash.strip_suffix(".log").unwrap_or(hash);
124 if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
125 return None;
126 }
127 let path = crate::core::paths::state_dir()
128 .ok()?
129 .join("tee")
130 .join(format!("proxy_{hash}.log"));
131 path.is_file().then_some(path)
132}
133
134pub(crate) fn inband_marker(handle: &str) -> Option<String> {
141 let name = Path::new(handle).file_name().and_then(|n| n.to_str())?;
142 let hash = name.strip_prefix("proxy_")?.strip_suffix(".log")?;
143 (hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
144 .then(|| format!("{EXPAND_OPEN}{hash}{EXPAND_CLOSE}"))
145}
146
147pub(crate) fn inband_locator(handle: &str) -> Option<String> {
155 crate::core::config::Config::load()
156 .proxy
157 .ccr_inband_enabled()
158 .then(|| inband_marker(handle))
159 .flatten()
160}
161
162fn recover(hash: &str) -> Option<String> {
165 if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
166 return None;
167 }
168 std::fs::read_to_string(resolve_tee(hash)?).ok()
169}
170
171fn splice_str(s: &str) -> Option<String> {
184 if !s.contains(EXPAND_OPEN) {
185 return None;
186 }
187 let mut out = String::with_capacity(s.len());
188 let mut rest = s;
189 let mut changed = false;
190 while let Some(pos) = rest.find(EXPAND_OPEN) {
191 let after = &rest[pos + EXPAND_OPEN.len()..];
192 match after.find(EXPAND_CLOSE) {
193 Some(end) => {
194 let hash = &after[..end];
195 if let Some(original) = recover(hash) {
196 out.push_str(&rest[..pos]);
197 out.push_str(&original);
198 rest = &after[end + EXPAND_CLOSE.len_utf8()..];
199 changed = true;
200 } else {
201 out.push_str(&rest[..pos + EXPAND_OPEN.len()]);
204 rest = after;
205 }
206 }
207 None => break,
209 }
210 }
211 out.push_str(rest);
212 changed.then_some(out)
213}
214
215pub(crate) fn splice_inband_in_place(value: &mut Value) -> bool {
224 match value {
225 Value::String(s) => {
226 if let Some(spliced) = splice_str(s) {
227 *s = spliced;
228 true
229 } else {
230 false
231 }
232 }
233 Value::Array(items) => {
234 let mut changed = false;
235 for item in items {
236 changed |= splice_inband_in_place(item);
237 }
238 changed
239 }
240 Value::Object(map) => {
241 let mut changed = false;
242 for (_, v) in map.iter_mut() {
243 changed |= splice_inband_in_place(v);
244 }
245 changed
246 }
247 _ => false,
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn big(seed: &str) -> String {
256 format!("{seed}\n").repeat(40)
257 }
258
259 #[test]
260 fn handle_is_content_addressed_and_deterministic() {
261 let _lock = crate::core::data_dir::test_env_lock();
262 let content = big("file body line");
263 let a = persist(&content).expect("persisted");
264 let b = persist(&content).expect("persisted again");
265 assert_eq!(
266 a, b,
267 "same content must map to the same handle (cache-safe)"
268 );
269 assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
270
271 let other = persist(&big("different body")).expect("persisted");
272 assert_ne!(a, other, "different content must get a different handle");
273 }
274
275 #[test]
276 fn persisted_original_is_recoverable() {
277 let _lock = crate::core::data_dir::test_env_lock();
278 let content = big("recoverable verbatim line");
279 let handle = persist(&content).expect("persisted");
280 let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
281 assert!(
282 on_disk.contains("recoverable verbatim line"),
283 "the verbatim original must be retrievable from the handle"
284 );
285 }
286
287 #[test]
288 fn small_content_gets_no_handle() {
289 let _lock = crate::core::data_dir::test_env_lock();
290 assert!(
291 persist("too small to bother").is_none(),
292 "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
293 );
294 }
295
296 #[test]
297 fn resolve_tee_accepts_every_stub_form() {
298 let _lock = crate::core::data_dir::test_env_lock();
299 let content = big("resolvable tee body");
300 let handle = persist(&content).expect("persisted");
301 let hash = crate::core::hasher::hash_short(&content);
302
303 for form in [
306 handle.clone(),
307 format!("proxy_{hash}.log"),
308 format!("proxy_{hash}"),
309 hash.clone(),
310 ] {
311 let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
312 assert_eq!(
313 resolved.to_string_lossy(),
314 handle,
315 "form {form} -> {handle}"
316 );
317 }
318 }
319
320 #[test]
321 fn resolve_tee_rejects_nontee_and_traversal_ids() {
322 let _lock = crate::core::data_dir::test_env_lock();
323 assert!(resolve_tee("/etc/passwd").is_none());
326 assert!(resolve_tee("../../secret").is_none());
327 assert!(resolve_tee("proxy_nothex0000000.log").is_none());
328 assert!(resolve_tee("deadbeefdeadbeef").is_none());
330 }
331
332 #[test]
333 fn inband_marker_is_derived_from_handle() {
334 let _lock = crate::core::data_dir::test_env_lock();
335 let content = big("inband marker body");
336 let handle = persist(&content).expect("persisted");
337 let hash = crate::core::hasher::hash_short(&content);
338 assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
341 assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
343 }
344
345 #[test]
346 fn splice_replaces_marker_with_verbatim_original() {
347 let _lock = crate::core::data_dir::test_env_lock();
348 let content = big("the historical verbatim line");
349 let handle = persist(&content).expect("persisted");
350 let marker = inband_marker(&handle).expect("marker");
351
352 let mut doc = serde_json::json!({
353 "messages": [{ "role": "assistant", "content": format!("recall {marker} please") }]
354 });
355 assert!(splice_inband_in_place(&mut doc), "a marker must splice");
356 let spliced = doc["messages"][0]["content"].as_str().unwrap();
357 assert!(
358 spliced.contains("the historical verbatim line"),
359 "verbatim original must be spliced in: {spliced}"
360 );
361 assert!(
362 !spliced.contains("<lc_expand:"),
363 "the marker must be consumed, not left behind"
364 );
365 }
366
367 #[test]
368 fn splice_is_byte_identical_no_op_without_marker() {
369 let _lock = crate::core::data_dir::test_env_lock();
370 let mut doc = serde_json::json!({
371 "messages": [{ "role": "user", "content": "no marker here" }],
372 "system": "plain"
373 });
374 let before = doc.clone();
375 assert!(
376 !splice_inband_in_place(&mut doc),
377 "no marker → must report no change"
378 );
379 assert_eq!(
380 doc, before,
381 "marker-less body must stay byte-identical (cache-safe)"
382 );
383 }
384
385 #[test]
386 fn splice_keeps_unresolvable_marker_verbatim() {
387 let _lock = crate::core::data_dir::test_env_lock();
388 let mut doc = serde_json::json!({ "t": "before <lc_expand:deadbeefdeadbeef> after" });
391 assert!(!splice_inband_in_place(&mut doc));
392 assert_eq!(
393 doc["t"].as_str().unwrap(),
394 "before <lc_expand:deadbeefdeadbeef> after"
395 );
396 }
397
398 #[test]
399 fn splice_recurses_and_handles_multiple_markers() {
400 let _lock = crate::core::data_dir::test_env_lock();
401 let a = big("first recovered body");
402 let b = big("second recovered body");
403 let ma = inband_marker(&persist(&a).unwrap()).unwrap();
404 let mb = inband_marker(&persist(&b).unwrap()).unwrap();
405
406 let mut doc = serde_json::json!({
408 "contents": [
409 { "parts": [{ "text": format!("{ma} and {mb}") }] }
410 ]
411 });
412 assert!(splice_inband_in_place(&mut doc));
413 let text = doc["contents"][0]["parts"][0]["text"].as_str().unwrap();
414 assert!(text.contains("first recovered body"));
415 assert!(text.contains("second recovered body"));
416 assert!(!text.contains("<lc_expand:"));
417 }
418}