sim_lib_view_device/
reaper.rs1use std::collections::BTreeMap;
4
5use sim_kernel::{Expr, Symbol};
6
7use crate::{ConsentReceipt, FrameClock};
8
9#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct StoreKey(Symbol);
12
13impl StoreKey {
14 pub fn new(symbol: Symbol) -> Self {
16 Self(symbol)
17 }
18
19 pub fn named(name: impl Into<String>) -> Self {
21 Self(Symbol::qualified("device/store", name.into()))
22 }
23
24 pub fn as_symbol(&self) -> &Symbol {
26 &self.0
27 }
28}
29
30#[derive(Clone, Debug, PartialEq)]
32pub struct StoredSample {
33 key: StoreKey,
34 receipt_seq: u64,
35 tick: u64,
36 content_refs: Vec<StoreKey>,
37 value: Expr,
38}
39
40impl StoredSample {
41 pub fn new(
43 key: StoreKey,
44 receipt_seq: u64,
45 tick: u64,
46 content_refs: Vec<StoreKey>,
47 value: Expr,
48 ) -> Self {
49 Self {
50 key,
51 receipt_seq,
52 tick,
53 content_refs,
54 value,
55 }
56 }
57
58 pub fn key(&self) -> &StoreKey {
60 &self.key
61 }
62
63 pub fn receipt_seq(&self) -> u64 {
65 self.receipt_seq
66 }
67
68 pub fn tick(&self) -> u64 {
70 self.tick
71 }
72
73 pub fn content_refs(&self) -> &[StoreKey] {
75 &self.content_refs
76 }
77
78 pub fn value(&self) -> &Expr {
80 &self.value
81 }
82}
83
84#[derive(Clone, Debug, Default, PartialEq)]
86pub struct DeviceSampleStore {
87 samples: BTreeMap<StoreKey, StoredSample>,
88 content: BTreeMap<StoreKey, Expr>,
89}
90
91impl DeviceSampleStore {
92 pub fn new() -> Self {
94 Self::default()
95 }
96
97 pub fn insert_content(&mut self, key: StoreKey, value: Expr) {
99 self.content.insert(key, value);
100 }
101
102 pub fn insert_sample(&mut self, sample: StoredSample) {
104 self.samples.insert(sample.key.clone(), sample);
105 }
106
107 pub fn contains_sample(&self, key: &StoreKey) -> bool {
109 self.samples.contains_key(key)
110 }
111
112 pub fn contains_content(&self, key: &StoreKey) -> bool {
114 self.content.contains_key(key)
115 }
116
117 pub fn sample_len(&self) -> usize {
119 self.samples.len()
120 }
121
122 pub fn sample(&self, key: &StoreKey) -> Option<&StoredSample> {
124 self.samples.get(key)
125 }
126
127 pub fn content_len(&self) -> usize {
129 self.content.len()
130 }
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct Evicted {
136 pub key: StoreKey,
138 pub reason: Symbol,
140}
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum PrivacyMode {
145 Retain,
147 Redact,
149 Delete,
151}
152
153#[derive(Clone, Debug, PartialEq, Eq)]
155pub struct ReaperDirective {
156 pub mode: PrivacyMode,
158 pub redact: Vec<Symbol>,
160}
161
162impl ReaperDirective {
163 pub fn from_receipt(receipt: &ConsentReceipt) -> Self {
165 let mode = if receipt.redact.is_empty() {
166 PrivacyMode::Retain
167 } else {
168 PrivacyMode::Redact
169 };
170 Self {
171 mode,
172 redact: receipt.redact.clone(),
173 }
174 }
175}
176
177#[derive(Clone, Debug, Default, PartialEq, Eq)]
179pub struct RetentionReaper;
180
181impl RetentionReaper {
182 pub fn new() -> Self {
184 Self
185 }
186
187 pub fn sweep(
189 &self,
190 store: &mut DeviceSampleStore,
191 receipts: &[ConsentReceipt],
192 now: FrameClock,
193 ) -> Vec<Evicted> {
194 let receipt_by_seq: BTreeMap<u64, &ConsentReceipt> = receipts
195 .iter()
196 .map(|receipt| (receipt.seq, receipt))
197 .collect();
198 for sample in store.samples.values_mut() {
199 if let Some(receipt) = receipt_by_seq.get(&sample.receipt_seq)
200 && now.elapsed_ms_since(sample.tick) <= receipt.retain_ms
201 {
202 apply_redaction(&mut sample.value, &receipt.redact);
203 }
204 }
205 let expired: Vec<StoreKey> = store
206 .samples
207 .values()
208 .filter(|sample| {
209 receipt_by_seq
210 .get(&sample.receipt_seq)
211 .is_none_or(|receipt| now.elapsed_ms_since(sample.tick) > receipt.retain_ms)
212 })
213 .map(|sample| sample.key.clone())
214 .collect();
215
216 let mut evicted = Vec::new();
217 for key in expired {
218 if let Some(sample) = store.samples.remove(&key) {
219 evicted.push(Evicted {
220 key,
221 reason: retention_reason(),
222 });
223 for content_key in sample.content_refs {
224 if store.content.remove(&content_key).is_some() {
225 evicted.push(Evicted {
226 key: content_key,
227 reason: retention_reason(),
228 });
229 }
230 }
231 }
232 }
233 evicted
234 }
235}
236
237pub fn retention_reason() -> Symbol {
239 Symbol::qualified("device/reaper", "retention")
240}
241
242fn redacted_marker() -> Expr {
243 Expr::Symbol(Symbol::qualified("device/reaper", "redacted"))
244}
245
246fn apply_redaction(value: &mut Expr, redact: &[Symbol]) {
247 if redact.is_empty() {
248 return;
249 }
250 let Expr::Map(entries) = value else {
251 return;
252 };
253 for (key, field_value) in entries {
254 if let Expr::Symbol(field) = key
255 && redact
256 .iter()
257 .any(|directive| redacts_field(directive, field))
258 {
259 *field_value = redacted_marker();
260 }
261 }
262}
263
264fn redacts_field(directive: &Symbol, field: &Symbol) -> bool {
265 directive == field
266 || directive.name == field.name
267 || directive.as_qualified_str() == field.as_qualified_str()
268}