Skip to main content

cloudiful_redactor/session/
restore.rs

1use std::collections::{HashMap, HashSet};
2
3use anyhow::{Result, anyhow};
4
5use crate::replace::{is_v2_token_like, parse_token, token_like_ranges};
6use crate::types::{RedactionSession, RestoreResult};
7
8#[derive(Debug)]
9pub struct RestoreContext<'a> {
10    session: &'a RedactionSession,
11    known_tokens: HashSet<&'a str>,
12    token_map: HashMap<&'a str, &'a str>,
13}
14
15impl<'a> RestoreContext<'a> {
16    pub fn new(session: &'a RedactionSession) -> Self {
17        let known_tokens = session
18            .entries
19            .iter()
20            .map(|entry| entry.token.as_str())
21            .collect();
22        let token_map = session
23            .entries
24            .iter()
25            .map(|entry| (entry.token.as_str(), entry.original.as_str()))
26            .collect();
27        Self {
28            session,
29            known_tokens,
30            token_map,
31        }
32    }
33
34    pub fn restore_text(&self, input: &str) -> RestoreResult {
35        restore_text(input, self.session, &self.known_tokens, &self.token_map)
36    }
37}
38
39pub fn restore_text_with_session(input: &str, session: &RedactionSession) -> RestoreResult {
40    RestoreContext::new(session).restore_text(input)
41}
42
43fn restore_text(
44    input: &str,
45    session: &RedactionSession,
46    known_tokens: &HashSet<&str>,
47    token_map: &HashMap<&str, &str>,
48) -> RestoreResult {
49    let mut restored_text = String::with_capacity(input.len());
50    let mut restored_count = 0;
51    let mut validation_errors = Vec::new();
52    let mut cursor = 0;
53
54    for token_range in token_like_ranges(input) {
55        restored_text.push_str(&input[cursor..token_range.start]);
56        let candidate = &input[token_range.clone()];
57        match parse_token(candidate) {
58            Ok(parsed) if parsed.scope_id != session.scope_id => {
59                validation_errors.push(format!(
60                    "token `{candidate}` does not belong to session scope `{}`",
61                    session.scope_id
62                ));
63                restored_text.push_str(candidate);
64            }
65            Ok(_) if !known_tokens.contains(candidate) => {
66                validation_errors.push(format!("unknown token `{candidate}`"));
67                restored_text.push_str(candidate);
68            }
69            Ok(_) => {
70                if let Some(original) = token_map.get(candidate) {
71                    restored_text.push_str(original);
72                    restored_count += 1;
73                } else {
74                    restored_text.push_str(candidate);
75                }
76            }
77            Err(error) => {
78                validation_errors.push(format!("malformed token `{candidate}`: {error}"));
79                restored_text.push_str(candidate);
80            }
81        }
82        cursor = token_range.end;
83    }
84    restored_text.push_str(&input[cursor..]);
85
86    let unresolved_tokens = token_like_ranges(&restored_text)
87        .into_iter()
88        .map(|range| restored_text[range].to_string())
89        .filter(|candidate| is_v2_token_like(candidate))
90        .collect::<Vec<_>>();
91
92    if !unresolved_tokens.is_empty() {
93        validation_errors.extend(
94            unresolved_tokens
95                .iter()
96                .map(|candidate| format!("unresolved token remained after restore: `{candidate}`")),
97        );
98    }
99
100    RestoreResult {
101        restored_text,
102        restored_count,
103        unresolved_tokens,
104        validation_errors,
105    }
106}
107
108pub fn restore_patch_with_session(patch: &str, session: &RedactionSession) -> RestoreResult {
109    restore_text_with_session(patch, session)
110}
111
112pub fn ensure_restore_valid(result: &RestoreResult) -> Result<()> {
113    if result.is_valid() {
114        return Ok(());
115    }
116
117    let mut messages = Vec::new();
118    if !result.validation_errors.is_empty() {
119        messages.extend(result.validation_errors.clone());
120    }
121    if !result.unresolved_tokens.is_empty() {
122        messages.push(format!(
123            "unresolved tokens: {}",
124            result.unresolved_tokens.join(", ")
125        ));
126    }
127    Err(anyhow!(messages.join("; ")))
128}
129
130#[cfg(test)]
131mod tests {
132    use super::{RestoreContext, restore_text_with_session};
133    use crate::{FindingKind, RedactionPolicy, Redactor, RedactorBuilder};
134
135    fn domain_redactor() -> Redactor {
136        RedactorBuilder::new()
137            .with_redaction_policy(
138                RedactionPolicy::default()
139                    .with_kind(FindingKind::Domain, true)
140                    .with_kind(FindingKind::Secret, true)
141                    .with_kind(FindingKind::Url, true),
142            )
143            .build()
144    }
145
146    #[test]
147    fn restore_streams_multiple_tokens_and_repetitions() {
148        let redactor = domain_redactor();
149        let text = "host=service.example.com alt=service.example.com";
150        let session = redactor.redact_with_session(text).expect("session");
151
152        let restored = restore_text_with_session(&session.redacted_text, &session);
153
154        assert!(restored.is_valid());
155        assert_eq!(restored.restored_text, text);
156        assert_eq!(restored.restored_count, 2);
157    }
158
159    #[test]
160    fn restore_context_reuses_session_index_across_fragments() {
161        let redactor = domain_redactor();
162        let session = redactor
163            .redact_with_session("first.example.com second.example.com")
164            .expect("session");
165        let context = RestoreContext::new(&session);
166
167        for entry in &session.entries {
168            let restored = context.restore_text(&entry.token);
169            assert!(restored.is_valid());
170            assert_eq!(restored.restored_text, entry.original);
171        }
172    }
173
174    #[test]
175    fn restore_preserves_unknown_token_validation() {
176        let redactor = domain_redactor();
177        let session = redactor
178            .redact_with_session("host=service.example.com")
179            .expect("session");
180        let unknown = crate::replace::format_token(&session.scope_id, FindingKind::Domain, 999);
181        let restored = restore_text_with_session(
182            &format!("{} {}", session.entries[0].token, unknown),
183            &session,
184        );
185
186        assert!(restored.validation_errors.iter().any(
187            |message| message.contains("unknown token") || message.contains("unresolved token")
188        ));
189        assert_eq!(restored.unresolved_tokens, vec![unknown]);
190    }
191
192    #[test]
193    fn restore_rejects_scope_mismatch() {
194        let redactor = domain_redactor();
195        let left = redactor
196            .redact_with_session("host=service.example.com")
197            .expect("left session");
198        let right = redactor
199            .redact_with_session("host=backup.example.com")
200            .expect("right session");
201        let restored = restore_text_with_session(&left.redacted_text, &right);
202
203        assert!(!restored.is_valid());
204        assert!(
205            restored
206                .validation_errors
207                .iter()
208                .any(|message| message.contains("does not belong to session scope"))
209        );
210    }
211}