#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const MAGIC: [u8; 4] = *b"SNSS";
pub const SUPPORTED_VERSION: i32 = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Record {
pub id: u8,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Warning {
TruncatedTail { offset: u64 },
BadNavigation { record: usize, error: PickleError },
UnreadableSource { path: String, reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordStream {
pub version: i32,
pub records: Vec<Record>,
pub warnings: Vec<Warning>,
}
#[derive(Debug)]
pub enum SnssError {
BadMagic([u8; 4]),
UnsupportedVersion(i32),
Io(std::io::Error),
}
impl std::fmt::Display for SnssError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SnssError::BadMagic(got) => {
write!(f, "not an SNSS file: expected magic {MAGIC:?}, got {got:?}")
}
SnssError::UnsupportedVersion(v) => {
write!(
f,
"unsupported SNSS version {v} (only {SUPPORTED_VERSION} is supported)"
)
}
SnssError::Io(e) => write!(f, "I/O error reading SNSS header: {e}"),
}
}
}
impl std::error::Error for SnssError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SnssError::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for SnssError {
fn from(e: std::io::Error) -> Self {
SnssError::Io(e)
}
}
pub fn read_records<R: Read>(mut reader: R) -> Result<RecordStream, SnssError> {
let mut buf = Vec::new();
reader.read_to_end(&mut buf)?;
if buf.len() < 8 {
let mut got = [0u8; 4];
let n = buf.len().min(4);
got[..n].copy_from_slice(&buf[..n]);
return Err(SnssError::BadMagic(got));
}
let magic: [u8; 4] = buf[0..4].try_into().unwrap_or([0u8; 4]);
if magic != MAGIC {
return Err(SnssError::BadMagic(magic));
}
let version = i32::from_le_bytes(buf[4..8].try_into().unwrap_or([0u8; 4]));
if version != SUPPORTED_VERSION {
return Err(SnssError::UnsupportedVersion(version));
}
let mut records = Vec::new();
let mut warnings = Vec::new();
let mut off = 8usize;
let len = buf.len();
loop {
if off + 2 > len {
if off < len {
warnings.push(Warning::TruncatedTail { offset: off as u64 });
}
break;
}
let size = u16::from_le_bytes([buf[off], buf[off + 1]]) as usize;
let body = off + 2;
if size == 0 || body + size > len {
warnings.push(Warning::TruncatedTail { offset: off as u64 });
break;
}
let id = buf[body];
let payload = buf[body + 1..body + size].to_vec();
records.push(Record { id, payload });
off = body + size;
}
Ok(RecordStream {
version,
records,
warnings,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavCommand {
pub tab_id: i32,
pub index: i32,
pub url: String,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickleError {
TooShort,
BadHeader { declared: usize, actual: usize },
Overrun,
BadLength(i32),
}
impl std::fmt::Display for PickleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PickleError::TooShort => write!(f, "payload too short for a Pickle header"),
PickleError::BadHeader { declared, actual } => {
write!(
f,
"Pickle declares {declared} payload bytes but only {actual} present"
)
}
PickleError::Overrun => write!(f, "a Pickle field runs past the end of the payload"),
PickleError::BadLength(n) => write!(f, "negative Pickle length prefix: {n}"),
}
}
}
impl std::error::Error for PickleError {}
pub fn decode_navigation(payload: &[u8]) -> Result<NavCommand, PickleError> {
let mut p = Pickle::new(payload)?;
let tab_id = p.read_i32()?;
let index = p.read_i32()?;
let url = p.read_string()?;
let title = p.read_string16()?;
Ok(NavCommand {
tab_id,
index,
url,
title,
})
}
struct Pickle<'a> {
data: &'a [u8],
cursor: usize,
}
impl<'a> Pickle<'a> {
fn new(payload: &'a [u8]) -> Result<Self, PickleError> {
if payload.len() < 4 {
return Err(PickleError::TooShort);
}
let declared = u32::from_le_bytes(payload[0..4].try_into().unwrap_or([0u8; 4])) as usize;
let actual = payload.len() - 4;
if declared > actual {
return Err(PickleError::BadHeader { declared, actual });
}
Ok(Pickle {
data: payload,
cursor: 4,
})
}
fn align(&mut self) {
let rem = self.cursor % 4;
if rem != 0 {
self.cursor += 4 - rem;
}
}
fn read_i32(&mut self) -> Result<i32, PickleError> {
let end = self.cursor.checked_add(4).ok_or(PickleError::Overrun)?;
if end > self.data.len() {
return Err(PickleError::Overrun);
}
let v = i32::from_le_bytes(self.data[self.cursor..end].try_into().unwrap_or([0u8; 4]));
self.cursor = end; Ok(v)
}
fn read_string(&mut self) -> Result<String, PickleError> {
let len = self.read_len()?;
let end = self.cursor.checked_add(len).ok_or(PickleError::Overrun)?;
if end > self.data.len() {
return Err(PickleError::Overrun);
}
let s = String::from_utf8_lossy(&self.data[self.cursor..end]).into_owned();
self.cursor = end;
self.align();
Ok(s)
}
fn read_string16(&mut self) -> Result<String, PickleError> {
let units = self.read_len()?;
let nbytes = units.checked_mul(2).ok_or(PickleError::Overrun)?;
let end = self
.cursor
.checked_add(nbytes)
.ok_or(PickleError::Overrun)?;
if end > self.data.len() {
return Err(PickleError::Overrun);
}
let u16s: Vec<u16> = self.data[self.cursor..end]
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
self.cursor = end;
self.align();
Ok(String::from_utf16_lossy(&u16s))
}
fn read_len(&mut self) -> Result<usize, PickleError> {
let n = self.read_i32()?;
if n < 0 {
return Err(PickleError::BadLength(n));
}
Ok(n as usize)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dialect {
Session,
Tabs,
}
impl Dialect {
fn nav_id(self) -> u8 {
match self {
Dialect::Session => 6,
Dialect::Tabs => 1,
}
}
fn selected_id(self) -> u8 {
match self {
Dialect::Session => 7,
Dialect::Tabs => 4,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Nav {
pub index: i32,
pub url: String,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tab {
pub id: i32,
pub pinned: bool,
pub current: usize,
pub history: Vec<Nav>,
}
impl Tab {
pub fn current_nav(&self) -> &Nav {
&self.history[self.current]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Window {
pub id: i32,
pub tabs: Vec<Tab>,
pub last_active: Option<SystemTime>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Replayed {
pub windows: Vec<Window>,
pub warnings: Vec<Warning>,
}
const CMD_SET_TAB_WINDOW: u8 = 0;
const CMD_TAB_INDEX_IN_WINDOW: u8 = 2;
const CMD_SET_PINNED_STATE: u8 = 12;
const CMD_LAST_ACTIVE_TIME: u8 = 21;
const WINDOWS_EPOCH_OFFSET_SECS: i64 = 11_644_473_600;
pub fn replay(stream: &RecordStream, dialect: Dialect) -> Replayed {
let nav_id = dialect.nav_id();
let selected_id = dialect.selected_id();
let mut histories: BTreeMap<i32, BTreeMap<i32, Nav>> = BTreeMap::new();
let mut tab_window: HashMap<i32, i32> = HashMap::new();
let mut tab_order: HashMap<i32, i32> = HashMap::new();
let mut tab_selected: HashMap<i32, i32> = HashMap::new();
let mut tab_pinned: HashMap<i32, bool> = HashMap::new();
let mut tab_time: HashMap<i32, i64> = HashMap::new();
let mut warnings = Vec::new();
for (i, rec) in stream.records.iter().enumerate() {
if rec.id == nav_id {
match decode_navigation(&rec.payload) {
Ok(n) => {
histories.entry(n.tab_id).or_default().insert(
n.index,
Nav {
index: n.index,
url: n.url,
title: n.title,
},
);
}
Err(error) => warnings.push(Warning::BadNavigation { record: i, error }),
}
continue;
}
if rec.id == selected_id {
if let Some((tab, idx)) = pod_pair(&rec.payload) {
tab_selected.insert(tab, idx);
}
continue;
}
if dialect == Dialect::Session {
match rec.id {
CMD_SET_TAB_WINDOW => {
if let Some((window, tab)) = pod_pair(&rec.payload) {
tab_window.insert(tab, window);
}
}
CMD_TAB_INDEX_IN_WINDOW => {
if let Some((tab, idx)) = pod_pair(&rec.payload) {
tab_order.insert(tab, idx);
}
}
CMD_SET_PINNED_STATE => {
if let Some((tab, pinned)) = pod_pinned(&rec.payload) {
tab_pinned.insert(tab, pinned);
}
}
CMD_LAST_ACTIVE_TIME => {
if let Some((tab, time)) = pod_last_active(&rec.payload) {
tab_time.insert(tab, time);
}
}
_ => {}
}
}
}
let mut window_tabs: BTreeMap<i32, Vec<(i32, Tab)>> = BTreeMap::new();
for (tab_id, idx_map) in histories {
let history: Vec<Nav> = idx_map.into_values().collect();
if history.is_empty() {
continue; }
let current = match tab_selected.get(&tab_id) {
Some(sel) => history
.iter()
.position(|n| n.index == *sel)
.unwrap_or(history.len() - 1),
None => history.len() - 1,
};
let tab = Tab {
id: tab_id,
pinned: tab_pinned.get(&tab_id).copied().unwrap_or(false),
current,
history,
};
let window_id = tab_window.get(&tab_id).copied().unwrap_or(0);
let order = tab_order.get(&tab_id).copied().unwrap_or(i32::MAX);
window_tabs.entry(window_id).or_default().push((order, tab));
}
let windows = window_tabs
.into_iter()
.map(|(id, mut ordered)| {
ordered.sort_by_key(|(order, tab)| (*order, tab.id));
let tabs: Vec<Tab> = ordered.into_iter().map(|(_, t)| t).collect();
let last_active = tabs
.iter()
.filter_map(|t| tab_time.get(&t.id).copied())
.max()
.and_then(windows_micros_to_system_time);
Window {
id,
tabs,
last_active,
}
})
.collect();
Replayed { windows, warnings }
}
fn pod_pair(payload: &[u8]) -> Option<(i32, i32)> {
if payload.len() < 8 {
return None;
}
let a = i32::from_le_bytes(payload[0..4].try_into().ok()?);
let b = i32::from_le_bytes(payload[4..8].try_into().ok()?);
Some((a, b))
}
fn pod_pinned(payload: &[u8]) -> Option<(i32, bool)> {
if payload.len() < 5 {
return None;
}
let tab = i32::from_le_bytes(payload[0..4].try_into().ok()?);
Some((tab, payload[4] != 0))
}
fn pod_last_active(payload: &[u8]) -> Option<(i32, i64)> {
if payload.len() < 16 {
return None;
}
let tab = i32::from_le_bytes(payload[0..4].try_into().ok()?);
let time = i64::from_le_bytes(payload[8..16].try_into().ok()?);
Some((tab, time))
}
fn windows_micros_to_system_time(micros: i64) -> Option<SystemTime> {
let unix_micros = micros.checked_sub(WINDOWS_EPOCH_OFFSET_SECS.checked_mul(1_000_000)?)?;
if unix_micros <= 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_micros(unix_micros as u64))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
Current,
Last,
RecentlyClosed,
Apps,
}
impl SourceKind {
pub fn label(self) -> &'static str {
match self {
SourceKind::Current => "Current Session",
SourceKind::Last => "Last Session",
SourceKind::RecentlyClosed => "Recently Closed",
SourceKind::Apps => "Apps",
}
}
fn dialect(self) -> Dialect {
match self {
SourceKind::RecentlyClosed => Dialect::Tabs,
_ => Dialect::Session,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Source {
pub kind: SourceKind,
pub path: PathBuf,
pub windows: Vec<Window>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionStore {
sources: Vec<Source>,
warnings: Vec<Warning>,
}
impl SessionStore {
pub fn open_default_profile() -> Result<Self, SnssError> {
Self::open_dir(&default_sessions_dir()?)
}
pub fn open_dir(dir: &Path) -> Result<Self, SnssError> {
let mut by_family: HashMap<&str, Vec<(u64, PathBuf)>> = HashMap::new();
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue; };
for family in ["Session", "Tabs", "Apps"] {
if let Some(suffix) = name.strip_prefix(family).and_then(|s| s.strip_prefix('_')) {
let rank = suffix.parse::<u64>().unwrap_or(0);
by_family
.entry(family)
.or_default()
.push((rank, path.clone()));
}
}
}
for files in by_family.values_mut() {
files.sort_by_key(|f| std::cmp::Reverse(f.0)); }
let sessions = by_family.get("Session").map_or(&[][..], Vec::as_slice);
let mut plan: Vec<(SourceKind, &PathBuf)> = Vec::new();
if let Some((_, p)) = sessions.first() {
plan.push((SourceKind::Current, p));
}
if let Some((_, p)) = sessions.get(1) {
plan.push((SourceKind::Last, p));
}
if let Some((_, p)) = by_family.get("Tabs").and_then(|v| v.first()) {
plan.push((SourceKind::RecentlyClosed, p));
}
if let Some((_, p)) = by_family.get("Apps").and_then(|v| v.first()) {
plan.push((SourceKind::Apps, p));
}
let mut sources = Vec::new();
let mut warnings = Vec::new();
for (kind, path) in plan {
match decode_source(kind, path) {
Ok((source, source_warnings)) => {
sources.push(source);
warnings.extend(source_warnings);
}
Err(e) => warnings.push(Warning::UnreadableSource {
path: path.display().to_string(),
reason: e.to_string(),
}),
}
}
Ok(SessionStore { sources, warnings })
}
pub fn sources(&self) -> &[Source] {
&self.sources
}
pub fn warnings(&self) -> &[Warning] {
&self.warnings
}
}
fn decode_source(kind: SourceKind, path: &Path) -> Result<(Source, Vec<Warning>), SnssError> {
let bytes = std::fs::read(path)?;
let stream = read_records(&bytes[..])?;
let mut warnings = stream.warnings.clone();
let replayed = replay(&stream, kind.dialect());
warnings.extend(replayed.warnings);
let source = Source {
kind,
path: path.to_path_buf(),
windows: replayed.windows,
};
Ok((source, warnings))
}
fn default_sessions_dir() -> Result<PathBuf, SnssError> {
let home = std::env::var_os("HOME").ok_or_else(|| {
SnssError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"HOME is not set",
))
})?;
Ok(PathBuf::from(home)
.join("Library/Application Support/BraveSoftware/Brave-Browser/Default/Sessions"))
}