use crate::core::{TabId, WindowId};
use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use url::Url;
pub const MAX_SESSION_WINDOWS: usize = 4;
pub const MAX_SESSION_TABS_PER_WINDOW: usize = 100;
pub const MAX_SESSION_FILE_BYTES: u64 = 512 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BrowserSession {
pub windows: Vec<SessionWindow>,
}
impl BrowserSession {
pub fn enforce_limits(&mut self) {
self.windows.truncate(MAX_SESSION_WINDOWS);
for window in &mut self.windows {
window.tabs.retain(|tab| !tab.private);
window.tabs.truncate(MAX_SESSION_TABS_PER_WINDOW);
for tab in &mut window.tabs {
if let Some(url) = &tab.url
&& !safe_session_restore_url(url)
{
tab.url = None;
}
if tab.title.trim().is_empty() {
tab.title = default_tab_title();
}
tab.zoom = if tab.zoom.is_finite() {
tab.zoom.clamp(0.5, 2.5)
} else {
default_tab_zoom()
};
}
if let Some(active_tab) = window.active_tab
&& !window.tabs.iter().any(|tab| tab.id == active_tab)
{
window.active_tab = window.tabs.first().map(|tab| tab.id);
}
}
self.windows.retain(|window| !window.tabs.is_empty());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionWindow {
pub id: WindowId,
#[serde(default)]
pub active_tab: Option<TabId>,
#[serde(default)]
pub tabs: Vec<SessionTab>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionTab {
pub id: TabId,
#[serde(default)]
pub url: Option<Url>,
#[serde(default = "default_tab_title")]
pub title: String,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub muted: bool,
#[serde(default)]
pub private: bool,
#[serde(default = "default_tab_zoom")]
pub zoom: f64,
}
fn default_tab_title() -> String {
"Start".to_string()
}
fn default_tab_zoom() -> f64 {
1.0
}
#[derive(Debug, Clone)]
pub struct SessionStore {
path: PathBuf,
}
impl SessionStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> anyhow::Result<BrowserSession> {
if !self.path.exists() {
return Ok(BrowserSession::default());
}
let data = read_bounded_to_string(&self.path, MAX_SESSION_FILE_BYTES)?;
let mut session: BrowserSession = serde_json::from_str(&data)?;
session.enforce_limits();
Ok(session)
}
pub fn save(&self, session: &BrowserSession) -> anyhow::Result<()> {
let mut session = session.clone();
session.enforce_limits();
atomic_write_json(&self.path, &session)
}
}
fn safe_session_restore_url(url: &Url) -> bool {
matches!(url.scheme(), "http" | "https") && url.domain() != Some("cephas.local")
}
fn read_bounded_to_string(path: &Path, max_bytes: u64) -> anyhow::Result<String> {
let size = fs::metadata(path)
.with_context(|| format!("failed to inspect session {}", path.display()))?
.len();
if size > max_bytes {
bail!(
"session file {} is too large: {size} bytes exceeds {max_bytes}",
path.display()
);
}
fs::read_to_string(path).with_context(|| format!("failed to read session {}", path.display()))
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
fs::write(&tmp, serde_json::to_vec(value)?)?;
fs::rename(tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_round_trips() {
let dir = tempfile::tempdir().unwrap();
let store = SessionStore::new(dir.path().join("session.json"));
let session = BrowserSession {
windows: vec![SessionWindow {
id: WindowId(1),
active_tab: Some(TabId(7)),
tabs: vec![SessionTab {
id: TabId(7),
url: Some(Url::parse("https://example.com/").unwrap()),
title: "Example".to_string(),
pinned: false,
muted: false,
private: false,
zoom: 1.1,
}],
}],
};
store.save(&session).unwrap();
let loaded = store.load().unwrap();
assert_eq!(loaded.windows[0].active_tab, Some(TabId(7)));
assert_eq!(loaded.windows[0].tabs[0].zoom, 1.1);
}
#[test]
fn session_limits_windows_tabs_and_private_entries() {
let mut session = BrowserSession {
windows: (0..8)
.map(|window| SessionWindow {
id: WindowId(window),
active_tab: Some(TabId(1)),
tabs: (0..150)
.map(|tab| SessionTab {
id: TabId(tab),
url: Some(Url::parse("https://example.com/").unwrap()),
title: format!("Tab {tab}"),
pinned: false,
muted: false,
private: tab % 2 == 0,
zoom: 1.0,
})
.collect(),
})
.collect(),
};
session.enforce_limits();
assert_eq!(session.windows.len(), MAX_SESSION_WINDOWS);
assert!(
session
.windows
.iter()
.all(|window| window.tabs.len() <= MAX_SESSION_TABS_PER_WINDOW)
);
assert!(
session
.windows
.iter()
.flat_map(|window| &window.tabs)
.all(|tab| !tab.private)
);
}
#[test]
fn session_loads_tabs_when_safe_fields_are_missing() {
let mut session: BrowserSession = serde_json::from_value(serde_json::json!({
"windows": [{
"id": 1,
"tabs": [{ "id": 7 }]
}]
}))
.unwrap();
session.enforce_limits();
let tab = &session.windows[0].tabs[0];
assert_eq!(tab.id, TabId(7));
assert_eq!(tab.title, "Start");
assert_eq!(tab.zoom, 1.0);
assert!(!tab.pinned);
assert!(!tab.private);
session.windows[0].tabs[0].zoom = f64::INFINITY;
session.enforce_limits();
assert_eq!(session.windows[0].tabs[0].zoom, 1.0);
}
#[test]
fn session_drops_unsafe_restore_urls() {
let mut session = BrowserSession {
windows: vec![SessionWindow {
id: WindowId(1),
active_tab: Some(TabId(1)),
tabs: vec![
SessionTab {
id: TabId(1),
url: Some(Url::parse("file:///etc/passwd").unwrap()),
title: "Local".to_string(),
pinned: false,
muted: false,
private: false,
zoom: 1.0,
},
SessionTab {
id: TabId(2),
url: Some(Url::parse("cephas://settings/theme?id=glass").unwrap()),
title: "Internal".to_string(),
pinned: false,
muted: false,
private: false,
zoom: 1.0,
},
SessionTab {
id: TabId(3),
url: Some(Url::parse("https://cephas.local/agent").unwrap()),
title: "Document base".to_string(),
pinned: false,
muted: false,
private: false,
zoom: 1.0,
},
SessionTab {
id: TabId(4),
url: Some(Url::parse("https://example.com/").unwrap()),
title: "Web".to_string(),
pinned: false,
muted: false,
private: false,
zoom: 1.0,
},
],
}],
};
session.enforce_limits();
assert!(session.windows[0].tabs[0].url.is_none());
assert!(session.windows[0].tabs[1].url.is_none());
assert!(session.windows[0].tabs[2].url.is_none());
assert_eq!(
session.windows[0].tabs[3].url.as_ref().unwrap().as_str(),
"https://example.com/"
);
}
#[test]
fn session_load_rejects_oversized_file_before_parse() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.json");
fs::write(&path, vec![b' '; (MAX_SESSION_FILE_BYTES + 1) as usize]).unwrap();
let store = SessionStore::new(path);
assert!(store.load().is_err());
}
}