Skip to main content

earl_protocol_browser/
session.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::BrowserError;
8
9/// Reject session IDs that could be used for path traversal or other abuse.
10///
11/// Allowed characters: ASCII letters, digits, hyphens, and underscores.
12pub fn validate_session_id(session_id: &str) -> Result<()> {
13    if session_id.is_empty() {
14        return Err(anyhow::anyhow!("session_id must not be empty"));
15    }
16    if !session_id
17        .chars()
18        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
19    {
20        return Err(anyhow::anyhow!(
21            "session_id contains invalid characters; only ASCII letters, digits, hyphens, \
22             and underscores are allowed"
23        ));
24    }
25    Ok(())
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct SessionFile {
30    pub pid: u32,
31    pub websocket_url: String,
32    pub target_id: String,
33    pub started_at: DateTime<Utc>,
34    pub last_used_at: DateTime<Utc>,
35    pub interrupted: bool,
36}
37
38impl SessionFile {
39    pub fn load_from(path: &Path) -> Result<Option<Self>> {
40        match std::fs::read_to_string(path) {
41            Ok(contents) => match serde_json::from_str(&contents) {
42                Ok(f) => Ok(Some(f)),
43                Err(_) => Ok(None), // corrupt — treat as stale
44            },
45            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
46            Err(e) => Err(e).context("reading session file"),
47        }
48    }
49
50    pub fn save_to(&self, path: &Path) -> Result<()> {
51        let dir = path.parent().unwrap_or(Path::new("."));
52        let tmp = tempfile::NamedTempFile::new_in(dir).context("creating temp session file")?;
53        serde_json::to_writer(&tmp, self).context("serializing session file")?;
54        tmp.persist(path)
55            .map_err(|e| anyhow::anyhow!("persisting session file: {}", e.error))?;
56        Ok(())
57    }
58
59    pub fn delete(path: &Path) -> Result<()> {
60        match std::fs::remove_file(path) {
61            Ok(()) => Ok(()),
62            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
63            Err(e) => Err(e).context("deleting session file"),
64        }
65    }
66}
67
68pub fn ensure_sessions_dir(dir: &Path) -> Result<()> {
69    std::fs::create_dir_all(dir).context("creating sessions directory")?;
70    #[cfg(unix)]
71    {
72        use std::os::unix::fs::PermissionsExt;
73        let perms = std::fs::Permissions::from_mode(0o700);
74        std::fs::set_permissions(dir, perms).context("setting sessions directory permissions")?;
75    }
76    Ok(())
77}
78
79pub fn sessions_dir() -> Result<PathBuf> {
80    let base = directories::BaseDirs::new()
81        .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
82    Ok(base.config_dir().join("earl").join("browser-sessions"))
83}
84
85pub fn session_file_path(session_id: &str) -> Result<PathBuf> {
86    validate_session_id(session_id)?;
87    Ok(sessions_dir()?.join(format!("{session_id}.json")))
88}
89
90pub fn lock_file_path(session_id: &str) -> Result<PathBuf> {
91    validate_session_id(session_id)?;
92    Ok(sessions_dir()?.join(format!("{session_id}.lock")))
93}
94
95pub fn is_pid_alive(pid: u32, _started_at: Option<DateTime<Utc>>) -> bool {
96    if pid == 0 {
97        // pid=0 means we don't know the PID (chromiumoxide doesn't expose it);
98        // return true so the caller falls through to the CDP probe.
99        return true;
100    }
101    #[cfg(unix)]
102    {
103        // Reject PIDs that would wrap to negative pid_t values (e.g. u32::MAX → -1).
104        // On Unix, kill(-1, 0) signals all processes and is not a PID existence check.
105        let pid_t = pid as libc::pid_t;
106        if pid_t <= 0 {
107            return false;
108        }
109        // kill(pid, 0) returns 0 if process exists and we can signal it, -1 otherwise.
110        let result = unsafe { libc::kill(pid_t, 0) };
111        result == 0
112    }
113    #[cfg(not(unix))]
114    {
115        // On non-unix, skip PID check; rely solely on CDP probe.
116        let _ = pid;
117        true
118    }
119}
120
121pub async fn acquire_session_lock(session_id: &str) -> Result<tokio::fs::File> {
122    use fs4::tokio::AsyncFileExt;
123
124    validate_session_id(session_id)?;
125    let dir = sessions_dir()?;
126    ensure_sessions_dir(&dir)?;
127    let lock_path = dir.join(format!("{session_id}.lock"));
128
129    let file = tokio::fs::OpenOptions::new()
130        .create(true)
131        .write(true)
132        .truncate(true)
133        .open(&lock_path)
134        .await
135        .context("opening session lock file")?;
136
137    match file.try_lock() {
138        Ok(()) => Ok(file),
139        Err(_) => {
140            // Try to read the PID from the lock file content (best-effort).
141            let pid = tokio::fs::read_to_string(&lock_path)
142                .await
143                .unwrap_or_default()
144                .trim()
145                .parse::<u32>()
146                .unwrap_or(0);
147            Err(BrowserError::SessionLocked {
148                session_id: session_id.to_string(),
149                pid,
150            }
151            .into())
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use tempfile::TempDir;
160
161    #[test]
162    fn round_trip_session_file() {
163        let dir = TempDir::new().unwrap();
164        let path = dir.path().join("test.json");
165        let orig = SessionFile {
166            pid: 12345,
167            websocket_url: "ws://127.0.0.1:9222/devtools/browser/xyz".into(),
168            target_id: "T42".into(),
169            started_at: Utc::now(),
170            last_used_at: Utc::now(),
171            interrupted: false,
172        };
173        orig.save_to(&path).unwrap();
174        let loaded = SessionFile::load_from(&path).unwrap();
175        assert!(loaded.is_some());
176        let loaded = loaded.unwrap();
177        assert_eq!(loaded.pid, 12345);
178        assert_eq!(loaded.target_id, "T42");
179        assert_eq!(
180            loaded.websocket_url,
181            "ws://127.0.0.1:9222/devtools/browser/xyz"
182        );
183        assert!(!loaded.interrupted);
184    }
185
186    #[test]
187    fn corrupt_json_returns_none() {
188        let dir = TempDir::new().unwrap();
189        let path = dir.path().join("corrupt.json");
190        std::fs::write(&path, b"not json {{{{").unwrap();
191        assert!(SessionFile::load_from(&path).unwrap().is_none());
192    }
193
194    #[test]
195    fn missing_file_returns_none() {
196        let dir = TempDir::new().unwrap();
197        let path = dir.path().join("nonexistent.json");
198        assert!(SessionFile::load_from(&path).unwrap().is_none());
199    }
200
201    #[test]
202    fn delete_removes_file() {
203        let dir = TempDir::new().unwrap();
204        let path = dir.path().join("session.json");
205        std::fs::write(&path, b"{}").unwrap();
206        SessionFile::delete(&path).unwrap();
207        assert!(!path.exists());
208    }
209
210    #[test]
211    fn delete_nonexistent_is_ok() {
212        let dir = TempDir::new().unwrap();
213        let path = dir.path().join("nonexistent.json");
214        assert!(SessionFile::delete(&path).is_ok());
215    }
216
217    #[test]
218    fn session_dir_creates_with_correct_permissions() {
219        let dir = TempDir::new().unwrap();
220        let sessions_dir = dir.path().join("sessions");
221        ensure_sessions_dir(&sessions_dir).unwrap();
222        assert!(sessions_dir.exists());
223        #[cfg(unix)]
224        {
225            use std::os::unix::fs::PermissionsExt;
226            let meta = std::fs::metadata(&sessions_dir).unwrap();
227            assert_eq!(meta.permissions().mode() & 0o777, 0o700);
228        }
229    }
230
231    #[test]
232    fn is_pid_alive_returns_false_for_impossible_pid() {
233        // PID u32::MAX is virtually guaranteed not to exist
234        let alive = is_pid_alive(u32::MAX, None);
235        assert!(!alive);
236    }
237
238    #[test]
239    fn validate_session_id_accepts_safe_ids() {
240        assert!(validate_session_id("my-session").is_ok());
241        assert!(validate_session_id("session_123").is_ok());
242        assert!(validate_session_id("ABC-def-0").is_ok());
243    }
244
245    #[test]
246    fn validate_session_id_rejects_path_traversal() {
247        assert!(validate_session_id("../../etc/passwd").is_err());
248        assert!(validate_session_id("../sibling").is_err());
249        assert!(validate_session_id("foo/bar").is_err());
250        assert!(validate_session_id("foo\\bar").is_err());
251    }
252
253    #[test]
254    fn validate_session_id_rejects_empty() {
255        assert!(validate_session_id("").is_err());
256    }
257}