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 [`Warning::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 continue;
487 }
488 match rec.id {
489 CMD_SET_TAB_WINDOW => {
490 if let Some((window, tab)) = pod_pair(&rec.payload) {
491 tab_window.insert(tab, window);
492 }
493 }
494 CMD_TAB_INDEX_IN_WINDOW => {
495 if let Some((tab, idx)) = pod_pair(&rec.payload) {
496 tab_order.insert(tab, idx);
497 }
498 }
499 CMD_SET_PINNED_STATE => {
500 if let Some((tab, pinned)) = pod_pinned(&rec.payload) {
501 tab_pinned.insert(tab, pinned);
502 }
503 }
504 CMD_LAST_ACTIVE_TIME => {
505 if let Some((tab, time)) = pod_last_active(&rec.payload) {
506 tab_time.insert(tab, time);
507 }
508 }
509 _ => {} // cov:unreachable: all SNSS record ids handled above; defensive catch-all
510 }
511 }
512
513 // Build tabs, grouped into windows. The Tabs dialect has no window mapping, so
514 // every closed tab lands in a single synthetic window (id 0).
515 let mut window_tabs: BTreeMap<i32, Vec<(i32, Tab)>> = BTreeMap::new();
516 for (tab_id, idx_map) in histories {
517 let history: Vec<Nav> = idx_map.into_values().collect();
518 if history.is_empty() {
519 continue; // cov:unreachable: every histories key is created by inserting a Nav, so its idx_map is never empty
520 }
521 let current = match tab_selected.get(&tab_id) {
522 Some(sel) => history
523 .iter()
524 .position(|n| n.index == *sel)
525 .unwrap_or(history.len() - 1),
526 None => history.len() - 1,
527 };
528 let tab = Tab {
529 id: tab_id,
530 pinned: tab_pinned.get(&tab_id).copied().unwrap_or(false),
531 current,
532 history,
533 };
534 let window_id = tab_window.get(&tab_id).copied().unwrap_or(0);
535 let order = tab_order.get(&tab_id).copied().unwrap_or(i32::MAX);
536 window_tabs.entry(window_id).or_default().push((order, tab));
537 }
538
539 let windows = window_tabs
540 .into_iter()
541 .map(|(id, mut ordered)| {
542 // Order tabs by TabIndexInWindow, then tab id for stability.
543 ordered.sort_by_key(|(order, tab)| (*order, tab.id));
544 let tabs: Vec<Tab> = ordered.into_iter().map(|(_, t)| t).collect();
545 let last_active = tabs
546 .iter()
547 .filter_map(|t| tab_time.get(&t.id).copied())
548 .max()
549 .and_then(windows_micros_to_system_time);
550 Window {
551 id,
552 tabs,
553 last_active,
554 }
555 })
556 .collect();
557
558 Replayed { windows, warnings }
559}
560
561/// Read a raw two-`i32` POD payload (SetTabWindow, TabIndexInWindow, selected nav).
562fn pod_pair(payload: &[u8]) -> Option<(i32, i32)> {
563 if payload.len() < 8 {
564 return None;
565 }
566 let a = i32::from_le_bytes(payload[0..4].try_into().ok()?);
567 let b = i32::from_le_bytes(payload[4..8].try_into().ok()?);
568 Some((a, b))
569}
570
571/// Read a SetPinnedState payload: `{tab_id: i32, pinned: bool}`.
572fn pod_pinned(payload: &[u8]) -> Option<(i32, bool)> {
573 if payload.len() < 5 {
574 return None;
575 }
576 let tab = i32::from_le_bytes(payload[0..4].try_into().ok()?);
577 Some((tab, payload[4] != 0))
578}
579
580/// Read a LastActiveTime payload: `{tab_id: i32, _pad: i32, time: i64}` where
581/// `time` is microseconds since the Windows epoch.
582fn pod_last_active(payload: &[u8]) -> Option<(i32, i64)> {
583 if payload.len() < 16 {
584 return None;
585 }
586 let tab = i32::from_le_bytes(payload[0..4].try_into().ok()?);
587 let time = i64::from_le_bytes(payload[8..16].try_into().ok()?);
588 Some((tab, time))
589}
590
591/// Convert Windows-epoch microseconds to a [`SystemTime`], or `None` for a zero
592/// or pre-Unix-epoch value (which would be meaningless as a last-active stamp).
593fn windows_micros_to_system_time(micros: i64) -> Option<SystemTime> {
594 let unix_micros = micros.checked_sub(WINDOWS_EPOCH_OFFSET_SECS.checked_mul(1_000_000)?)?;
595 if unix_micros <= 0 {
596 return None;
597 }
598 Some(UNIX_EPOCH + Duration::from_micros(unix_micros as u64))
599}
600
601// ----------------------------------------------------------------------------
602// Source discovery — glob the profile dir into typed sources (DESIGN.md §2.2)
603// ----------------------------------------------------------------------------
604
605/// Which on-disk file family a [`Source`] came from.
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
607pub enum SourceKind {
608 /// The newest `Session_*` file — the live/last windows.
609 Current,
610 /// An older `Session_*` file — the previous session.
611 Last,
612 /// The newest `Tabs_*` file — the recently-closed restore list.
613 RecentlyClosed,
614 /// An `Apps_*` file — PWA/app windows.
615 Apps,
616}
617
618impl SourceKind {
619 /// A short human label for the UI.
620 pub fn label(self) -> &'static str {
621 match self {
622 SourceKind::Current => "Current Session",
623 SourceKind::Last => "Last Session",
624 SourceKind::RecentlyClosed => "Recently Closed",
625 SourceKind::Apps => "Apps",
626 }
627 }
628 fn dialect(self) -> Dialect {
629 match self {
630 SourceKind::RecentlyClosed => Dialect::Tabs,
631 _ => Dialect::Session,
632 }
633 }
634}
635
636/// One decoded session file: its kind, path, and reconstructed windows.
637#[derive(Debug, Clone, PartialEq, Eq)]
638pub struct Source {
639 /// Which file family this came from.
640 pub kind: SourceKind,
641 /// Absolute path to the file it was decoded from.
642 pub path: PathBuf,
643 /// Windows reconstructed from this file.
644 pub windows: Vec<Window>,
645}
646
647/// A read-only, in-memory snapshot of a Brave profile's `Sessions` directory.
648///
649/// Discovery globs `Session_*`/`Tabs_*`/`Apps_*` (filenames rotate while Brave
650/// runs, so never hardcode a name), snapshots each file's bytes, and decodes them
651/// into [`Source`]s. There is **no write path**: this type cannot mutate Brave's
652/// store. A file that fails to decode becomes a [`Warning::UnreadableSource`]
653/// while the other sources stay usable.
654#[derive(Debug, Clone, PartialEq, Eq)]
655pub struct SessionStore {
656 sources: Vec<Source>,
657 warnings: Vec<Warning>,
658}
659
660impl SessionStore {
661 /// Open the default macOS Brave profile's `Sessions` directory (read-only).
662 ///
663 /// # Errors
664 /// [`SnssError::Io`] if the home directory cannot be resolved or the
665 /// directory cannot be listed.
666 pub fn open_default_profile() -> Result<Self, SnssError> {
667 Self::open_dir(&default_sessions_dir()?)
668 }
669
670 /// Open an explicit `Sessions` directory (other profiles, forensic copies).
671 ///
672 /// # Errors
673 /// [`SnssError::Io`] if the directory cannot be listed.
674 pub fn open_dir(dir: &Path) -> Result<Self, SnssError> {
675 // Group files by family, newest first. Recency comes from the numeric
676 // filename suffix (Brave's Windows-epoch stamp), not mtime — copying a
677 // profile (fixtures, forensic images) resets mtime but keeps the name.
678 let mut by_family: HashMap<&str, Vec<(u64, PathBuf)>> = HashMap::new();
679 for entry in std::fs::read_dir(dir)? {
680 let path = entry?.path();
681 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
682 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
683 };
684 for family in ["Session", "Tabs", "Apps"] {
685 if let Some(suffix) = name.strip_prefix(family).and_then(|s| s.strip_prefix('_')) {
686 let rank = suffix.parse::<u64>().unwrap_or(0);
687 by_family
688 .entry(family)
689 .or_default()
690 .push((rank, path.clone()));
691 }
692 }
693 }
694 for files in by_family.values_mut() {
695 files.sort_by_key(|f| std::cmp::Reverse(f.0)); // newest (largest suffix) first
696 }
697
698 // Assign kinds: newest Session = Current, next = Last; newest Tabs =
699 // Recently-Closed; newest Apps = Apps. Order is fixed for the UI.
700 let sessions = by_family.get("Session").map_or(&[][..], Vec::as_slice);
701 let mut plan: Vec<(SourceKind, &PathBuf)> = Vec::new();
702 if let Some((_, p)) = sessions.first() {
703 plan.push((SourceKind::Current, p));
704 }
705 if let Some((_, p)) = sessions.get(1) {
706 plan.push((SourceKind::Last, p));
707 }
708 if let Some((_, p)) = by_family.get("Tabs").and_then(|v| v.first()) {
709 plan.push((SourceKind::RecentlyClosed, p));
710 }
711 if let Some((_, p)) = by_family.get("Apps").and_then(|v| v.first()) {
712 plan.push((SourceKind::Apps, p));
713 }
714
715 let mut sources = Vec::new();
716 let mut warnings = Vec::new();
717 for (kind, path) in plan {
718 match decode_source(kind, path) {
719 Ok((source, source_warnings)) => {
720 sources.push(source);
721 warnings.extend(source_warnings);
722 }
723 Err(e) => warnings.push(Warning::UnreadableSource {
724 path: path.display().to_string(),
725 reason: e.to_string(),
726 }),
727 }
728 }
729 Ok(SessionStore { sources, warnings })
730 }
731
732 /// The decoded sources, ordered Current, Last, Recently-Closed, Apps.
733 pub fn sources(&self) -> &[Source] {
734 &self.sources
735 }
736
737 /// Non-fatal anomalies gathered across all sources.
738 pub fn warnings(&self) -> &[Warning] {
739 &self.warnings
740 }
741}
742
743/// Snapshot a session file's bytes and decode it into a [`Source`], returning any
744/// per-file [`Warning`]s (e.g. truncated tail, bad navigation) alongside it.
745fn decode_source(kind: SourceKind, path: &Path) -> Result<(Source, Vec<Warning>), SnssError> {
746 // Read fully into memory first so a concurrent Brave rewrite can't tear the
747 // decode; the model is immutable once built.
748 let bytes = std::fs::read(path)?;
749 let stream = read_records(&bytes[..])?;
750 let mut warnings = stream.warnings.clone();
751 let replayed = replay(&stream, kind.dialect());
752 warnings.extend(replayed.warnings);
753 let source = Source {
754 kind,
755 path: path.to_path_buf(),
756 windows: replayed.windows,
757 };
758 Ok((source, warnings))
759}
760
761/// Resolve the default macOS Brave `Sessions` directory from `$HOME`.
762fn default_sessions_dir() -> Result<PathBuf, SnssError> {
763 let home = std::env::var_os("HOME").ok_or_else(|| {
764 SnssError::Io(std::io::Error::new(
765 std::io::ErrorKind::NotFound,
766 "HOME is not set",
767 ))
768 })?;
769 Ok(PathBuf::from(home)
770 .join("Library/Application Support/BraveSoftware/Brave-Browser/Default/Sessions"))
771}