cloudiful_redactor/session/
state.rs1use std::collections::{HashMap, HashSet};
2
3use anyhow::{Result, anyhow};
4use serde::{Deserialize, Deserializer, Serialize};
5
6use crate::replace::parse_token;
7use crate::{RedactionSession, RestorationEntry, RestorePermit, RestoreResult};
8
9use super::permit::{PERMIT_VERSION, create_restore_permit};
10use super::{RestoreContext, StreamingRestoreContext};
11
12const SESSION_VERSION: u32 = 2;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub struct RestoreState {
16 session: RedactionSession,
17 permits: Vec<RestorePermit>,
18}
19
20impl RestoreState {
21 pub fn new(session: RedactionSession) -> Result<Self> {
22 validate_session(&session)?;
23 let permits = if session.issued_tokens.is_empty() {
24 Vec::new()
25 } else {
26 vec![create_restore_permit(&session)]
27 };
28 Self::from_parts(session, permits)
29 }
30
31 pub fn from_parts(session: RedactionSession, permits: Vec<RestorePermit>) -> Result<Self> {
32 validate_session(&session)?;
33 let permits = normalize_permits(&session, permits)?;
34 Ok(Self { session, permits })
35 }
36
37 pub fn advance(&self, session: RedactionSession) -> Result<Self> {
38 validate_session(&session)?;
39 validate_continuity(&self.session, &session)?;
40
41 let mut permits = self.permits.clone();
42 if !session.issued_tokens.is_empty() {
43 permits.push(create_restore_permit(&session));
44 }
45 Self::from_parts(session, permits)
46 }
47
48 pub fn session(&self) -> &RedactionSession {
49 &self.session
50 }
51
52 pub fn permits(&self) -> &[RestorePermit] {
53 &self.permits
54 }
55
56 pub fn restore_context(&self) -> Result<RestoreContext<'_>> {
57 RestoreContext::with_permits(&self.session, &self.permits)
58 }
59
60 pub fn streaming_restore_context(&self) -> Result<StreamingRestoreContext<'_>> {
61 StreamingRestoreContext::with_permits(&self.session, &self.permits)
62 }
63
64 pub fn restore_text(&self, text: &str) -> Result<RestoreResult> {
65 Ok(self.restore_context()?.restore_text(text))
66 }
67
68 pub fn has_authorized_tokens(&self) -> bool {
69 self.permits
70 .iter()
71 .any(|permit| !permit.issued_tokens.is_empty())
72 }
73}
74
75impl<'de> Deserialize<'de> for RestoreState {
76 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77 where
78 D: Deserializer<'de>,
79 {
80 #[derive(Deserialize)]
81 struct Parts {
82 session: RedactionSession,
83 permits: Vec<RestorePermit>,
84 }
85
86 let parts = Parts::deserialize(deserializer)?;
87 Self::from_parts(parts.session, parts.permits).map_err(serde::de::Error::custom)
88 }
89}
90
91fn validate_session(session: &RedactionSession) -> Result<()> {
92 if session.version != SESSION_VERSION {
93 return Err(anyhow!(
94 "unsupported redaction session version {}",
95 session.version
96 ));
97 }
98 if session.session_id.is_empty() {
99 return Err(anyhow!("redaction session ID must not be empty"));
100 }
101 if session.scope_id.is_empty() {
102 return Err(anyhow!("redaction session scope must not be empty"));
103 }
104
105 let mut entries = HashSet::new();
106 for entry in &session.entries {
107 if !entries.insert(entry.token.as_str()) {
108 return Err(anyhow!("duplicate session entry token `{}`", entry.token));
109 }
110 validate_entry(session, entry)?;
111 }
112
113 let mut issued = HashSet::new();
114 for token in &session.issued_tokens {
115 if !issued.insert(token.as_str()) {
116 return Err(anyhow!("duplicate issued token `{token}`"));
117 }
118 if !entries.contains(token.as_str()) {
119 return Err(anyhow!(
120 "issued token `{token}` is missing from session entries"
121 ));
122 }
123 }
124 Ok(())
125}
126
127fn validate_entry(session: &RedactionSession, entry: &RestorationEntry) -> Result<()> {
128 let parsed = parse_token(&entry.token)
129 .map_err(|error| anyhow!("invalid session entry token `{}`: {error}", entry.token))?;
130 if parsed.scope_id != session.scope_id {
131 return Err(anyhow!("session entry token scope does not match session"));
132 }
133 if parsed.kind != entry.kind {
134 return Err(anyhow!(
135 "session entry token kind does not match entry kind"
136 ));
137 }
138 Ok(())
139}
140
141fn normalize_permits(
142 session: &RedactionSession,
143 permits: Vec<RestorePermit>,
144) -> Result<Vec<RestorePermit>> {
145 let known = session
146 .entries
147 .iter()
148 .map(|entry| entry.token.as_str())
149 .collect::<HashSet<_>>();
150 let mut permit_ids = HashSet::new();
151 let mut authorized = HashSet::new();
152 let mut normalized = Vec::new();
153
154 for mut permit in permits {
155 validate_permit(session, &permit, &known, &mut permit_ids)?;
156 permit
157 .issued_tokens
158 .retain(|token| authorized.insert(token.clone()));
159 if !permit.issued_tokens.is_empty() {
160 normalized.push(permit);
161 }
162 }
163 Ok(normalized)
164}
165
166fn validate_permit(
167 session: &RedactionSession,
168 permit: &RestorePermit,
169 known: &HashSet<&str>,
170 permit_ids: &mut HashSet<String>,
171) -> Result<()> {
172 if permit.version != PERMIT_VERSION {
173 return Err(anyhow!(
174 "unsupported restore permit version {}",
175 permit.version
176 ));
177 }
178 if permit.permit_id.is_empty() {
179 return Err(anyhow!("restore permit ID must not be empty"));
180 }
181 if !permit_ids.insert(permit.permit_id.clone()) {
182 return Err(anyhow!(
183 "duplicate restore permit ID `{}`",
184 permit.permit_id
185 ));
186 }
187 if permit.scope_id != session.scope_id || permit.external_id != session.external_id {
188 return Err(anyhow!("restore permit does not match session context"));
189 }
190 for token in &permit.issued_tokens {
191 if !known.contains(token.as_str()) {
192 return Err(anyhow!("restore permit authorizes unknown token `{token}`"));
193 }
194 }
195 Ok(())
196}
197
198fn validate_continuity(previous: &RedactionSession, next: &RedactionSession) -> Result<()> {
199 if previous.version != next.version {
200 return Err(anyhow!("redaction session version changed"));
201 }
202 if previous.scope_id != next.scope_id {
203 return Err(anyhow!("redaction session scope changed"));
204 }
205 if previous.external_id != next.external_id {
206 return Err(anyhow!("redaction session external ID changed"));
207 }
208
209 let next_entries = next
210 .entries
211 .iter()
212 .map(|entry| (entry.token.as_str(), entry))
213 .collect::<HashMap<_, _>>();
214 for old in &previous.entries {
215 let Some(new) = next_entries.get(old.token.as_str()) else {
216 return Err(anyhow!("new session removed entry `{}`", old.token));
217 };
218 if old.original != new.original
219 || old.kind != new.kind
220 || old.replacement_hint != new.replacement_hint
221 {
222 return Err(anyhow!("new session changed entry mapping `{}`", old.token));
223 }
224 if new.occurrences < old.occurrences {
225 return Err(anyhow!(
226 "new session reduced occurrences for `{}`",
227 old.token
228 ));
229 }
230 }
231 Ok(())
232}