Skip to main content

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}