Skip to main content

lean_ctx/core/
protect.rs

1//! Explicit, deterministic preservation of user-marked spans across every
2//! compressor (read, shell, proxy, prose).
3//!
4//! Two complementary, fully deterministic mechanisms (#498 — pure functions of
5//! their inputs, no model, no clock, no global state):
6//!
7//! 1. **Universal markers** `<lc_safe>…</lc_safe>`: any compressor wraps its
8//!    work in [`compress_preserving`]; the content between the markers passes
9//!    through verbatim and the markers themselves are stripped from the output.
10//! 2. **`protect` token list** (`ctx_read` convenience): [`line_is_protected`]
11//!    lets the line-based lossy filters (entropy / information-bottleneck)
12//!    force-keep every line that contains one of the given tokens.
13//!
14//! Security: callers MUST run secret redaction *before* protect (`redact →
15//! protect → compress`). Protect never re-introduces redacted secrets because
16//! it only ever passes through bytes that already survived redaction.
17
18/// Opening marker for a verbatim-preserved span.
19pub const SAFE_OPEN: &str = "<lc_safe>";
20/// Closing marker for a verbatim-preserved span.
21pub const SAFE_CLOSE: &str = "</lc_safe>";
22
23/// Cheap pre-check: does the input contain at least one protect marker? Lets
24/// hot paths skip the span-splitting machinery entirely when nothing is marked.
25#[must_use]
26pub fn has_markers(s: &str) -> bool {
27    s.contains(SAFE_OPEN)
28}
29
30/// Compress only the *unprotected* regions of `input` with `f`; everything
31/// between `<lc_safe>` and `</lc_safe>` passes through byte-for-byte and the
32/// markers are stripped from the output.
33///
34/// Deterministic: pure function of `(input, f)`. An unterminated open marker
35/// keeps the remainder of the input verbatim (fail-safe: never compress what the
36/// user tried to protect). When there are no markers the input is handed to `f`
37/// unchanged, so existing callers keep their exact byte output.
38#[must_use]
39pub fn compress_preserving<F: Fn(&str) -> String>(input: &str, f: F) -> String {
40    if !has_markers(input) {
41        return f(input);
42    }
43    let mut out = String::with_capacity(input.len());
44    let mut rest = input;
45    while let Some(start) = rest.find(SAFE_OPEN) {
46        out.push_str(&f(&rest[..start]));
47        let after = &rest[start + SAFE_OPEN.len()..];
48        let Some(end) = after.find(SAFE_CLOSE) else {
49            out.push_str(after); // unterminated → keep remainder verbatim
50            return out;
51        };
52        out.push_str(&after[..end]); // verbatim, markers dropped
53        rest = &after[end + SAFE_CLOSE.len()..];
54    }
55    out.push_str(&f(rest));
56    out
57}
58
59/// True if `line` must survive a lossy line filter because it contains one of
60/// the explicit `protect` tokens. Empty tokens are ignored so an empty list (or
61/// a list of empty strings) reproduces today's behaviour exactly.
62#[must_use]
63pub fn line_is_protected(line: &str, needles: &[String]) -> bool {
64    needles
65        .iter()
66        .any(|n| !n.is_empty() && line.contains(n.as_str()))
67}
68
69/// Stable cache-key fragment for a `protect` token list, or `""` when the list
70/// is empty (so unprotected reads keep their current cache key).
71///
72/// The fragment is order- and duplicate-independent: force-keep matching is a
73/// set operation, so `["a","b"]` and `["b","a","a"]` must map to the same key
74/// (#498). Tokens are canonicalised (non-empty, sorted, deduped) before hashing.
75#[must_use]
76pub fn protect_fragment(needles: &[String]) -> String {
77    let mut canon: Vec<&str> = needles
78        .iter()
79        .map(String::as_str)
80        .filter(|s| !s.is_empty())
81        .collect();
82    if canon.is_empty() {
83        return String::new();
84    }
85    canon.sort_unstable();
86    canon.dedup();
87    // NUL separator: cannot occur inside a realistic source token, so distinct
88    // token sets cannot collide by joining (e.g. ["ab","c"] vs ["a","bc"]).
89    let joined = canon.join("\u{0}");
90    format!("p{}", &crate::core::hasher::hash_short(&joined)[..8])
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    /// A maximally aggressive test compressor: it deletes *everything*. If a
98    /// span survives this, it survives any real compressor.
99    fn drop_all(_: &str) -> String {
100        String::new()
101    }
102
103    #[test]
104    fn no_markers_passes_input_to_f() {
105        assert_eq!(
106            compress_preserving("plain text", str::to_uppercase),
107            "PLAIN TEXT"
108        );
109        assert!(!has_markers("plain text"));
110    }
111
112    #[test]
113    fn protect_spans_survive_compression() {
114        let input =
115            "noise before\n<lc_safe>CRITICAL = 42\nkeep me literally</lc_safe>\nnoise after";
116        let out = compress_preserving(input, drop_all);
117        // Protected content is byte-identical and the markers are gone.
118        assert_eq!(out, "CRITICAL = 42\nkeep me literally");
119        assert!(!out.contains(SAFE_OPEN));
120        assert!(!out.contains(SAFE_CLOSE));
121    }
122
123    #[test]
124    fn unprotected_regions_are_compressed_protected_are_not() {
125        let f = |s: &str| s.to_uppercase();
126        let out = compress_preserving("a<lc_safe>b</lc_safe>c", f);
127        assert_eq!(out, "AbC");
128    }
129
130    #[test]
131    fn multiple_spans_all_survive() {
132        let input = "x<lc_safe>one</lc_safe>y<lc_safe>two</lc_safe>z";
133        let out = compress_preserving(input, drop_all);
134        assert_eq!(out, "onetwo");
135    }
136
137    #[test]
138    fn unterminated_marker_keeps_remainder_verbatim() {
139        let input = "drop<lc_safe>tail without close\nstill kept";
140        let out = compress_preserving(input, drop_all);
141        assert_eq!(out, "tail without close\nstill kept");
142    }
143
144    #[test]
145    fn compress_preserving_is_deterministic() {
146        let input = "a<lc_safe>SAFE</lc_safe>b<lc_safe>X</lc_safe>c";
147        let a = compress_preserving(input, str::to_uppercase);
148        let b = compress_preserving(input, str::to_uppercase);
149        assert_eq!(a, b);
150    }
151
152    #[test]
153    fn line_is_protected_matches_token_and_ignores_empty() {
154        let needles = vec!["TODO".to_string(), String::new()];
155        assert!(line_is_protected("  // TODO: fix", &needles));
156        assert!(!line_is_protected("nothing here", &needles));
157        // An empty needle must never match every line.
158        assert!(!line_is_protected("anything", &[String::new()]));
159        assert!(!line_is_protected("anything", &[]));
160    }
161
162    #[test]
163    fn protect_fragment_empty_is_blank() {
164        assert_eq!(protect_fragment(&[]), "");
165        assert_eq!(protect_fragment(&[String::new()]), "");
166    }
167
168    #[test]
169    fn protect_fragment_is_order_and_dup_independent() {
170        let a = protect_fragment(&["alpha".to_string(), "beta".to_string()]);
171        let b = protect_fragment(&["beta".to_string(), "alpha".to_string(), "alpha".to_string()]);
172        assert_eq!(a, b);
173        assert!(a.starts_with('p'));
174        assert_eq!(a.len(), 9); // 'p' + 8 hex chars
175    }
176
177    #[test]
178    fn protect_fragment_distinguishes_sets() {
179        let a = protect_fragment(&["alpha".to_string()]);
180        let b = protect_fragment(&["beta".to_string()]);
181        assert_ne!(a, b);
182    }
183}