Skip to main content

tuitbot_core/x_api/local_mode/
session.rs

1//! Scraper session management — load/save cookie-based auth sessions.
2//!
3//! A scraper session stores the two browser cookies (`auth_token` and `ct0`)
4//! needed to authenticate with X's internal API endpoints. Sessions are
5//! persisted as JSON in the data directory.
6
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11/// Cookie-based authentication session extracted from a browser.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ScraperSession {
14    /// The `auth_token` cookie value.
15    pub auth_token: String,
16    /// The `ct0` cookie value (CSRF token).
17    pub ct0: String,
18    /// Optional X username associated with this session.
19    #[serde(default)]
20    pub username: Option<String>,
21    /// ISO 8601 timestamp when this session was created/imported.
22    #[serde(default)]
23    pub created_at: Option<String>,
24}
25
26impl ScraperSession {
27    /// Load a session from a JSON file. Returns `None` if the file does not exist.
28    pub fn load(path: &Path) -> Result<Option<Self>, std::io::Error> {
29        if !path.exists() {
30            return Ok(None);
31        }
32        let contents = std::fs::read_to_string(path)?;
33        let session: Self = serde_json::from_str(&contents)
34            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
35        if session.auth_token.is_empty() || session.ct0.is_empty() {
36            return Ok(None);
37        }
38        Ok(Some(session))
39    }
40
41    /// Save this session to a JSON file with restrictive permissions.
42    pub fn save(&self, path: &Path) -> Result<(), std::io::Error> {
43        let json = serde_json::to_string_pretty(self)
44            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
45        std::fs::write(path, &json)?;
46
47        // Set 0600 permissions on Unix so only the owner can read cookies.
48        #[cfg(unix)]
49        {
50            use std::os::unix::fs::PermissionsExt;
51            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
52        }
53
54        Ok(())
55    }
56
57    /// Delete the session file if it exists.
58    pub fn delete(path: &Path) -> Result<bool, std::io::Error> {
59        match std::fs::remove_file(path) {
60            Ok(()) => Ok(true),
61            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
62            Err(e) => Err(e),
63        }
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use tempfile::TempDir;
71
72    #[test]
73    fn load_returns_none_when_missing() {
74        let dir = TempDir::new().unwrap();
75        let path = dir.path().join("session.json");
76        assert!(ScraperSession::load(&path).unwrap().is_none());
77    }
78
79    #[test]
80    fn save_and_load_roundtrip() {
81        let dir = TempDir::new().unwrap();
82        let path = dir.path().join("session.json");
83        let session = ScraperSession {
84            auth_token: "abc123".to_string(),
85            ct0: "csrf456".to_string(),
86            username: Some("testuser".to_string()),
87            created_at: Some("2026-03-05T12:00:00Z".to_string()),
88        };
89        session.save(&path).unwrap();
90        let loaded = ScraperSession::load(&path).unwrap().unwrap();
91        assert_eq!(loaded.auth_token, "abc123");
92        assert_eq!(loaded.ct0, "csrf456");
93        assert_eq!(loaded.username.as_deref(), Some("testuser"));
94    }
95
96    #[test]
97    fn load_returns_none_for_empty_tokens() {
98        let dir = TempDir::new().unwrap();
99        let path = dir.path().join("session.json");
100        let session = ScraperSession {
101            auth_token: String::new(),
102            ct0: "csrf".to_string(),
103            username: None,
104            created_at: None,
105        };
106        session.save(&path).unwrap();
107        assert!(ScraperSession::load(&path).unwrap().is_none());
108    }
109
110    #[test]
111    fn delete_returns_false_when_missing() {
112        let dir = TempDir::new().unwrap();
113        let path = dir.path().join("session.json");
114        assert!(!ScraperSession::delete(&path).unwrap());
115    }
116
117    #[test]
118    fn delete_removes_existing_file() {
119        let dir = TempDir::new().unwrap();
120        let path = dir.path().join("session.json");
121        std::fs::write(&path, "{}").unwrap();
122        assert!(ScraperSession::delete(&path).unwrap());
123        assert!(!path.exists());
124    }
125}