lean_ctx/core/session/
taint.rs1use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Arc, LazyLock};
25use std::time::Duration;
26
27use tokio::sync::{OwnedRwLockWriteGuard, RwLock};
28
29use crate::core::bounded_lock;
30use crate::core::context_admission::origin::{InputTaint, TaintView};
31
32#[derive(Default)]
33struct Active {
34 session_id: String,
36 taint: InputTaint,
37 generation: u64,
40}
41
42static ACTIVE: LazyLock<Arc<RwLock<Option<Active>>>> =
43 LazyLock::new(|| Arc::new(RwLock::new(None)));
44
45static UNRECORDED: AtomicBool = AtomicBool::new(false);
48
49const LOCK_BUDGET: Duration = Duration::from_secs(2);
52
53fn write_guard() -> Option<OwnedRwLockWriteGuard<Option<Active>>> {
54 let guard = bounded_lock::write_for(&ACTIVE, "session taint", LOCK_BUDGET);
55 if guard.is_none() {
56 UNRECORDED.store(true, Ordering::SeqCst);
57 }
58 guard
59}
60
61fn with_unrecorded(mut taint: InputTaint) -> InputTaint {
62 if UNRECORDED.load(Ordering::SeqCst) {
63 taint.mark_overflow();
64 }
65 taint
66}
67
68pub fn bind(session_id: &str, taint: &InputTaint) {
73 let save = {
74 let Some(mut active) = write_guard() else {
75 return;
76 };
77 match active.as_mut() {
78 Some(current) if current.session_id == session_id => {
79 if !current.taint.merge(taint) {
80 return;
81 }
82 current.generation += 1;
83 Some((current.taint.clone(), current.generation))
84 }
85 _ => None,
86 }
87 };
88 if let Some((taint, generation)) = save {
89 persist(session_id, taint, generation);
90 return;
91 }
92 let mut stored = load_sidecar(session_id).unwrap_or_default();
94 stored.merge(taint);
95 let save = {
96 let Some(mut active) = write_guard() else {
97 return;
98 };
99 let mut carried = false;
100 let mut generation = 1;
101 if let Some(previous) = active.take()
102 && (previous.session_id.is_empty() || previous.session_id == session_id)
103 {
104 carried = stored.merge(&previous.taint);
107 generation = previous.generation + 1;
108 }
109 *active = Some(Active {
110 session_id: session_id.to_owned(),
111 taint: stored.clone(),
112 generation,
113 });
114 carried.then_some((stored, generation))
115 };
116 if let Some((taint, generation)) = save {
117 persist(session_id, taint, generation);
118 }
119}
120
121pub fn record(delta: &InputTaint) {
125 if !delta.is_tainted() {
126 return;
127 }
128 let save = {
129 let Some(mut active) = write_guard() else {
130 return;
131 };
132 let current = active.get_or_insert_with(Active::default);
133 if !current.taint.merge(delta) {
134 return;
135 }
136 current.generation += 1;
137 (!current.session_id.is_empty()).then(|| {
138 (
139 current.session_id.clone(),
140 current.taint.clone(),
141 current.generation,
142 )
143 })
144 };
145 if let Some((session_id, taint, generation)) = save {
146 persist(&session_id, taint, generation);
147 }
148}
149
150fn persist(session_id: &str, mut taint: InputTaint, mut generation: u64) {
154 loop {
155 save_sidecar(session_id, &taint);
156 let Some(active) = bounded_lock::read_for(&ACTIVE, "session taint", LOCK_BUDGET) else {
157 UNRECORDED.store(true, Ordering::SeqCst);
158 return;
159 };
160 match active.as_ref() {
161 Some(current)
162 if current.session_id == session_id && current.generation > generation =>
163 {
164 taint = current.taint.clone();
165 generation = current.generation;
166 }
167 _ => return,
168 }
169 }
170}
171
172pub fn active_view() -> TaintView {
174 snapshot().view()
175}
176
177pub fn snapshot() -> InputTaint {
179 let Some(active) = bounded_lock::read_for(&ACTIVE, "session taint", LOCK_BUDGET) else {
180 UNRECORDED.store(true, Ordering::SeqCst);
181 return with_unrecorded(InputTaint::default());
182 };
183 with_unrecorded(
184 active
185 .as_ref()
186 .map(|active| active.taint.clone())
187 .unwrap_or_default(),
188 )
189}
190
191pub fn active_session_id() -> Option<String> {
193 bounded_lock::read_for(&ACTIVE, "session taint", LOCK_BUDGET)?
194 .as_ref()
195 .map(|active| active.session_id.clone())
196 .filter(|id| !id.is_empty())
197}
198
199pub fn snapshot_for(session_id: &str) -> Option<InputTaint> {
201 let taint = bounded_lock::read_for(&ACTIVE, "session taint", LOCK_BUDGET)?
202 .as_ref()
203 .filter(|active| active.session_id == session_id)
204 .map(|active| active.taint.clone())?;
205 Some(with_unrecorded(taint))
206}
207
208#[cfg(any(test, feature = "test-support"))]
211pub fn reset_active() {
212 if let Some(mut active) = bounded_lock::write_for(&ACTIVE, "session taint", LOCK_BUDGET) {
213 *active = None;
214 }
215 UNRECORDED.store(false, Ordering::SeqCst);
216}
217
218fn sidecar_path(session_id: &str) -> Option<std::path::PathBuf> {
219 let valid = !session_id.is_empty()
220 && !session_id.starts_with('.')
221 && session_id
222 .chars()
223 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'));
224 if !valid {
225 return None;
226 }
227 super::paths::sessions_dir().map(|dir| dir.join(format!("{session_id}.taint")))
228}
229
230pub(crate) fn load_sidecar(session_id: &str) -> Option<InputTaint> {
232 let path = sidecar_path(session_id)?;
233 let json = std::fs::read_to_string(path).ok()?;
234 Some(serde_json::from_str(&json).unwrap_or_else(|_| InputTaint {
236 overflow: true,
237 ..InputTaint::default()
238 }))
239}
240
241fn save_sidecar(session_id: &str, taint: &InputTaint) {
242 let Some(path) = sidecar_path(session_id) else {
243 return;
244 };
245 let Ok(json) = serde_json::to_string(taint) else {
246 return;
247 };
248 if let Some(dir) = path.parent() {
249 let _ = std::fs::create_dir_all(dir);
250 }
251 if let Err(error) = crate::core::atomic_fs::try_atomic_write(&path, json.as_bytes(), None) {
252 tracing::warn!("session taint not persisted: {error}");
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::core::context_admission::origin::{InputKind, InputRef};
260
261 #[test]
262 fn taint_is_written_through_and_survives_a_rebind() {
263 let _data = crate::core::data_dir::isolated_data_dir();
264 reset_active();
265 bind("20261106-000000u000000p1s0", &InputTaint::default());
266 let mut delta = InputTaint::default();
267 delta.add(InputRef::new(InputKind::Issue, "github:o/r#1"));
268 delta.mark_overflow();
269 record(&delta);
270 assert!(active_view().overflow());
271 reset_active();
273 assert!(!active_view().is_tainted());
274 bind("20261106-000000u000000p1s0", &InputTaint::default());
275 let view = active_view();
276 assert!(view.overflow());
277 assert!(view.contains(&InputRef::new(InputKind::Issue, "github:o/r#1").unwrap()));
278 bind("20261106-000000u000000p1s1", &InputTaint::default());
280 assert!(!active_view().is_tainted());
281 reset_active();
282 }
283
284 #[test]
288 fn taint_recorded_before_the_first_bind_is_kept_and_saved() {
289 let _data = crate::core::data_dir::isolated_data_dir();
290 reset_active();
291 let issue = InputRef::new(InputKind::Issue, "github:o/r#7").unwrap();
292 let mut delta = InputTaint::default();
293 delta.add(Some(issue.clone()));
294 record(&delta);
295 bind("20261106-000000u000000p1s7", &InputTaint::default());
296 assert!(active_view().contains(&issue), "lost on bind");
297 reset_active();
299 bind("20261106-000000u000000p1s7", &InputTaint::default());
300 assert!(active_view().contains(&issue), "not written through");
301 reset_active();
302 }
303}