earl_protocol_browser/
session.rs1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::BrowserError;
8
9pub 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), },
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 return true;
100 }
101 #[cfg(unix)]
102 {
103 let pid_t = pid as libc::pid_t;
106 if pid_t <= 0 {
107 return false;
108 }
109 let result = unsafe { libc::kill(pid_t, 0) };
111 result == 0
112 }
113 #[cfg(not(unix))]
114 {
115 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 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 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}