glass/browser/session/
snapshot.rs1use super::*;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::path::PathBuf;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9pub const SESSION_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
10const MAX_SNAPSHOT_BYTES: usize = 128 * 1024;
11pub fn default_session_snapshot_path(profile: &str) -> PathBuf {
12 std::env::var_os("GLASS_CONFIG_HOME")
13 .map(PathBuf::from)
14 .or_else(dirs::config_dir)
15 .unwrap_or_else(|| PathBuf::from("."))
16 .join("glass")
17 .join("snapshots")
18 .join(profile)
19}
20const MAX_SNAPSHOTS: usize = 64;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct SessionSnapshot {
25 pub schema_version: u32,
26 pub snapshot_id: String,
27 pub created_at: u64,
28 pub profile: String,
29 pub observation: SemanticObservation,
30}
31
32#[derive(Debug, Clone, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct SessionSnapshotDiff {
35 pub from_snapshot: String,
36 pub to_snapshot: String,
37 pub same_route: bool,
38 pub revision_delta: u64,
39 pub changed_regions: usize,
40 pub changed_targets: usize,
41}
42
43#[derive(Debug, Clone)]
44pub struct SessionSnapshotStore {
45 root: PathBuf,
46}
47
48impl SessionSnapshot {
49 pub fn from_observation(profile: impl Into<String>, observation: SemanticObservation) -> Self {
50 let mut observation = observation;
51 redact_observation(&mut observation);
52 let created_at = SystemTime::now()
53 .duration_since(UNIX_EPOCH)
54 .unwrap_or_default()
55 .as_secs();
56 let bytes = serde_json::to_vec(&observation).unwrap_or_default();
57 let digest = Sha256::digest(&bytes);
58 let short = digest[..6]
59 .iter()
60 .map(|byte| format!("{byte:02x}"))
61 .collect::<String>();
62 Self {
63 schema_version: SESSION_SNAPSHOT_SCHEMA_VERSION,
64 snapshot_id: format!("snap-{}-{short}", observation.revision),
65 created_at,
66 profile: profile.into(),
67 observation,
68 }
69 }
70
71 pub fn validate(&self) -> BrowserResult<()> {
72 if self.schema_version != SESSION_SNAPSHOT_SCHEMA_VERSION {
73 return Err(format!(
74 "unsupported session snapshot schema: {}",
75 self.schema_version
76 )
77 .into());
78 }
79 if self.snapshot_id.is_empty() || self.snapshot_id.len() > 128 {
80 return Err("snapshot ID must be 1..=128 bytes".into());
81 }
82 if self.profile.is_empty() || self.profile.len() > 128 {
83 return Err("snapshot profile must be 1..=128 bytes".into());
84 }
85 let bytes = serde_json::to_vec(self)?;
86 if bytes.len() > MAX_SNAPSHOT_BYTES {
87 return Err(format!("session snapshot exceeds {MAX_SNAPSHOT_BYTES} bytes").into());
88 }
89 Ok(())
90 }
91}
92
93impl SessionSnapshotStore {
94 pub fn new(root: impl Into<PathBuf>) -> Self {
95 Self { root: root.into() }
96 }
97
98 pub fn save(&self, snapshot: &SessionSnapshot) -> BrowserResult<()> {
99 snapshot.validate()?;
100 std::fs::create_dir_all(&self.root)?;
101 let path = self.path_for(&snapshot.snapshot_id)?;
102 let temp = path.with_extension("tmp");
103 std::fs::write(&temp, serde_json::to_vec_pretty(snapshot)?)?;
104 std::fs::rename(temp, path)?;
105 let mut files = self.files()?;
106 if files.len() > MAX_SNAPSHOTS {
107 files.sort_by_key(|path| std::fs::metadata(path).and_then(|m| m.modified()).ok());
108 let remove_count = files.len() - MAX_SNAPSHOTS;
109 for path in files.into_iter().take(remove_count) {
110 let _ = std::fs::remove_file(path);
111 }
112 }
113 Ok(())
114 }
115
116 pub fn load(&self, snapshot_id: &str) -> BrowserResult<SessionSnapshot> {
117 let path = self.path_for(snapshot_id)?;
118 let snapshot: SessionSnapshot = serde_json::from_slice(&std::fs::read(path)?)?;
119 snapshot.validate()?;
120 Ok(snapshot)
121 }
122
123 pub fn list(&self) -> BrowserResult<Vec<SessionSnapshot>> {
124 let mut snapshots = Vec::new();
125 for path in self.files()? {
126 if let Ok(snapshot) = serde_json::from_slice::<SessionSnapshot>(&std::fs::read(path)?)
127 && snapshot.validate().is_ok()
128 {
129 snapshots.push(snapshot);
130 }
131 }
132 snapshots.sort_by_key(|snapshot| (snapshot.created_at, snapshot.snapshot_id.clone()));
133 Ok(snapshots)
134 }
135
136 pub fn diff(&self, from: &str, to: &str) -> BrowserResult<SessionSnapshotDiff> {
137 let from_snapshot = self.load(from)?;
138 let to_snapshot = self.load(to)?;
139 let same_route = from_snapshot.observation.route == to_snapshot.observation.route;
140 let changed_regions = from_snapshot
141 .observation
142 .regions
143 .iter()
144 .zip(to_snapshot.observation.regions.iter())
145 .filter(|(left, right)| left != right)
146 .count()
147 + from_snapshot
148 .observation
149 .regions
150 .len()
151 .abs_diff(to_snapshot.observation.regions.len());
152 let changed_targets = from_snapshot
153 .observation
154 .regions
155 .iter()
156 .flat_map(|region| region.targets.iter())
157 .zip(
158 to_snapshot
159 .observation
160 .regions
161 .iter()
162 .flat_map(|region| region.targets.iter()),
163 )
164 .filter(|(left, right)| left != right)
165 .count();
166 Ok(SessionSnapshotDiff {
167 from_snapshot: from.into(),
168 to_snapshot: to.into(),
169 same_route,
170 revision_delta: to_snapshot
171 .observation
172 .revision
173 .saturating_sub(from_snapshot.observation.revision),
174 changed_regions,
175 changed_targets,
176 })
177 }
178
179 pub fn purge(&self) -> BrowserResult<usize> {
180 let mut removed = 0;
181 for path in self.files()? {
182 if std::fs::remove_file(path).is_ok() {
183 removed += 1;
184 }
185 }
186 Ok(removed)
187 }
188
189 fn files(&self) -> BrowserResult<Vec<PathBuf>> {
190 if !self.root.is_dir() {
191 return Ok(Vec::new());
192 }
193 Ok(std::fs::read_dir(&self.root)?
194 .filter_map(Result::ok)
195 .map(|entry| entry.path())
196 .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
197 .collect())
198 }
199
200 fn path_for(&self, snapshot_id: &str) -> BrowserResult<PathBuf> {
201 if snapshot_id.is_empty()
202 || snapshot_id.len() > 128
203 || !snapshot_id
204 .bytes()
205 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
206 {
207 return Err("snapshot ID contains invalid characters".into());
208 }
209 Ok(self.root.join(format!("{snapshot_id}.json")))
210 }
211}
212
213fn redact_observation(observation: &mut SemanticObservation) {
214 observation.text = None;
215 observation.accessibility = None;
216 observation.raw_accessibility = None;
217 observation.changes = None;
218 observation.page.url = redacted_url(&observation.page.url);
219 observation.page.title = redact_text(&observation.page.title);
220 observation.route.url = observation.page.url.clone();
221 for region in &mut observation.regions {
222 region.label = redact_text(®ion.label);
223 region.evidence = region
224 .evidence
225 .iter()
226 .map(|value| redact_text(value))
227 .collect();
228 for target in &mut region.targets {
229 target.name = redact_text(&target.name);
230 }
231 if let Some(expansion) = &mut region.expansion {
232 expansion.route.url = observation.route.url.clone();
233 }
234 }
235}
236
237fn redacted_url(url: &str) -> String {
238 url.split(['?', '#']).next().unwrap_or(url).to_string()
239}
240
241fn redact_text(value: &str) -> String {
242 let lower = value.to_ascii_lowercase();
243 if [
244 "password",
245 "passwd",
246 "token",
247 "secret",
248 "cookie",
249 "authorization",
250 ]
251 .iter()
252 .any(|term| lower.contains(term))
253 {
254 "[REDACTED]".into()
255 } else {
256 value.chars().take(512).collect()
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn snapshot_redacts_urls_and_sensitive_labels() {
266 let observation: SemanticObservation = serde_json::from_value(serde_json::json!({
267 "schemaVersion": 1, "revision": 2, "level": "structured",
268 "route": {"targetId":"t", "frameId":"f", "url":"https://x.test/?token=secret"},
269 "page": {"kind":"form", "title":"Password reset", "url":"https://x.test/?token=secret", "targetId":"t", "frameId":"f", "confidence":"high"},
270 "regions": [{"id":"main", "kind":"form", "label":"password form", "interactiveCount":1, "confidence":"high", "targets":[{"reference":"r2:b1", "role":"textbox", "name":"password"}]}],
271 "limits": {"truncated":false,"omittedRegions":0}
272 })).unwrap();
273 let snapshot = SessionSnapshot::from_observation("default", observation);
274 snapshot.validate().unwrap();
275 assert_eq!(snapshot.observation.page.url, "https://x.test/");
276 assert_eq!(
277 snapshot.observation.regions[0].targets[0].name,
278 "[REDACTED]"
279 );
280 assert!(serde_json::to_string(&snapshot).unwrap().len() < MAX_SNAPSHOT_BYTES);
281 }
282}