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