cephas 0.1.8

Privacy-first GTK/WebKit browser with local agent tooling.
Documentation
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());
    }
}