Skip to main content

wm_memory/
redact.rs

1//! Store-wide credential redaction retrofit.
2//!
3//! `wm ingest --redact` redacts at ingest time, but content written before
4//! that flag existed (or by other write paths) can carry credential-shaped
5//! spans. A ledger entry makes the source file "unchanged", so a re-ingest
6//! cannot re-scrub it: the stored rows themselves must be rewritten.
7//!
8//! This pass mirrors [`crate::reindex::repair_content`]: matching rows are
9//! rewritten under the SAME id (content + recomputed content_hash) through
10//! `MemoryStore::put`, chained into the revision history as a content change,
11//! and delete-then-add reindexed. `apply = false` reports without writing.
12//!
13//! The caller must hold the writer lock (no writable serve on the store);
14//! a fresh `wm backup` before applying is the operator's responsibility.
15
16// The writer must stay alive for the whole pass (one commit at the end);
17// tightening its drop scope mid-scan is exactly what the lint suggests and
18// exactly what would release the index lock early.
19#![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/// Per-galaxy redaction outcome.
30#[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/// Aggregate redaction outcome.
40#[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    /// Credential kind → number of memories in which it fired.
47    pub kinds: BTreeMap<String, usize>,
48    pub galaxies: Vec<GalaxyRedactStats>,
49}
50
51/// Redact credential-shaped spans across `galaxies`.
52///
53/// When `tag_filter` is set, only memories carrying that exact tag are
54/// considered (all others count as `filtered_out`). When `apply` is false,
55/// the pass classifies and reports without writing or reindexing anything.
56///
57/// # Errors
58/// Propagates store/index errors; LMDB rows commit per-put and the index
59/// commits once at the end (take a backup before applying).
60pub 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        // Second pass is a no-op.
242        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}