cloudiful_redactor/session/
restore.rs1use 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}