Skip to main content

lean_ctx/core/
ccr.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Content-addressed recovery (CCR) for the proxy's lossy rewrites (#482).
3//!
4//! When the proxy prunes an old `tool_result` from conversation history, the
5//! lossy stub used to say *"re-read the file"* — which is stale-unsafe by
6//! construction: in an agent session files are edited or deleted between turns,
7//! so a re-read returns the *current* bytes (or fails), not the historical
8//! version the conversation actually showed. The model could then silently
9//! reason about the wrong content.
10//!
11//! CCR fixes this by persisting the **verbatim original** to the shared,
12//! content-addressed tee store (`{state}/tee/`, reused from the shell path) and
13//! embedding a **retrieval handle** — the absolute path of that file — in the
14//! stub. These legacy copies have no source authority and are only available
15//! without an active policy. Protected file recovery uses source-bound archives.
16//!
17//! ## Cache-safety (#448)
18//! The handle is the file path, and the path is a pure function of the content
19//! hash ([`crate::core::hasher::hash_short`]). For a fixed pruned message the
20//! handle is therefore byte-identical on every later turn, so the provider
21//! prompt-cache prefix is preserved for available copies. Failed or unauthorized
22//! persistence returns no handle; a deterministic path is not proof of recovery.
23//!
24//! The proxy splices in-band markers back into requests (`proxy::ccr`); the
25//! store, its resolver and its bounded read live here, below every caller.
26
27use std::path::{Path, PathBuf};
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::time::{SystemTime, UNIX_EPOCH};
30
31/// Opening delimiter of an in-band retrieval marker: `<lc_expand:HASH>` (#493).
32pub const EXPAND_OPEN: &str = "<lc_expand:";
33/// Closing delimiter of an in-band retrieval marker.
34pub const EXPAND_CLOSE: char = '>';
35
36/// Originals smaller than this are not worth a tee file + handle; the caller
37/// keeps its plain stub. Matches the spirit of the prune length thresholds.
38pub const MIN_TEE_BYTES: usize = 512;
39
40/// Throttle the O(dir) TTL cleanup so the prune hot path does at most one
41/// directory scan per this interval.
42const CLEANUP_INTERVAL_SECS: u64 = 600;
43
44/// Length of the content hash in a proxy/json tee name ([`hash_short`]).
45const TEE_HASH_LEN: usize = 16;
46/// Length of the command hash in a shell tee name (`shell::redact::save_tee`).
47const SHELL_TEE_HASH_LEN: usize = 8;
48
49/// Deterministic tee path for `content`:
50/// `{state}/tee/{prefix}_{blake3(content)[..16]}.log`. Pure (no I/O) so a stub
51/// embedding it stays byte-stable regardless of filesystem state. `prefix`
52/// segregates the producer (`proxy` for history-prune / live-compression stubs,
53/// `conv` for conversation history messages, `json` for the JSON crusher's
54/// lossy originals, #936) yet keeps one shared
55/// store + one resolver ([`resolve_tee`]).
56fn 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
62/// Run the shared 24h TTL cleanup at most once per [`CLEANUP_INTERVAL_SECS`].
63fn 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    // Only one thread wins the slot; the rest skip until the next interval.
74    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
82/// Persist `content` verbatim (best-effort, secret-redacted) to the
83/// content-addressed tee store and return its retrieval handle (the absolute
84/// path). Returns `None` for protected originless data, invalid/oversized input
85/// or persistence failure. Re-persisting identical content keeps the same path;
86/// callers must preserve useful fresh output when no recovery handle is available.
87pub fn persist(content: &str) -> Option<String> {
88    persist_with(content, "proxy")
89}
90
91/// Like [`persist`], but a content-addressed copy that is already on disk is
92/// named as it is: no rewrite, no new relevance registration. A copy the TTL
93/// cleanup removed is written again (self-healing). Under protection nothing
94/// originless is named, existing or not.
95pub fn persist_once(content: &str) -> Option<String> {
96    if content.len() < MIN_TEE_BYTES
97        || crate::core::context_admission::stored::source_authority_required()
98    {
99        return None;
100    }
101    let path = tee_path(content, "proxy")?;
102    if std::fs::symlink_metadata(&path).is_ok_and(|meta| meta.file_type().is_file()) {
103        return Some(path.to_string_lossy().to_string());
104    }
105    persist(content)
106}
107
108/// Persist a dropped conversation message under a compact `conv_` handle.
109///
110/// Conversation messages may be short, so unlike tool-output tees this path has
111/// no minimum-size gate. The returned basename is sufficient for `ctx_expand` and
112/// avoids putting machine-specific absolute paths into provider prompts.
113#[cfg_attr(not(test), allow(dead_code))] // conversation tee handle for ctx_expand recovery
114pub fn persist_conversation(content: &str) -> Option<String> {
115    let path = persist_with_min(content, "conv", 1)?;
116    Path::new(&path)
117        .file_name()
118        .and_then(|name| name.to_str())
119        .map(str::to_owned)
120}
121
122/// Persist a JSON crusher's verbatim original (#936) under the `json_` prefix and
123/// return its `{state}/tee/json_{hash}.log` handle. Used by the lossy crush stage
124/// so a dropped column is always recoverable out-of-band via [`resolve_tee`] /
125/// `ctx_expand`, never reconstructed from the (lossy) text. Shares the
126/// content-address and best-effort write contract of [`persist`], so the embedded
127/// handle stays deterministic (cache-safe).
128pub fn persist_json(content: &str) -> Option<String> {
129    persist_with(content, "json")
130}
131
132/// Persist a tabular (CSV/TSV) crusher's verbatim original (#982) under the
133/// `tbl_` prefix and return its `{state}/tee/tbl_{hash}.log` handle. Used by the
134/// lossy column-drop stage so a dropped column is always recoverable out-of-band
135/// via [`resolve_tee`] / `ctx_expand`, never reconstructed from the (lossy) text.
136/// Shares the content-address and best-effort write contract of [`persist`].
137pub fn persist_tabular(content: &str) -> Option<String> {
138    persist_with(content, "tbl")
139}
140
141/// Persist a YAML crusher's verbatim original (#985) under the `yaml_` prefix and
142/// return its `{state}/tee/yaml_{hash}.log` handle. Used by the lossy column-drop
143/// stage so a dropped column is always recoverable out-of-band via [`resolve_tee`]
144/// / `ctx_expand`, never reconstructed from the (lossy) text. Shares the
145/// content-address and best-effort write contract of [`persist`].
146pub fn persist_yaml(content: &str) -> Option<String> {
147    persist_with(content, "yaml")
148}
149
150/// Persist an HTML page's verbatim original (#1124) under the `html_` prefix
151/// and return its `{state}/tee/html_{hash}.log` handle. The extracted markdown
152/// is the compressed form; the full HTML is recoverable via [`resolve_tee`] /
153/// `ctx_expand`. Shares the content-address and best-effort write contract of
154/// [`persist`].
155pub fn persist_html(content: &str) -> Option<String> {
156    persist_with(content, "html")
157}
158
159/// Persist the full definition of a tool whose description the proxy shortened
160/// (K26) under the `tool_` prefix. Returns the basename, like
161/// [`persist_conversation`]: it resolves through `ctx_expand` and keeps a
162/// machine-specific path out of the provider prompt, so the shortened tool
163/// list is the same bytes on every machine and turn.
164#[cfg(feature = "http-server")]
165pub fn persist_tool_definition(content: &str) -> Option<String> {
166    let path = persist_with_min(content, "tool", 1)?;
167    Path::new(&path)
168        .file_name()
169        .and_then(|name| name.to_str())
170        .map(str::to_owned)
171}
172
173fn persist_with(content: &str, prefix: &str) -> Option<String> {
174    persist_with_min(content, prefix, MIN_TEE_BYTES)
175}
176
177fn persist_with_min(content: &str, prefix: &str, min_bytes: usize) -> Option<String> {
178    debug_assert!(
179        TEE_PREFIXES.contains(&prefix),
180        "tee prefix {prefix} must be resolvable (add it to TEE_PREFIXES)"
181    );
182    if content.len() < min_bytes {
183        return None;
184    }
185    let path = tee_path(content, prefix)?;
186    let handle = path.to_string_lossy().to_string();
187    storage::write(&path, content)?;
188    if let Some(dir) = path.parent() {
189        maybe_cleanup(dir);
190    }
191    if path.is_file() {
192        let source_tool = match prefix {
193            "json" | "tbl" | "yaml" => "ctx_read",
194            "html" => "ctx_shell",
195            _ => "proxy",
196        };
197        crate::core::relevance_tracker::register_compressed(
198            handle.clone(),
199            content,
200            source_tool,
201            crate::core::tokens::count_tokens(content),
202            0,
203        );
204    }
205    Some(handle)
206}
207
208fn is_hex(s: &str, len: usize) -> bool {
209    s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit())
210}
211
212/// Every prefix [`persist_with_min`] writes. The resolver is driven by the same
213/// table, so a new producer can never mint a handle `ctx_expand` rejects
214/// (#1911: `html_` was persisted but unresolvable).
215const TEE_PREFIXES: &[&str] = &["proxy", "conv", "json", "tbl", "yaml", "html", "tool"];
216
217/// Canonical `{prefix}_{16hex}.log` name for a prefixed ([`TEE_PREFIXES`]) or
218/// bare-hash id, or `None`. A bare 16-hex id defaults to the `proxy_` store
219/// (back-compat: that is the only form pre-#936 stubs carry).
220fn canonical_tee_name(name: &str) -> Option<String> {
221    let stem = name.strip_suffix(".log").unwrap_or(name);
222    for prefix in TEE_PREFIXES {
223        if let Some(hash) = stem
224            .strip_prefix(prefix)
225            .and_then(|rest| rest.strip_prefix('_'))
226        {
227            return is_hex(hash, TEE_HASH_LEN).then(|| format!("{prefix}_{hash}.log"));
228        }
229    }
230    is_hex(stem, TEE_HASH_LEN).then(|| format!("proxy_{stem}.log"))
231}
232
233/// True for a shell tee basename `<slug>_<8hex>.log` (`shell::redact::save_tee`):
234/// ends in `.log`, the whole basename is safe (`[A-Za-z0-9_-]`), and the **last**
235/// `_`-segment is exactly 8 hex. The slug itself may contain `_`, so the hash is
236/// matched as the suffix — never the first segment (the documented parsing trap).
237fn is_shell_tee_name(name: &str) -> bool {
238    let Some(stem) = name.strip_suffix(".log") else {
239        return false;
240    };
241    if !stem
242        .bytes()
243        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
244    {
245        return false;
246    }
247    match stem.rsplit_once('_') {
248        Some((slug, hash)) => !slug.is_empty() && is_hex(hash, SHELL_TEE_HASH_LEN),
249        None => false,
250    }
251}
252
253/// Why a canonical tee handle could not be resolved.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum TeeResolveError {
256    Malformed,
257    Missing,
258    Refused(&'static str),
259}
260
261/// Resolve a retrieval ID through the same canonical store path used by
262/// `ctx_expand`, while distinguishing malformed IDs and policy refusals.
263pub fn resolve_tee_checked(id: &str) -> Result<PathBuf, TeeResolveError> {
264    let name = Path::new(id)
265        .file_name()
266        .and_then(|n| n.to_str())
267        .unwrap_or(id);
268    let canon = canonical_tee_name(name)
269        .or_else(|| is_shell_tee_name(name).then(|| name.to_string()))
270        .ok_or(TeeResolveError::Malformed)?;
271    let state_dir = crate::core::paths::state_dir().map_err(|_| TeeResolveError::Missing)?;
272    let tee_dir = state_dir.join("tee");
273    let path = tee_dir.join(canon);
274    // The tee store itself must be a real directory: a `tee` link to another
275    // store (archives, references) would let a tee-shaped name read that store.
276    if std::fs::symlink_metadata(&tee_dir).is_ok_and(|m| m.file_type().is_symlink()) {
277        return Err(TeeResolveError::Refused(
278            "tee store is a symlink to another location",
279        ));
280    }
281
282    let link_metadata = std::fs::symlink_metadata(&path).map_err(|error| {
283        if error.kind() == std::io::ErrorKind::PermissionDenied {
284            TeeResolveError::Refused("tee metadata read refused by filesystem policy")
285        } else {
286            TeeResolveError::Missing
287        }
288    })?;
289    if link_metadata.file_type().is_symlink() {
290        return Err(TeeResolveError::Refused(
291            "tee handles cannot resolve through symlinks",
292        ));
293    }
294    if !link_metadata.is_file() {
295        return Err(TeeResolveError::Missing);
296    }
297
298    let canonical_state = std::fs::canonicalize(&state_dir).map_err(|error| {
299        if error.kind() == std::io::ErrorKind::PermissionDenied {
300            TeeResolveError::Refused("tee store access refused by filesystem policy")
301        } else {
302            TeeResolveError::Missing
303        }
304    })?;
305    let canonical_tee = std::fs::canonicalize(&tee_dir).map_err(|error| {
306        if error.kind() == std::io::ErrorKind::PermissionDenied {
307            TeeResolveError::Refused("tee store access refused by filesystem policy")
308        } else {
309            TeeResolveError::Missing
310        }
311    })?;
312    if canonical_tee != canonical_state.join("tee") {
313        return Err(TeeResolveError::Refused(
314            "tee store resolves outside the state directory",
315        ));
316    }
317
318    let canonical_file = std::fs::canonicalize(&path).map_err(|error| {
319        if error.kind() == std::io::ErrorKind::PermissionDenied {
320            TeeResolveError::Refused("tee read refused by filesystem policy")
321        } else {
322            TeeResolveError::Missing
323        }
324    })?;
325    if !canonical_file.starts_with(&canonical_tee) {
326        return Err(TeeResolveError::Refused(
327            "tee handle resolves outside the tee store",
328        ));
329    }
330    if !canonical_file.is_file() {
331        return Err(TeeResolveError::Missing);
332    }
333    Ok(path)
334}
335
336/// Back-compatible resolver used by `ctx_expand`.
337pub fn resolve_tee(id: &str) -> Option<PathBuf> {
338    resolve_tee_checked(id).ok()
339}
340
341/// The in-band retrieval marker `<lc_expand:HASH>` for a CCR `handle` (#493).
342///
343/// `HASH` is the content hash already embedded in the tee handle, so a model can
344/// echo the marker verbatim and the proxy can recover the original via
345/// [`resolve_tee`] on the next turn. Pure (no I/O, no config) so it is trivially
346/// testable; returns `None` for a handle that is not a canonical tee path.
347pub fn inband_marker(handle: &str) -> Option<String> {
348    let name = Path::new(handle).file_name().and_then(|n| n.to_str())?;
349    let hash = name.strip_prefix("proxy_")?.strip_suffix(".log")?;
350    (hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
351        .then(|| format!("{EXPAND_OPEN}{hash}{EXPAND_CLOSE}"))
352}
353
354/// The in-band marker for `handle` **only when in-band CCR is enabled** (#493),
355/// else `None`. Stub sites use this to advertise an echo-able `<lc_expand:HASH>`
356/// solely in in-band mode: a normal (shared-filesystem) deployment keeps its
357/// path handle, so the model never sees a marker the proxy would not splice.
358///
359/// Reads the (process-cached) config; the surrounding stub path already does
360/// per-message tee I/O via [`persist`], so this adds no new I/O class.
361pub fn inband_locator(handle: &str) -> Option<String> {
362    crate::core::config::Config::load()
363        .proxy
364        .ccr_inband_enabled()
365        .then(|| inband_marker(handle))
366        .flatten()
367}
368
369/// Read a tee file that [`resolve_tee`] returned. One read path for every
370/// caller: bounded, no-follow and bound to the checked file, re-checked
371/// against the current policy and re-admitted by the context gateway.
372pub fn read_tee_file(path: &Path) -> Option<String> {
373    read_checked_tee(path)
374}
375
376/// Resolve and read a tee handle in one step.
377pub fn read_tee(id: &str) -> Option<String> {
378    read_tee_file(&resolve_tee(id)?)
379}
380
381pub use storage::{
382    read as read_checked_tee, read_detailed as read_tee_detailed, write as write_tee,
383};
384
385mod storage;
386
387/// Length of a LiteLLM gateway retrieval hash (#702): LiteLLM's headroom
388/// guardrail scans compressed text with `hash=([a-f0-9]{24})`
389/// (BerriAI/litellm#31681), so the marker must carry exactly 24 lowercase hex.
390pub const LITELLM_HASH_LEN: usize = 24;
391
392/// The 24-hex gateway retrieval hash for `content` (#702): the first 24 chars
393/// of the full blake3 hex. A strict extension of the 16-hex tee name
394/// ([`crate::core::hasher::hash_short`] is the same digest truncated to 16),
395/// so `hash[..16]` resolves the tee file while the extra 8 chars keep the
396/// marker collision-resistant on the gateway side. Pure — the marker embedding
397/// it stays byte-stable per content (#498).
398pub fn litellm_hash(content: &str) -> String {
399    blake3::hash(content.as_bytes()).to_hex()[..LITELLM_HASH_LEN].to_string()
400}
401
402/// Resolve a LiteLLM `hash=<24hex>` retrieval id (#702) back to the verbatim
403/// original in the tee store, for `GET /v1/retrieve/{hash}`. Shape-locked to
404/// LiteLLM's own regex (24 lowercase hex — uppercase or any other length is
405/// rejected, never coerced) so only a string the guardrail could actually have
406/// captured resolves; the first 16 chars are the `proxy_` tee content-address.
407#[cfg(feature = "http-server")]
408pub fn retrieve_litellm(hash: &str) -> Option<String> {
409    if hash.len() != LITELLM_HASH_LEN
410        || !hash
411            .bytes()
412            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
413    {
414        return None;
415    }
416    read_tee(&hash[..TEE_HASH_LEN])
417}
418
419#[cfg(any(test, feature = "test-support"))]
420/// Inputs shared by the CCR tests here and in the proxy.
421pub mod test_support {
422    /// `seed` repeated past the tee threshold.
423    pub fn big(seed: &str) -> String {
424        format!("{seed}\n").repeat(40)
425    }
426}
427
428#[cfg(test)]
429pub(crate) mod tests {
430    use super::*;
431
432    pub(crate) use super::test_support::big;
433
434    fn write_gateway_mode(mode: &str) {
435        let config = crate::core::paths::config_dir_read_only().expect("config directory");
436        std::fs::create_dir_all(&config).expect("config directory");
437        std::fs::write(
438            config.join("config.toml"),
439            format!("[context_gateway]\nmode = \"{mode}\"\n"),
440        )
441        .expect("gateway config");
442    }
443
444    /// A stored tee log has no source authority. Governed mode protects
445    /// without any policy pack: the log written in developer mode is then
446    /// neither handed back nor is a new one written, and the refusal says
447    /// nothing about the content. Developer mode keeps its recovery.
448    #[test]
449    fn governed_mode_without_a_pack_neither_serves_nor_writes_an_originless_tee() {
450        let _data = crate::core::data_dir::isolated_data_dir();
451        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
452        write_gateway_mode("developer");
453        let content = big("deploy log line with the marker tee-governed-regression");
454        let handle = persist(&content).expect("developer mode keeps a recovery copy");
455        let path = Path::new(&handle);
456        assert!(
457            read_tee_detailed(path)
458                .expect("developer mode serves it")
459                .contains("tee-governed-regression")
460        );
461
462        write_gateway_mode("governed");
463        let refusal = read_tee_detailed(path).expect_err("governed mode must not serve it");
464        assert!(refusal.contains("no source authority"), "{refusal}");
465        assert!(!refusal.contains("tee-governed-regression"));
466        assert!(read_tee(&handle).is_none());
467        assert!(
468            persist(&big("another originless log line")).is_none(),
469            "governed mode writes no originless copy"
470        );
471
472        write_gateway_mode("developer");
473        assert!(read_tee_detailed(path).is_ok(), "developer mode unchanged");
474    }
475
476    #[test]
477    fn handle_is_content_addressed_and_deterministic() {
478        let _lock = crate::core::data_dir::test_env_lock();
479        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
480        let content = big("file body line");
481        let a = persist(&content).expect("persisted");
482        let b = persist(&content).expect("persisted again");
483        assert_eq!(
484            a, b,
485            "same content must map to the same handle (cache-safe)"
486        );
487        assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
488
489        let other = persist(&big("different body")).expect("persisted");
490        assert_ne!(a, other, "different content must get a different handle");
491    }
492
493    #[test]
494    fn persisted_original_is_recoverable() {
495        let _lock = crate::core::data_dir::test_env_lock();
496        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
497        let content = big("recoverable verbatim line");
498        let handle = persist(&content).expect("persisted");
499        let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
500        assert!(
501            on_disk.contains("recoverable verbatim line"),
502            "the verbatim original must be retrievable from the handle"
503        );
504    }
505
506    #[test]
507    fn small_content_gets_no_handle() {
508        let _lock = crate::core::data_dir::test_env_lock();
509        assert!(
510            persist("too small to bother").is_none(),
511            "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
512        );
513    }
514
515    #[test]
516    #[allow(clippy::case_sensitive_file_extension_comparisons)]
517    fn conversation_handle_is_compact_and_deterministic() {
518        let _lock = crate::core::data_dir::test_env_lock();
519        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
520        let a = persist_conversation("{\"role\":\"user\",\"content\":\"hello\"}")
521            .expect("conversation handle");
522        let b = persist_conversation("{\"role\":\"user\",\"content\":\"hello\"}")
523            .expect("conversation handle");
524        assert_eq!(a, b);
525        assert!(a.starts_with("conv_") && a.ends_with(".log"));
526    }
527
528    #[test]
529    fn conversation_handle_resolves_short_messages() {
530        let _lock = crate::core::data_dir::test_env_lock();
531        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
532        let handle = persist_conversation("short conversation message").expect("handle");
533        assert!(resolve_tee(&handle).is_some());
534    }
535
536    #[test]
537    fn conversation_original_is_recoverable() {
538        let _lock = crate::core::data_dir::test_env_lock();
539        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
540        let original = "{\"role\":\"tool\",\"content\":\"recover me\"}";
541        let handle = persist_conversation(original).expect("handle");
542        let path = resolve_tee(&handle).expect("resolved handle");
543        assert_eq!(std::fs::read_to_string(path).unwrap(), original);
544    }
545
546    #[test]
547    fn resolve_tee_accepts_every_stub_form() {
548        let _lock = crate::core::data_dir::test_env_lock();
549        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
550        let content = big("resolvable tee body");
551        let handle = persist(&content).expect("persisted");
552        let hash = crate::core::hasher::hash_short(&content);
553
554        // Full path, bare file name, proxy_<hash>, and bare <hash> all resolve to
555        // the same on-disk file — whatever the agent copied out of the stub.
556        for form in [
557            handle.clone(),
558            format!("proxy_{hash}.log"),
559            format!("proxy_{hash}"),
560            hash.clone(),
561        ] {
562            let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
563            assert_eq!(
564                resolved.to_string_lossy(),
565                handle,
566                "form {form} -> {handle}"
567            );
568        }
569    }
570
571    /// #1911: the HTML crush's `html_` handle must round-trip through the
572    /// resolver `ctx_expand` uses, in every form the footer can be copied in.
573    #[test]
574    fn persist_html_handle_resolves_in_every_form() {
575        let _lock = crate::core::data_dir::test_env_lock();
576        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
577        let content = big("<p>html crush original</p>");
578        let handle = persist_html(&content).expect("html persisted");
579        let hash = crate::core::hasher::hash_short(&content);
580        for form in [
581            handle.clone(),
582            format!("html_{hash}.log"),
583            format!("html_{hash}"),
584        ] {
585            let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
586            assert_eq!(resolved.to_string_lossy(), handle, "form {form}");
587        }
588        assert!(
589            std::fs::read_to_string(&handle)
590                .unwrap()
591                .contains("html crush original")
592        );
593    }
594
595    /// Every prefix a producer can write is accepted by the resolver, and a
596    /// prefix without its `_` separator is not.
597    #[test]
598    fn canonical_tee_name_covers_every_producer_prefix() {
599        let hash = "0123456789abcdef";
600        for prefix in TEE_PREFIXES {
601            assert_eq!(
602                canonical_tee_name(&format!("{prefix}_{hash}.log")),
603                Some(format!("{prefix}_{hash}.log"))
604            );
605        }
606        assert_eq!(canonical_tee_name(&format!("html{hash}")), None);
607        assert_eq!(canonical_tee_name("html_nothex000000000"), None);
608    }
609
610    #[test]
611    fn resolve_tee_rejects_nontee_and_traversal_ids() {
612        let _lock = crate::core::data_dir::test_env_lock();
613        // No FS escape: a crafted path is reduced to its file name, which is not a
614        // valid proxy tee name, so it resolves to None instead of reading it.
615        assert!(resolve_tee("/etc/passwd").is_none());
616        assert!(resolve_tee("../../secret").is_none());
617        assert!(resolve_tee("proxy_nothex0000000.log").is_none());
618        // Right shape but no such file in the store.
619        assert!(resolve_tee("deadbeefdeadbeef").is_none());
620    }
621
622    #[test]
623    fn persist_json_is_distinct_prefix_and_resolvable() {
624        let _lock = crate::core::data_dir::test_env_lock();
625        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
626        let content = big("json crusher original");
627        let proxy = persist(&content).expect("proxy persisted");
628        let json = persist_json(&content).expect("json persisted");
629        assert!(
630            json.contains("json_"),
631            "json handle uses json_ prefix: {json}"
632        );
633        assert_ne!(
634            proxy, json,
635            "same content gets distinct files per producer prefix"
636        );
637
638        // The json_ handle resolves through the unified resolver in every form a
639        // stub / footer can carry: full path, bare file name, and bare json_id.
640        let hash = crate::core::hasher::hash_short(&content);
641        for form in [
642            json.clone(),
643            format!("json_{hash}.log"),
644            format!("json_{hash}"),
645        ] {
646            assert_eq!(
647                resolve_tee(&form)
648                    .expect("json form resolves")
649                    .to_string_lossy(),
650                json,
651                "json form {form} -> {json}"
652            );
653        }
654    }
655
656    #[test]
657    fn persist_tabular_is_distinct_prefix_and_resolvable() {
658        let _lock = crate::core::data_dir::test_env_lock();
659        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
660        let content = big("tabular crusher original");
661        let json = persist_json(&content).expect("json persisted");
662        let tbl = persist_tabular(&content).expect("tbl persisted");
663        assert!(
664            tbl.contains("tbl_"),
665            "tabular handle uses tbl_ prefix: {tbl}"
666        );
667        assert_ne!(
668            json, tbl,
669            "same content gets distinct files per producer prefix"
670        );
671
672        let hash = crate::core::hasher::hash_short(&content);
673        for form in [
674            tbl.clone(),
675            format!("tbl_{hash}.log"),
676            format!("tbl_{hash}"),
677        ] {
678            assert_eq!(
679                resolve_tee(&form)
680                    .expect("tbl form resolves")
681                    .to_string_lossy(),
682                tbl,
683                "tbl form {form} -> {tbl}"
684            );
685        }
686    }
687
688    #[test]
689    fn persist_yaml_is_distinct_prefix_and_resolvable() {
690        let _lock = crate::core::data_dir::test_env_lock();
691        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
692        let content = big("yaml crusher original");
693        let tbl = persist_tabular(&content).expect("tbl persisted");
694        let yaml = persist_yaml(&content).expect("yaml persisted");
695        assert!(
696            yaml.contains("yaml_"),
697            "yaml handle uses yaml_ prefix: {yaml}"
698        );
699        assert_ne!(
700            tbl, yaml,
701            "same content gets distinct files per producer prefix"
702        );
703
704        let hash = crate::core::hasher::hash_short(&content);
705        for form in [
706            yaml.clone(),
707            format!("yaml_{hash}.log"),
708            format!("yaml_{hash}"),
709        ] {
710            assert_eq!(
711                resolve_tee(&form)
712                    .expect("yaml form resolves")
713                    .to_string_lossy(),
714                yaml,
715                "yaml form {form} -> {yaml}"
716            );
717        }
718    }
719
720    #[test]
721    fn resolve_tee_resolves_shell_tee_with_underscored_slug() {
722        let _lock = crate::core::data_dir::test_env_lock();
723        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
724        // A real shell command whose slug contains underscores: the hash is the
725        // last `_`-segment (8 hex), never the first — the parsing trap. The full
726        // verbatim output the shell already teed is now surgically retrievable.
727        let path = crate::shell::save_tee("gh api /repos/foo/bar", &big("api row"))
728            .expect("shell tee saved");
729        let name = std::path::Path::new(&path)
730            .file_name()
731            .and_then(|n| n.to_str())
732            .unwrap()
733            .to_string();
734        assert!(
735            is_shell_tee_name(&name),
736            "save_tee name must be recognized as a shell tee: {name}"
737        );
738        for form in [path.clone(), name] {
739            assert_eq!(
740                resolve_tee(&form)
741                    .expect("shell tee form resolves")
742                    .to_string_lossy(),
743                path,
744                "shell tee form -> {path}"
745            );
746        }
747    }
748
749    #[test]
750    fn resolve_tee_does_not_capture_reference_ids() {
751        let _lock = crate::core::data_dir::test_env_lock();
752        // A reference-store id (`ref_<16hex>`, no `.log`) must fall through the
753        // tee resolver so ctx_expand routes it to the reference store, not the
754        // tee store — the precedence guard for the unified retrieve ladder.
755        assert!(resolve_tee("ref_deadbeefcafef00d").is_none());
756        // A bare 16-hex archive id with no backing tee file also stays None.
757        assert!(resolve_tee("0123456789abcdef").is_none());
758    }
759
760    #[test]
761    fn litellm_hash_is_24_lowercase_hex_and_extends_tee_hash() {
762        let content = big("gateway retrieval body");
763        let hash = litellm_hash(&content);
764        assert_eq!(hash.len(), LITELLM_HASH_LEN);
765        assert!(
766            hash.bytes()
767                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
768            "must match LiteLLM's [a-f0-9]{{24}} class: {hash}"
769        );
770        // Same blake3 digest as the tee name, longer prefix: the first 16 chars
771        // ARE the tee content-address, which is what makes retrieval work.
772        assert_eq!(hash[..16], crate::core::hasher::hash_short(&content));
773        // Pure function of content (#498): stable across calls.
774        assert_eq!(hash, litellm_hash(&content));
775    }
776
777    #[cfg(feature = "http-server")]
778    #[test]
779    fn retrieve_litellm_resolves_persisted_content() {
780        let _lock = crate::core::data_dir::test_env_lock();
781        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
782        let content = big("litellm retrievable original");
783        persist(&content).expect("persisted");
784        let recovered =
785            retrieve_litellm(&litellm_hash(&content)).expect("24-hex hash must resolve");
786        assert!(recovered.contains("litellm retrievable original"));
787    }
788
789    #[cfg(feature = "http-server")]
790    #[test]
791    fn retrieve_litellm_is_shape_locked_to_the_guardrail_regex() {
792        let _lock = crate::core::data_dir::test_env_lock();
793        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
794        let content = big("shape locked body");
795        persist(&content).expect("persisted");
796        let hash = litellm_hash(&content);
797
798        // 16-hex (our tee id), truncated, extended, uppercased, non-hex: all
799        // rejected — only the exact shape LiteLLM's regex captures resolves.
800        assert!(retrieve_litellm(&hash[..16]).is_none(), "16-hex rejected");
801        assert!(retrieve_litellm(&hash[..23]).is_none(), "23-hex rejected");
802        assert!(
803            retrieve_litellm(&format!("{hash}0")).is_none(),
804            "25-hex rejected"
805        );
806        assert!(
807            retrieve_litellm(&hash.to_uppercase()).is_none(),
808            "uppercase rejected (regex class is [a-f0-9])"
809        );
810        assert!(
811            retrieve_litellm("zzzzzzzzzzzzzzzzzzzzzzzz").is_none(),
812            "non-hex rejected"
813        );
814        // Traversal attempts die in resolve_tee's name canonicalization.
815        assert!(retrieve_litellm("../../etc/passwd00000000").is_none());
816        // Right shape, unknown content.
817        assert!(retrieve_litellm("0123456789abcdef01234567").is_none());
818    }
819
820    #[test]
821    fn inband_marker_is_derived_from_handle() {
822        let _lock = crate::core::data_dir::test_env_lock();
823        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
824        let content = big("inband marker body");
825        let handle = persist(&content).expect("persisted");
826        let hash = crate::core::hasher::hash_short(&content);
827        // The marker carries the same content hash the handle does, so a model can
828        // echo it and the proxy resolves it back to the very same tee file.
829        assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
830        // A non-tee handle has no marker.
831        assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
832    }
833}