snss/lib.rs
1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
2//! `snss` — a read-only decoder for Chromium/Brave SNSS session files.
3//!
4//! The crate is a pure decoder: it reads bytes and returns a typed model. It has
5//! no UI, performs no clipboard or launch side effects, and exposes **no write
6//! path** — mutating Brave's store is structurally impossible through this API.
7//!
8//! Milestone 1 (this module) covers the container framing only: validate the
9//! `SNSS` header and split the command stream into [`Record`]s. Higher layers
10//! (Pickle decode, replay) build on top of these records.
11
12use std::collections::{BTreeMap, HashMap};
13use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17/// The 4-byte magic every SNSS file begins with.
18pub const MAGIC: [u8; 4] = *b"SNSS";
19
20/// The only container version observed in the wild (and the only one supported).
21pub const SUPPORTED_VERSION: i32 = 3;
22
23/// One command record from the append-only stream.
24///
25/// `payload` is the raw bytes following the command id — for navigation commands
26/// this is a Chromium `Pickle` (including its own 4-byte length header), decoded
27/// in a later milestone.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Record {
30 /// The command id (e.g. 6 = `UpdateTabNavigation` in the `Session_*` dialect).
31 pub id: u8,
32 /// Raw payload bytes (everything after the id, `size - 1` bytes long).
33 pub payload: Vec<u8>,
34}
35
36/// A non-fatal decode anomaly. The model is still usable; warnings record where
37/// and why something was skipped so nothing fails silently.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Warning {
40 /// The stream ended early at this byte offset: a zero size marker or a record
41 /// whose declared size runs past EOF. Normal — Brave appends to live files, so
42 /// the final record can be half-written. Parsing stops cleanly here.
43 TruncatedTail { offset: u64 },
44 /// A navigation record (at this index in the stream) failed to decode and was
45 /// skipped during replay. Surfaced, never silently dropped.
46 BadNavigation { record: usize, error: PickleError },
47 /// A session file in the profile directory could not be read or decoded. The
48 /// other sources remain usable; this records which file and why.
49 UnreadableSource { path: String, reason: String },
50}
51
52/// The result of reading a record stream: the container version, every decoded
53/// [`Record`] in stream order, and any non-fatal [`Warning`]s.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct RecordStream {
56 /// Container version from the header (always [`SUPPORTED_VERSION`] today).
57 pub version: i32,
58 /// Records in stream (append) order.
59 pub records: Vec<Record>,
60 /// Non-fatal anomalies encountered while decoding.
61 pub warnings: Vec<Warning>,
62}
63
64/// A fatal error that prevents producing any model at all.
65#[derive(Debug)]
66pub enum SnssError {
67 /// The first four bytes were not `SNSS`.
68 BadMagic([u8; 4]),
69 /// The header declared a container version this decoder does not support.
70 UnsupportedVersion(i32),
71 /// An I/O error reading the header (record-stream truncation is *not* an
72 /// error — it is reported as a [`WarningKind::TruncatedTail`]).
73 Io(std::io::Error),
74}
75
76impl std::fmt::Display for SnssError {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 SnssError::BadMagic(got) => {
80 write!(f, "not an SNSS file: expected magic {MAGIC:?}, got {got:?}")
81 }
82 SnssError::UnsupportedVersion(v) => {
83 write!(
84 f,
85 "unsupported SNSS version {v} (only {SUPPORTED_VERSION} is supported)"
86 )
87 }
88 SnssError::Io(e) => write!(f, "I/O error reading SNSS header: {e}"),
89 }
90 }
91}
92
93impl std::error::Error for SnssError {
94 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
95 match self {
96 SnssError::Io(e) => Some(e),
97 _ => None,
98 }
99 }
100}
101
102impl From<std::io::Error> for SnssError {
103 fn from(e: std::io::Error) -> Self {
104 SnssError::Io(e)
105 }
106}
107
108/// Read an SNSS command stream from any byte source.
109///
110/// The reader is consumed fully into memory first by the caller's `reader`; this
111/// function validates the `SNSS` header, then splits the remaining bytes into
112/// [`Record`]s. A truncated tail (zero size marker or a length that overruns EOF)
113/// terminates parsing gracefully and is reported as a [`Warning`], never an error.
114///
115/// # Errors
116/// Returns [`SnssError::BadMagic`] / [`SnssError::UnsupportedVersion`] for a header
117/// that is not a supported SNSS file, or [`SnssError::Io`] if the header cannot be
118/// read.
119pub fn read_records<R: Read>(mut reader: R) -> Result<RecordStream, SnssError> {
120 let mut buf = Vec::new();
121 reader.read_to_end(&mut buf)?;
122
123 // Header: 4-byte magic + int32 LE version.
124 if buf.len() < 8 {
125 let mut got = [0u8; 4];
126 let n = buf.len().min(4);
127 got[..n].copy_from_slice(&buf[..n]);
128 return Err(SnssError::BadMagic(got));
129 }
130 // `buf.len() >= 8` is guaranteed above, so both slices are exactly 4 bytes;
131 // the fallbacks are unreachable defence-in-depth, not behavior changes.
132 let magic: [u8; 4] = buf[0..4].try_into().unwrap_or([0u8; 4]);
133 if magic != MAGIC {
134 return Err(SnssError::BadMagic(magic));
135 }
136 let version = i32::from_le_bytes(buf[4..8].try_into().unwrap_or([0u8; 4]));
137 if version != SUPPORTED_VERSION {
138 return Err(SnssError::UnsupportedVersion(version));
139 }
140
141 let mut records = Vec::new();
142 let mut warnings = Vec::new();
143 let mut off = 8usize;
144 let len = buf.len();
145
146 loop {
147 // Need a full 2-byte size field to continue.
148 if off + 2 > len {
149 if off < len {
150 // A stray partial byte that is not a complete size field.
151 warnings.push(Warning::TruncatedTail { offset: off as u64 });
152 }
153 break;
154 }
155 let size = u16::from_le_bytes([buf[off], buf[off + 1]]) as usize;
156 let body = off + 2;
157 // A zero size marker, or a record whose body runs past EOF, is the
158 // normal half-written tail Brave leaves behind. Stop cleanly.
159 if size == 0 || body + size > len {
160 warnings.push(Warning::TruncatedTail { offset: off as u64 });
161 break;
162 }
163 // size counts id (1 byte) + payload (size - 1 bytes).
164 let id = buf[body];
165 let payload = buf[body + 1..body + size].to_vec();
166 records.push(Record { id, payload });
167 off = body + size;
168 }
169
170 Ok(RecordStream {
171 version,
172 records,
173 warnings,
174 })
175}
176
177// ----------------------------------------------------------------------------
178// Milestone 2 — Pickle decode of the UpdateTabNavigation payload (DESIGN.md §1.3)
179// ----------------------------------------------------------------------------
180
181/// A decoded `UpdateTabNavigation` command: which tab, which back/forward
182/// position, and the URL + title recorded at that position.
183///
184/// `tab_id` groups entries into a tab (the replay engine uses it in a later
185/// milestone); `index` is the position within that tab's history.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct NavCommand {
188 /// SessionID grouping entries into one tab.
189 pub tab_id: i32,
190 /// Position in the tab's back/forward history.
191 pub index: i32,
192 /// The page URL (lossily decoded UTF-8; never panics on bad bytes).
193 pub url: String,
194 /// The page title (lossily decoded UTF-16-LE; never panics on bad bytes).
195 pub title: String,
196}
197
198/// A malformed navigation payload. Surfaced as a typed error so the caller can
199/// count it as a warning rather than crash or emit a silently-wrong row.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum PickleError {
202 /// The payload is too short to even hold the 4-byte Pickle length header.
203 TooShort,
204 /// The Pickle's declared payload size exceeds the bytes actually present.
205 BadHeader { declared: usize, actual: usize },
206 /// A field's length runs past the end of the Pickle.
207 Overrun,
208 /// A length prefix was negative (corrupt).
209 BadLength(i32),
210}
211
212impl std::fmt::Display for PickleError {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 match self {
215 PickleError::TooShort => write!(f, "payload too short for a Pickle header"),
216 PickleError::BadHeader { declared, actual } => {
217 write!(
218 f,
219 "Pickle declares {declared} payload bytes but only {actual} present"
220 )
221 }
222 PickleError::Overrun => write!(f, "a Pickle field runs past the end of the payload"),
223 PickleError::BadLength(n) => write!(f, "negative Pickle length prefix: {n}"),
224 }
225 }
226}
227
228impl std::error::Error for PickleError {}
229
230/// Decode an `UpdateTabNavigation` payload into a [`NavCommand`].
231///
232/// `payload` is the raw bytes after the command id (i.e. the [`Record::payload`]),
233/// which begin with the Chromium Pickle's own 4-byte length header. Fields are
234/// 4-byte aligned; `string16` lengths are UTF-16 code-unit counts, not bytes.
235///
236/// Malformed input yields a [`PickleError`] — never a panic — so a single bad
237/// record degrades to a counted warning, not a crash or a wrong value.
238///
239/// # Errors
240/// See [`PickleError`].
241pub fn decode_navigation(payload: &[u8]) -> Result<NavCommand, PickleError> {
242 let mut p = Pickle::new(payload)?;
243 let tab_id = p.read_i32()?;
244 let index = p.read_i32()?;
245 let url = p.read_string()?;
246 let title = p.read_string16()?;
247 Ok(NavCommand {
248 tab_id,
249 index,
250 url,
251 title,
252 })
253}
254
255/// A cursor over a Chromium `Pickle`: a 4-byte LE length header followed by
256/// 4-byte-aligned fields. Internal: the only public entry point is the
257/// type-safe [`decode_navigation`], so a caller cannot read fields in the wrong
258/// order or forget the alignment rule. Every read is bounds-checked — reads
259/// never panic, they return [`PickleError`].
260struct Pickle<'a> {
261 data: &'a [u8],
262 /// Cursor measured from the start of `data` (i.e. including the 4-byte
263 /// header), so alignment is relative to the Pickle start, as Chromium does.
264 cursor: usize,
265}
266
267impl<'a> Pickle<'a> {
268 fn new(payload: &'a [u8]) -> Result<Self, PickleError> {
269 if payload.len() < 4 {
270 return Err(PickleError::TooShort);
271 }
272 // `payload.len() >= 4` guaranteed above; the slice is exactly 4 bytes.
273 let declared = u32::from_le_bytes(payload[0..4].try_into().unwrap_or([0u8; 4])) as usize;
274 let actual = payload.len() - 4;
275 if declared > actual {
276 return Err(PickleError::BadHeader { declared, actual });
277 }
278 Ok(Pickle {
279 data: payload,
280 cursor: 4,
281 })
282 }
283
284 /// Advance the cursor to the next 4-byte boundary (Chromium aligns every
285 /// variable-length read up to a 4-byte multiple).
286 fn align(&mut self) {
287 let rem = self.cursor % 4;
288 if rem != 0 {
289 self.cursor += 4 - rem;
290 }
291 }
292
293 fn read_i32(&mut self) -> Result<i32, PickleError> {
294 let end = self.cursor.checked_add(4).ok_or(PickleError::Overrun)?;
295 if end > self.data.len() {
296 return Err(PickleError::Overrun);
297 }
298 // `end - self.cursor == 4` and `end <= len` guaranteed above.
299 let v = i32::from_le_bytes(self.data[self.cursor..end].try_into().unwrap_or([0u8; 4]));
300 self.cursor = end; // i32 reads are inherently 4-aligned
301 Ok(v)
302 }
303
304 /// A length-prefixed UTF-8 string, padded to a 4-byte boundary. Decoded
305 /// lossily so invalid bytes become U+FFFD rather than crashing or hiding.
306 fn read_string(&mut self) -> Result<String, PickleError> {
307 let len = self.read_len()?;
308 let end = self.cursor.checked_add(len).ok_or(PickleError::Overrun)?;
309 if end > self.data.len() {
310 return Err(PickleError::Overrun);
311 }
312 let s = String::from_utf8_lossy(&self.data[self.cursor..end]).into_owned();
313 self.cursor = end;
314 self.align();
315 Ok(s)
316 }
317
318 /// A length-prefixed UTF-16-LE string. The prefix counts code *units*, not
319 /// bytes; the byte run is padded to a 4-byte boundary. Decoded lossily.
320 fn read_string16(&mut self) -> Result<String, PickleError> {
321 let units = self.read_len()?;
322 let nbytes = units.checked_mul(2).ok_or(PickleError::Overrun)?;
323 let end = self
324 .cursor
325 .checked_add(nbytes)
326 .ok_or(PickleError::Overrun)?;
327 if end > self.data.len() {
328 return Err(PickleError::Overrun);
329 }
330 let u16s: Vec<u16> = self.data[self.cursor..end]
331 .chunks_exact(2)
332 .map(|c| u16::from_le_bytes([c[0], c[1]]))
333 .collect();
334 self.cursor = end;
335 self.align();
336 Ok(String::from_utf16_lossy(&u16s))
337 }
338
339 /// Read a non-negative length prefix.
340 fn read_len(&mut self) -> Result<usize, PickleError> {
341 let n = self.read_i32()?;
342 if n < 0 {
343 return Err(PickleError::BadLength(n));
344 }
345 Ok(n as usize)
346 }
347}
348
349// ----------------------------------------------------------------------------
350// Milestone 3 — replay the command log into a Window/Tab/Nav tree (DESIGN.md §1.4)
351// ----------------------------------------------------------------------------
352
353/// Which command-id mapping a file uses. `Session_*`/`Apps_*` files and the
354/// recently-closed `Tabs_*` files number their commands differently.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum Dialect {
357 /// Live/last windows and PWA apps (`Session_*`, `Apps_*`): nav = cmd 6.
358 Session,
359 /// Recently-closed restore list (`Tabs_*`): nav = cmd 1.
360 Tabs,
361}
362
363impl Dialect {
364 /// Command id of `UpdateTabNavigation` in this dialect.
365 fn nav_id(self) -> u8 {
366 match self {
367 Dialect::Session => 6,
368 Dialect::Tabs => 1,
369 }
370 }
371 /// Command id carrying the selected navigation index in this dialect.
372 fn selected_id(self) -> u8 {
373 match self {
374 Dialect::Session => 7,
375 Dialect::Tabs => 4,
376 }
377 }
378}
379
380/// One back/forward history entry of a tab.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct Nav {
383 /// Position in the tab's history (as stored on disk).
384 pub index: i32,
385 /// Page URL.
386 pub url: String,
387 /// Page title.
388 pub title: String,
389}
390
391/// A reconstructed tab: its history and which entry is current.
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct Tab {
394 /// SessionID for this tab.
395 pub id: i32,
396 /// Whether the tab is pinned (Chrome shows pinned tabs first).
397 pub pinned: bool,
398 /// Position **within [`Tab::history`]** of the current entry (already
399 /// resolved from the selected-navigation-index command, or the last entry).
400 pub current: usize,
401 /// History entries in ascending on-disk index order, deduplicated so only the
402 /// latest append for each index survives.
403 pub history: Vec<Nav>,
404}
405
406impl Tab {
407 /// The current navigation entry (never panics; `history` is always non-empty
408 /// for tabs the replay emits, and `current` is always in range).
409 pub fn current_nav(&self) -> &Nav {
410 &self.history[self.current]
411 }
412}
413
414/// A reconstructed window holding ordered tabs.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct Window {
417 /// SessionID for this window (0 for the synthetic window holding closed tabs).
418 pub id: i32,
419 /// Tabs in left-to-right order (pinned tabs sort first, as on disk).
420 pub tabs: Vec<Tab>,
421 /// Most recent tab activity in this window, if any timestamps were present.
422 pub last_active: Option<SystemTime>,
423}
424
425/// The result of replaying one file's command log.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct Replayed {
428 /// Windows in ascending id order.
429 pub windows: Vec<Window>,
430 /// Non-fatal anomalies (e.g. a navigation record that failed to decode).
431 pub warnings: Vec<Warning>,
432}
433
434// Raw POD command ids that are identical across the Session/Apps dialect.
435const CMD_SET_TAB_WINDOW: u8 = 0;
436const CMD_TAB_INDEX_IN_WINDOW: u8 = 2;
437const CMD_SET_PINNED_STATE: u8 = 12;
438const CMD_LAST_ACTIVE_TIME: u8 = 21;
439
440/// Seconds between the Windows epoch (1601-01-01) and the Unix epoch (1970-01-01).
441const WINDOWS_EPOCH_OFFSET_SECS: i64 = 11_644_473_600;
442
443/// Replay an append-only command [`RecordStream`] into the logical
444/// [`Window`]/[`Tab`]/[`Nav`] tree, applying last-write-wins per `(tab, index)`
445/// and resolving each tab's current entry and pinned state.
446pub fn replay(stream: &RecordStream, dialect: Dialect) -> Replayed {
447 let nav_id = dialect.nav_id();
448 let selected_id = dialect.selected_id();
449
450 // tab_id -> (index -> Nav). BTreeMap on the inner key keeps history sorted and
451 // gives last-write-wins: a later append for the same index overwrites.
452 let mut histories: BTreeMap<i32, BTreeMap<i32, Nav>> = BTreeMap::new();
453 let mut tab_window: HashMap<i32, i32> = HashMap::new();
454 let mut tab_order: HashMap<i32, i32> = HashMap::new();
455 let mut tab_selected: HashMap<i32, i32> = HashMap::new();
456 let mut tab_pinned: HashMap<i32, bool> = HashMap::new();
457 let mut tab_time: HashMap<i32, i64> = HashMap::new();
458 let mut warnings = Vec::new();
459
460 for (i, rec) in stream.records.iter().enumerate() {
461 if rec.id == nav_id {
462 match decode_navigation(&rec.payload) {
463 Ok(n) => {
464 histories.entry(n.tab_id).or_default().insert(
465 n.index,
466 Nav {
467 index: n.index,
468 url: n.url,
469 title: n.title,
470 },
471 );
472 }
473 Err(error) => warnings.push(Warning::BadNavigation { record: i, error }),
474 }
475 continue;
476 }
477 if rec.id == selected_id {
478 if let Some((tab, idx)) = pod_pair(&rec.payload) {
479 tab_selected.insert(tab, idx);
480 }
481 continue;
482 }
483 // The remaining commands only carry meaning in the Session/Apps dialect;
484 // the Tabs dialect reuses these ids for unrelated commands.
485 if dialect == Dialect::Session {
486 match rec.id {
487 CMD_SET_TAB_WINDOW => {
488 if let Some((window, tab)) = pod_pair(&rec.payload) {
489 tab_window.insert(tab, window);
490 }
491 }
492 CMD_TAB_INDEX_IN_WINDOW => {
493 if let Some((tab, idx)) = pod_pair(&rec.payload) {
494 tab_order.insert(tab, idx);
495 }
496 }
497 CMD_SET_PINNED_STATE => {
498 if let Some((tab, pinned)) = pod_pinned(&rec.payload) {
499 tab_pinned.insert(tab, pinned);
500 }
501 }
502 CMD_LAST_ACTIVE_TIME => {
503 if let Some((tab, time)) = pod_last_active(&rec.payload) {
504 tab_time.insert(tab, time);
505 }
506 }
507 _ => {}
508 }
509 }
510 }
511
512 // Build tabs, grouped into windows. The Tabs dialect has no window mapping, so
513 // every closed tab lands in a single synthetic window (id 0).
514 let mut window_tabs: BTreeMap<i32, Vec<(i32, Tab)>> = BTreeMap::new();
515 for (tab_id, idx_map) in histories {
516 let history: Vec<Nav> = idx_map.into_values().collect();
517 if history.is_empty() {
518 continue; // cov:unreachable: every histories key is created by inserting a Nav, so its idx_map is never empty
519 }
520 let current = match tab_selected.get(&tab_id) {
521 Some(sel) => history
522 .iter()
523 .position(|n| n.index == *sel)
524 .unwrap_or(history.len() - 1),
525 None => history.len() - 1,
526 };
527 let tab = Tab {
528 id: tab_id,
529 pinned: tab_pinned.get(&tab_id).copied().unwrap_or(false),
530 current,
531 history,
532 };
533 let window_id = tab_window.get(&tab_id).copied().unwrap_or(0);
534 let order = tab_order.get(&tab_id).copied().unwrap_or(i32::MAX);
535 window_tabs.entry(window_id).or_default().push((order, tab));
536 }
537
538 let windows = window_tabs
539 .into_iter()
540 .map(|(id, mut ordered)| {
541 // Order tabs by TabIndexInWindow, then tab id for stability.
542 ordered.sort_by_key(|(order, tab)| (*order, tab.id));
543 let tabs: Vec<Tab> = ordered.into_iter().map(|(_, t)| t).collect();
544 let last_active = tabs
545 .iter()
546 .filter_map(|t| tab_time.get(&t.id).copied())
547 .max()
548 .and_then(windows_micros_to_system_time);
549 Window {
550 id,
551 tabs,
552 last_active,
553 }
554 })
555 .collect();
556
557 Replayed { windows, warnings }
558}
559
560/// Read a raw two-`i32` POD payload (SetTabWindow, TabIndexInWindow, selected nav).
561fn pod_pair(payload: &[u8]) -> Option<(i32, i32)> {
562 if payload.len() < 8 {
563 return None;
564 }
565 let a = i32::from_le_bytes(payload[0..4].try_into().ok()?);
566 let b = i32::from_le_bytes(payload[4..8].try_into().ok()?);
567 Some((a, b))
568}
569
570/// Read a SetPinnedState payload: `{tab_id: i32, pinned: bool}`.
571fn pod_pinned(payload: &[u8]) -> Option<(i32, bool)> {
572 if payload.len() < 5 {
573 return None;
574 }
575 let tab = i32::from_le_bytes(payload[0..4].try_into().ok()?);
576 Some((tab, payload[4] != 0))
577}
578
579/// Read a LastActiveTime payload: `{tab_id: i32, _pad: i32, time: i64}` where
580/// `time` is microseconds since the Windows epoch.
581fn pod_last_active(payload: &[u8]) -> Option<(i32, i64)> {
582 if payload.len() < 16 {
583 return None;
584 }
585 let tab = i32::from_le_bytes(payload[0..4].try_into().ok()?);
586 let time = i64::from_le_bytes(payload[8..16].try_into().ok()?);
587 Some((tab, time))
588}
589
590/// Convert Windows-epoch microseconds to a [`SystemTime`], or `None` for a zero
591/// or pre-Unix-epoch value (which would be meaningless as a last-active stamp).
592fn windows_micros_to_system_time(micros: i64) -> Option<SystemTime> {
593 let unix_micros = micros.checked_sub(WINDOWS_EPOCH_OFFSET_SECS.checked_mul(1_000_000)?)?;
594 if unix_micros <= 0 {
595 return None;
596 }
597 Some(UNIX_EPOCH + Duration::from_micros(unix_micros as u64))
598}
599
600// ----------------------------------------------------------------------------
601// Source discovery — glob the profile dir into typed sources (DESIGN.md §2.2)
602// ----------------------------------------------------------------------------
603
604/// Which on-disk file family a [`Source`] came from.
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606pub enum SourceKind {
607 /// The newest `Session_*` file — the live/last windows.
608 Current,
609 /// An older `Session_*` file — the previous session.
610 Last,
611 /// The newest `Tabs_*` file — the recently-closed restore list.
612 RecentlyClosed,
613 /// An `Apps_*` file — PWA/app windows.
614 Apps,
615}
616
617impl SourceKind {
618 /// A short human label for the UI.
619 pub fn label(self) -> &'static str {
620 match self {
621 SourceKind::Current => "Current Session",
622 SourceKind::Last => "Last Session",
623 SourceKind::RecentlyClosed => "Recently Closed",
624 SourceKind::Apps => "Apps",
625 }
626 }
627 fn dialect(self) -> Dialect {
628 match self {
629 SourceKind::RecentlyClosed => Dialect::Tabs,
630 _ => Dialect::Session,
631 }
632 }
633}
634
635/// One decoded session file: its kind, path, and reconstructed windows.
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub struct Source {
638 /// Which file family this came from.
639 pub kind: SourceKind,
640 /// Absolute path to the file it was decoded from.
641 pub path: PathBuf,
642 /// Windows reconstructed from this file.
643 pub windows: Vec<Window>,
644}
645
646/// A read-only, in-memory snapshot of a Brave profile's `Sessions` directory.
647///
648/// Discovery globs `Session_*`/`Tabs_*`/`Apps_*` (filenames rotate while Brave
649/// runs, so never hardcode a name), snapshots each file's bytes, and decodes them
650/// into [`Source`]s. There is **no write path**: this type cannot mutate Brave's
651/// store. A file that fails to decode becomes a [`Warning::UnreadableSource`]
652/// while the other sources stay usable.
653#[derive(Debug, Clone, PartialEq, Eq)]
654pub struct SessionStore {
655 sources: Vec<Source>,
656 warnings: Vec<Warning>,
657}
658
659impl SessionStore {
660 /// Open the default macOS Brave profile's `Sessions` directory (read-only).
661 ///
662 /// # Errors
663 /// [`SnssError::Io`] if the home directory cannot be resolved or the
664 /// directory cannot be listed.
665 pub fn open_default_profile() -> Result<Self, SnssError> {
666 Self::open_dir(&default_sessions_dir()?)
667 }
668
669 /// Open an explicit `Sessions` directory (other profiles, forensic copies).
670 ///
671 /// # Errors
672 /// [`SnssError::Io`] if the directory cannot be listed.
673 pub fn open_dir(dir: &Path) -> Result<Self, SnssError> {
674 // Group files by family, newest first. Recency comes from the numeric
675 // filename suffix (Brave's Windows-epoch stamp), not mtime — copying a
676 // profile (fixtures, forensic images) resets mtime but keeps the name.
677 let mut by_family: HashMap<&str, Vec<(u64, PathBuf)>> = HashMap::new();
678 for entry in std::fs::read_dir(dir)? {
679 let path = entry?.path();
680 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
681 continue; // cov:unreachable: macOS/Windows reject non-UTF-8 filenames at write time, so a dir entry whose name is not valid UTF-8 cannot be materialized on the test matrix
682 };
683 for family in ["Session", "Tabs", "Apps"] {
684 if let Some(suffix) = name.strip_prefix(family).and_then(|s| s.strip_prefix('_')) {
685 let rank = suffix.parse::<u64>().unwrap_or(0);
686 by_family
687 .entry(family)
688 .or_default()
689 .push((rank, path.clone()));
690 }
691 }
692 }
693 for files in by_family.values_mut() {
694 files.sort_by_key(|f| std::cmp::Reverse(f.0)); // newest (largest suffix) first
695 }
696
697 // Assign kinds: newest Session = Current, next = Last; newest Tabs =
698 // Recently-Closed; newest Apps = Apps. Order is fixed for the UI.
699 let sessions = by_family.get("Session").map_or(&[][..], Vec::as_slice);
700 let mut plan: Vec<(SourceKind, &PathBuf)> = Vec::new();
701 if let Some((_, p)) = sessions.first() {
702 plan.push((SourceKind::Current, p));
703 }
704 if let Some((_, p)) = sessions.get(1) {
705 plan.push((SourceKind::Last, p));
706 }
707 if let Some((_, p)) = by_family.get("Tabs").and_then(|v| v.first()) {
708 plan.push((SourceKind::RecentlyClosed, p));
709 }
710 if let Some((_, p)) = by_family.get("Apps").and_then(|v| v.first()) {
711 plan.push((SourceKind::Apps, p));
712 }
713
714 let mut sources = Vec::new();
715 let mut warnings = Vec::new();
716 for (kind, path) in plan {
717 match decode_source(kind, path) {
718 Ok((source, source_warnings)) => {
719 sources.push(source);
720 warnings.extend(source_warnings);
721 }
722 Err(e) => warnings.push(Warning::UnreadableSource {
723 path: path.display().to_string(),
724 reason: e.to_string(),
725 }),
726 }
727 }
728 Ok(SessionStore { sources, warnings })
729 }
730
731 /// The decoded sources, ordered Current, Last, Recently-Closed, Apps.
732 pub fn sources(&self) -> &[Source] {
733 &self.sources
734 }
735
736 /// Non-fatal anomalies gathered across all sources.
737 pub fn warnings(&self) -> &[Warning] {
738 &self.warnings
739 }
740}
741
742/// Snapshot a session file's bytes and decode it into a [`Source`], returning any
743/// per-file [`Warning`]s (e.g. truncated tail, bad navigation) alongside it.
744fn decode_source(kind: SourceKind, path: &Path) -> Result<(Source, Vec<Warning>), SnssError> {
745 // Read fully into memory first so a concurrent Brave rewrite can't tear the
746 // decode; the model is immutable once built.
747 let bytes = std::fs::read(path)?;
748 let stream = read_records(&bytes[..])?;
749 let mut warnings = stream.warnings.clone();
750 let replayed = replay(&stream, kind.dialect());
751 warnings.extend(replayed.warnings);
752 let source = Source {
753 kind,
754 path: path.to_path_buf(),
755 windows: replayed.windows,
756 };
757 Ok((source, warnings))
758}
759
760/// Resolve the default macOS Brave `Sessions` directory from `$HOME`.
761fn default_sessions_dir() -> Result<PathBuf, SnssError> {
762 let home = std::env::var_os("HOME").ok_or_else(|| {
763 SnssError::Io(std::io::Error::new(
764 std::io::ErrorKind::NotFound,
765 "HOME is not set",
766 ))
767 })?;
768 Ok(PathBuf::from(home)
769 .join("Library/Application Support/BraveSoftware/Brave-Browser/Default/Sessions"))
770}