Skip to main content

glass/browser/session/
snapshot.rs

1//! Versioned, redacted session snapshots for deterministic local inspection.
2
3use 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(&region.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}