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 [`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}