1#![allow(clippy::significant_drop_tightening)]
28
29use std::collections::BTreeMap;
30
31use serde::{Deserialize, Serialize};
32
33use crate::credentials::redact_credential_content;
34use crate::{MemoryStore, SearchEngine};
35use wm_core::{Galaxy, Result};
36
37#[derive(Debug, Default, Clone, Serialize, Deserialize)]
39pub struct GalaxyRedactStats {
40 pub galaxy: String,
41 pub scanned: usize,
42 pub redacted: usize,
43 pub already_clean: usize,
44 pub filtered_out: usize,
45}
46
47#[derive(Debug, Default, Clone, Serialize, Deserialize)]
49pub struct RedactReport {
50 pub scanned: usize,
51 pub redacted: usize,
52 pub already_clean: usize,
53 pub filtered_out: usize,
54 pub kinds: BTreeMap<String, usize>,
56 pub galaxies: Vec<GalaxyRedactStats>,
57}
58
59pub fn redact_store_content(
69 store: &MemoryStore,
70 search: &SearchEngine,
71 galaxies: &[Galaxy],
72 tag_filter: Option<&str>,
73 apply: bool,
74) -> Result<RedactReport> {
75 let mut report = RedactReport::default();
76 let mut writer = if apply { Some(search.writer()?) } else { None };
77
78 for galaxy in galaxies {
79 let mut stats = GalaxyRedactStats {
80 galaxy: galaxy.db_name().to_string(),
81 ..Default::default()
82 };
83
84 for mut mem in store.scan_all(*galaxy)? {
85 stats.scanned += 1;
86
87 if let Some(tag) = tag_filter {
88 if !mem.metadata.tags.iter().any(|t| t == tag) {
89 stats.filtered_out += 1;
90 continue;
91 }
92 }
93
94 let (scrubbed, kinds) = redact_credential_content(&mem.content);
95 if kinds.is_empty() {
96 stats.already_clean += 1;
97 continue;
98 }
99
100 stats.redacted += 1;
101 for kind in kinds {
102 *report.kinds.entry(kind.to_string()).or_default() += 1;
103 }
104
105 let Some(writer) = writer.as_mut() else {
106 continue;
107 };
108
109 let old_hash = mem.metadata.content_hash.clone();
110 mem.content = scrubbed;
111 mem.metadata.content_hash = crate::content_hash(&mem.content);
112 mem.metadata.revision_count = mem.metadata.revision_count.saturating_add(1);
113 store.put(*galaxy, &mem)?;
114 store.record_revision(
115 *galaxy,
116 mem.metadata.id,
117 &old_hash,
118 &mem.metadata.content_hash,
119 crate::revision::RevisionActor {
120 session: None,
121 user: Some("wm-redact-content".to_string()),
122 compartment: None,
123 },
124 )?;
125 store
130 .episodic()
131 .replace_content(mem.metadata.id, &mem.content)?;
132 let id_str = mem.metadata.id.to_string();
133 search.delete_document(writer, &id_str)?;
134 search.add_document(
135 writer,
136 &id_str,
137 galaxy.db_name(),
138 &mem.content,
139 &mem.metadata.tags,
140 mem.metadata.created_at.timestamp(),
141 )?;
142 }
143
144 report.scanned += stats.scanned;
145 report.redacted += stats.redacted;
146 report.already_clean += stats.already_clean;
147 report.filtered_out += stats.filtered_out;
148 report.galaxies.push(stats);
149 }
150
151 if let Some(mut writer) = writer {
152 search.commit(&mut writer)?;
153 }
154
155 Ok(report)
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::Memory;
162 use tempfile::tempdir;
163
164 fn setup() -> (tempfile::TempDir, MemoryStore, SearchEngine) {
165 let tmp = tempdir().unwrap();
166 let store = MemoryStore::open_default(tmp.path()).unwrap();
167 let tantivy_dir = tmp.path().join("tantivy");
168 std::fs::create_dir_all(&tantivy_dir).unwrap();
169 let search = SearchEngine::open(&tantivy_dir).unwrap();
170 (tmp, store, search)
171 }
172
173 fn put_tagged(
174 store: &MemoryStore,
175 search: &SearchEngine,
176 galaxy: Galaxy,
177 content: &str,
178 tags: &[&str],
179 ) -> uuid::Uuid {
180 let mut mem = Memory::new(galaxy, content.to_string());
181 mem.metadata.tags = tags.iter().map(std::string::ToString::to_string).collect();
182 mem.metadata.content_hash = crate::content_hash(content);
183 let id = mem.metadata.id;
184 store.put(galaxy, &mem).unwrap();
185 let mut writer = search.writer().unwrap();
186 search
187 .add_document(
188 &mut writer,
189 &id.to_string(),
190 galaxy.db_name(),
191 content,
192 &mem.metadata.tags,
193 mem.metadata.created_at.timestamp(),
194 )
195 .unwrap();
196 search.commit(&mut writer).unwrap();
197 id
198 }
199
200 #[test]
201 fn dry_run_reports_without_writing() {
202 let (_tmp, store, search) = setup();
203 let secret = "db password=correct-horse-battery-staple";
204 let id = put_tagged(&store, &search, Galaxy::Research, secret, &["source:test"]);
205
206 let report =
207 redact_store_content(&store, &search, &[Galaxy::Research], None, false).unwrap();
208 assert_eq!(report.redacted, 1);
209 assert_eq!(report.already_clean, 0);
210 assert!(report.kinds.contains_key("credential_assignment"));
211
212 let mem = store.get(Galaxy::Research, id).unwrap().unwrap();
213 assert_eq!(mem.content, secret, "dry run must not write");
214 assert_eq!(mem.metadata.revision_count, 0);
215 }
216
217 #[test]
218 fn apply_redacts_in_place_reindexes_and_is_idempotent() {
219 let (_tmp, store, search) = setup();
220 put_tagged(
221 &store,
222 &search,
223 Galaxy::Research,
224 "db password=correct-horse-battery-staple",
225 &["source:test"],
226 );
227 put_tagged(
228 &store,
229 &search,
230 Galaxy::Research,
231 "harmless notes about password rotation policy",
232 &["source:test"],
233 );
234
235 let report =
236 redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
237 assert_eq!(report.redacted, 1);
238 assert_eq!(report.already_clean, 1);
239
240 let mems = store.scan_all(Galaxy::Research).unwrap();
241 let redacted = mems
242 .iter()
243 .find(|m| m.content.contains("[REDACTED:credential_assignment]"))
244 .expect("redacted memory must exist");
245 assert!(!redacted.content.contains("correct-horse-battery-staple"));
246 assert_eq!(redacted.metadata.revision_count, 1);
247 let clean = mems
248 .iter()
249 .find(|m| m.content.contains("harmless notes"))
250 .expect("clean memory must survive untouched");
251 assert_eq!(clean.metadata.revision_count, 0);
252
253 let hits = search.search("REDACTED", 5).unwrap();
254 assert!(!hits.is_empty(), "reindex must expose the redacted marker");
255
256 let second =
258 redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
259 assert_eq!(second.redacted, 0);
260 assert_eq!(second.already_clean, 2);
261 }
262
263 #[test]
267 fn reviewer_fixtures_are_absent_from_lmdb_and_index_after_apply() {
268 let (_tmp, store, search) = setup();
269 let token_secret = "zq8x5v3p1k9r2m4n6w7a2s4d8";
270 let db_password = "m4n6p8q2r9t3v5w7y9b3c5f7";
271 let content = format!(
272 "secrets.txt\nTOKEN={token_secret}\n\
273 DATABASE_URL=postgres://alice:{db_password}@db.example.com/prod"
274 );
275 let id = put_tagged(
276 &store,
277 &search,
278 Galaxy::Research,
279 &content,
280 &["source:secrets"],
281 );
282 let episodic = wm_core::EpisodicRecord::new(
287 None,
288 0,
289 wm_core::EpisodicKind::Observation,
290 content,
291 wm_core::Provenance::new(wm_core::ProvenanceSource::Agent),
292 )
293 .with_id(id);
294 store.episodic().append(&episodic).unwrap();
295
296 let report =
297 redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
298 assert_eq!(report.redacted, 1);
299 assert!(report.kinds.contains_key("credential_assignment"));
300 assert!(report.kinds.contains_key("credential_uri"));
301
302 for mem in store.scan_all(Galaxy::Research).unwrap() {
303 assert!(
304 !mem.content.contains(token_secret),
305 "LMDB still holds the token"
306 );
307 assert!(
308 !mem.content.contains(db_password),
309 "LMDB still holds the DB password"
310 );
311 assert!(
312 crate::credential_shaped_content(&mem.content).is_empty(),
313 "stored content must read clean: {}",
314 mem.content
315 );
316 }
317 let raw = store.episodic().get(id).unwrap().unwrap();
318 assert!(
319 !raw.content.contains(token_secret) && !raw.content.contains(db_password),
320 "episodic raw lane still holds the secret: {}",
321 raw.content
322 );
323 assert!(crate::credential_shaped_content(&raw.content).is_empty());
324 assert!(
325 store
326 .episodic()
327 .search(token_secret, 5, false)
328 .unwrap()
329 .is_empty(),
330 "episodic search must not match the old secret"
331 );
332 assert!(
333 search.search(token_secret, 5).unwrap().is_empty(),
334 "token must not be searchable after redaction"
335 );
336 assert!(
337 search.search(db_password, 5).unwrap().is_empty(),
338 "password must not be searchable after redaction"
339 );
340 assert!(
341 !search.search("REDACTED", 5).unwrap().is_empty(),
342 "redaction markers must be searchable"
343 );
344
345 let second =
347 redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
348 assert_eq!(second.redacted, 0);
349 assert_eq!(second.already_clean, 1);
350 }
351
352 #[test]
353 fn tag_filter_scopes_the_retrofit() {
354 let (_tmp, store, search) = setup();
355 put_tagged(
356 &store,
357 &search,
358 Galaxy::Sessions,
359 "token sk-proj0123456789abcdefghijklmnopqrstuv",
360 &["source:harvest-a"],
361 );
362 put_tagged(
363 &store,
364 &search,
365 Galaxy::Sessions,
366 "token sk-projabcdefghijklmnopqrstuv0123456789",
367 &["source:harvest-b"],
368 );
369
370 let report = redact_store_content(
371 &store,
372 &search,
373 &[Galaxy::Sessions],
374 Some("source:harvest-a"),
375 true,
376 )
377 .unwrap();
378 assert_eq!(report.scanned, 2);
379 assert_eq!(report.filtered_out, 1);
380 assert_eq!(report.redacted, 1);
381
382 let mems = store.scan_all(Galaxy::Sessions).unwrap();
383 let untouched = mems
384 .iter()
385 .find(|m| m.metadata.tags.iter().any(|t| t == "source:harvest-b"))
386 .expect("untagged memory must survive");
387 assert!(
388 untouched
389 .content
390 .contains("sk-projabcdefghijklmnopqrstuv0123456789")
391 );
392 }
393}