Skip to main content

cloudiful_redactor/session/
restore.rs

1use 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}