1#![allow(clippy::significant_drop_tightening)]
20
21use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24
25use crate::credentials::redact_credential_content;
26use crate::{MemoryStore, SearchEngine};
27use wm_core::{Galaxy, Result};
28
29#[derive(Debug, Default, Clone, Serialize, Deserialize)]
31pub struct GalaxyRedactStats {
32 pub galaxy: String,
33 pub scanned: usize,
34 pub redacted: usize,
35 pub already_clean: usize,
36 pub filtered_out: usize,
37}
38
39#[derive(Debug, Default, Clone, Serialize, Deserialize)]
41pub struct RedactReport {
42 pub scanned: usize,
43 pub redacted: usize,
44 pub already_clean: usize,
45 pub filtered_out: usize,
46 pub kinds: BTreeMap<String, usize>,
48 pub galaxies: Vec<GalaxyRedactStats>,
49}
50
51pub fn redact_store_content(
61 store: &MemoryStore,
62 search: &SearchEngine,
63 galaxies: &[Galaxy],
64 tag_filter: Option<&str>,
65 apply: bool,
66) -> Result<RedactReport> {
67 let mut report = RedactReport::default();
68 let mut writer = if apply { Some(search.writer()?) } else { None };
69
70 for galaxy in galaxies {
71 let mut stats = GalaxyRedactStats {
72 galaxy: galaxy.db_name().to_string(),
73 ..Default::default()
74 };
75
76 for mut mem in store.scan_all(*galaxy)? {
77 stats.scanned += 1;
78
79 if let Some(tag) = tag_filter {
80 if !mem.metadata.tags.iter().any(|t| t == tag) {
81 stats.filtered_out += 1;
82 continue;
83 }
84 }
85
86 let (scrubbed, kinds) = redact_credential_content(&mem.content);
87 if kinds.is_empty() {
88 stats.already_clean += 1;
89 continue;
90 }
91
92 stats.redacted += 1;
93 for kind in kinds {
94 *report.kinds.entry(kind.to_string()).or_default() += 1;
95 }
96
97 let Some(writer) = writer.as_mut() else {
98 continue;
99 };
100
101 let old_hash = mem.metadata.content_hash.clone();
102 mem.content = scrubbed;
103 mem.metadata.content_hash = crate::content_hash(&mem.content);
104 mem.metadata.revision_count = mem.metadata.revision_count.saturating_add(1);
105 store.put(*galaxy, &mem)?;
106 store.record_revision(
107 *galaxy,
108 mem.metadata.id,
109 &old_hash,
110 &mem.metadata.content_hash,
111 crate::revision::RevisionActor {
112 session: None,
113 user: Some("wm-redact-content".to_string()),
114 compartment: None,
115 },
116 )?;
117 let id_str = mem.metadata.id.to_string();
118 search.delete_document(writer, &id_str)?;
119 search.add_document(
120 writer,
121 &id_str,
122 galaxy.db_name(),
123 &mem.content,
124 &mem.metadata.tags,
125 mem.metadata.created_at.timestamp(),
126 )?;
127 }
128
129 report.scanned += stats.scanned;
130 report.redacted += stats.redacted;
131 report.already_clean += stats.already_clean;
132 report.filtered_out += stats.filtered_out;
133 report.galaxies.push(stats);
134 }
135
136 if let Some(mut writer) = writer {
137 search.commit(&mut writer)?;
138 }
139
140 Ok(report)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use crate::Memory;
147 use tempfile::tempdir;
148
149 fn setup() -> (tempfile::TempDir, MemoryStore, SearchEngine) {
150 let tmp = tempdir().unwrap();
151 let store = MemoryStore::open_default(tmp.path()).unwrap();
152 let tantivy_dir = tmp.path().join("tantivy");
153 std::fs::create_dir_all(&tantivy_dir).unwrap();
154 let search = SearchEngine::open(&tantivy_dir).unwrap();
155 (tmp, store, search)
156 }
157
158 fn put_tagged(
159 store: &MemoryStore,
160 search: &SearchEngine,
161 galaxy: Galaxy,
162 content: &str,
163 tags: &[&str],
164 ) -> uuid::Uuid {
165 let mut mem = Memory::new(galaxy, content.to_string());
166 mem.metadata.tags = tags.iter().map(std::string::ToString::to_string).collect();
167 mem.metadata.content_hash = crate::content_hash(content);
168 let id = mem.metadata.id;
169 store.put(galaxy, &mem).unwrap();
170 let mut writer = search.writer().unwrap();
171 search
172 .add_document(
173 &mut writer,
174 &id.to_string(),
175 galaxy.db_name(),
176 content,
177 &mem.metadata.tags,
178 mem.metadata.created_at.timestamp(),
179 )
180 .unwrap();
181 search.commit(&mut writer).unwrap();
182 id
183 }
184
185 #[test]
186 fn dry_run_reports_without_writing() {
187 let (_tmp, store, search) = setup();
188 let secret = "db password=correct-horse-battery-staple";
189 let id = put_tagged(&store, &search, Galaxy::Research, secret, &["source:test"]);
190
191 let report =
192 redact_store_content(&store, &search, &[Galaxy::Research], None, false).unwrap();
193 assert_eq!(report.redacted, 1);
194 assert_eq!(report.already_clean, 0);
195 assert!(report.kinds.contains_key("credential_assignment"));
196
197 let mem = store.get(Galaxy::Research, id).unwrap().unwrap();
198 assert_eq!(mem.content, secret, "dry run must not write");
199 assert_eq!(mem.metadata.revision_count, 0);
200 }
201
202 #[test]
203 fn apply_redacts_in_place_reindexes_and_is_idempotent() {
204 let (_tmp, store, search) = setup();
205 put_tagged(
206 &store,
207 &search,
208 Galaxy::Research,
209 "db password=correct-horse-battery-staple",
210 &["source:test"],
211 );
212 put_tagged(
213 &store,
214 &search,
215 Galaxy::Research,
216 "harmless notes about password rotation policy",
217 &["source:test"],
218 );
219
220 let report =
221 redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
222 assert_eq!(report.redacted, 1);
223 assert_eq!(report.already_clean, 1);
224
225 let mems = store.scan_all(Galaxy::Research).unwrap();
226 let redacted = mems
227 .iter()
228 .find(|m| m.content.contains("[REDACTED:credential_assignment]"))
229 .expect("redacted memory must exist");
230 assert!(!redacted.content.contains("correct-horse-battery-staple"));
231 assert_eq!(redacted.metadata.revision_count, 1);
232 let clean = mems
233 .iter()
234 .find(|m| m.content.contains("harmless notes"))
235 .expect("clean memory must survive untouched");
236 assert_eq!(clean.metadata.revision_count, 0);
237
238 let hits = search.search("REDACTED", 5).unwrap();
239 assert!(!hits.is_empty(), "reindex must expose the redacted marker");
240
241 let second =
243 redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
244 assert_eq!(second.redacted, 0);
245 assert_eq!(second.already_clean, 2);
246 }
247
248 #[test]
249 fn tag_filter_scopes_the_retrofit() {
250 let (_tmp, store, search) = setup();
251 put_tagged(
252 &store,
253 &search,
254 Galaxy::Sessions,
255 "token sk-proj0123456789abcdefghijklmnopqrstuv",
256 &["source:harvest-a"],
257 );
258 put_tagged(
259 &store,
260 &search,
261 Galaxy::Sessions,
262 "token sk-projabcdefghijklmnopqrstuv0123456789",
263 &["source:harvest-b"],
264 );
265
266 let report = redact_store_content(
267 &store,
268 &search,
269 &[Galaxy::Sessions],
270 Some("source:harvest-a"),
271 true,
272 )
273 .unwrap();
274 assert_eq!(report.scanned, 2);
275 assert_eq!(report.filtered_out, 1);
276 assert_eq!(report.redacted, 1);
277
278 let mems = store.scan_all(Galaxy::Sessions).unwrap();
279 let untouched = mems
280 .iter()
281 .find(|m| m.metadata.tags.iter().any(|t| t == "source:harvest-b"))
282 .expect("untagged memory must survive");
283 assert!(
284 untouched
285 .content
286 .contains("sk-projabcdefghijklmnopqrstuv0123456789")
287 );
288 }
289}