Skip to main content

glass/browser/
profile.rs

1//! Chrome user-data directory profile management.
2//!
3//! Manages named profiles backed by Chrome user-data directories. Profiles
4//! persist cookies, localStorage, and all browser state across sessions.
5
6use std::path::{Path, PathBuf};
7use tracing::info;
8
9/// Manages Chrome user-data directories used as named Glass profiles.
10///
11/// Chrome owns cookies, local storage, and all other persisted browser state.
12/// Glass intentionally does not maintain a second JSON representation of that
13/// state: the directory passed to `--user-data-dir` is the sole source of
14/// truth.
15pub struct ProfileManager {
16    profiles_dir: PathBuf,
17}
18
19impl ProfileManager {
20    pub fn new() -> Self {
21        let profiles_dir = std::env::var_os("GLASS_CONFIG_HOME")
22            .map(PathBuf::from)
23            .or_else(dirs::config_dir)
24            .unwrap_or_else(|| PathBuf::from("."))
25            .join("glass")
26            .join("profiles");
27        Self { profiles_dir }
28    }
29
30    pub fn validate_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
31        if name.is_empty()
32            || name == "."
33            || name == ".."
34            || name.len() > 64
35            || !name
36                .chars()
37                .all(|character| character.is_ascii_alphanumeric() || "-_".contains(character))
38        {
39            return Err(
40                "profile names must be 1-64 characters of A-Z, a-z, 0-9, '-' or '_'".into(),
41            );
42        }
43        Ok(())
44    }
45
46    /// Return the canonical Chrome user-data directory for a profile.
47    pub fn profile_dir(&self, name: &str) -> PathBuf {
48        self.profile_data_root().join(name)
49    }
50
51    /// Create (or return) a named profile's Chrome user-data directory.
52    ///
53    /// Older Glass versions stored Chrome data in `<name>_chrome` alongside a
54    /// JSON metadata file. Move that existing browser data into the canonical
55    /// directory before use so Chrome remains the authority for persistence.
56    pub fn create_profile(&self, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
57        Self::validate_name(name)?;
58        std::fs::create_dir_all(self.profile_data_root())?;
59
60        let profile_dir = self.profile_dir(name);
61        let legacy_chrome_dir = self.legacy_chrome_data_dir(name);
62        if !profile_dir.exists() && legacy_chrome_dir.is_dir() {
63            std::fs::rename(&legacy_chrome_dir, &profile_dir)?;
64            info!(%name, "migrated legacy Chrome profile directory");
65        }
66        std::fs::create_dir_all(&profile_dir)?;
67        info!(%name, path = %profile_dir.display(), "ensured Chrome profile directory");
68        Ok(profile_dir)
69    }
70
71    /// Ensure a named profile exists and return its Chrome user-data directory.
72    pub fn ensure_profile_dir(&self, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
73        self.create_profile(name)
74    }
75
76    pub fn list_profiles(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
77        let mut profiles = Vec::new();
78        let profile_data_root = self.profile_data_root();
79        if profile_data_root.exists() {
80            for entry in std::fs::read_dir(profile_data_root)? {
81                let entry = entry?;
82                if entry.path().is_dir()
83                    && let Some(name) = entry.file_name().to_str()
84                    && Self::validate_name(name).is_ok()
85                {
86                    profiles.push(name.to_string());
87                }
88            }
89        }
90
91        // Older releases stored profile data and metadata directly under the
92        // profiles root. Include those names until they are migrated on use.
93        if self.profiles_dir.exists() {
94            for entry in std::fs::read_dir(&self.profiles_dir)? {
95                let entry = entry?;
96                let path = entry.path();
97                let file_name = entry.file_name();
98                let Some(file_name) = file_name.to_str() else {
99                    continue;
100                };
101
102                let name = if path.is_dir() {
103                    file_name.strip_suffix("_chrome").map(str::to_string)
104                } else if path.extension().and_then(|extension| extension.to_str()) == Some("json")
105                {
106                    Some(file_name.trim_end_matches(".json").to_string())
107                } else {
108                    None
109                };
110
111                if let Some(name) = name
112                    && Self::validate_name(&name).is_ok()
113                {
114                    profiles.push(name);
115                }
116            }
117        }
118        profiles.sort();
119        profiles.dedup();
120        Ok(profiles)
121    }
122
123    /// Delete all persisted state for a named profile.
124    ///
125    /// The canonical Chrome user-data directory is removed, as are the legacy
126    /// browser directory and JSON metadata left by older Glass releases.
127    pub fn delete_profile(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
128        Self::validate_name(name)?;
129
130        remove_dir_if_exists(&self.profile_dir(name))?;
131        remove_dir_if_exists(&self.legacy_chrome_data_dir(name))?;
132
133        let legacy_metadata = self.legacy_metadata_path(name);
134        if legacy_metadata.exists() {
135            std::fs::remove_file(legacy_metadata)?;
136        }
137        info!(%name, "deleted persisted Chrome profile data");
138        Ok(())
139    }
140
141    fn legacy_chrome_data_dir(&self, name: &str) -> PathBuf {
142        self.profiles_dir.join(format!("{name}_chrome"))
143    }
144
145    fn profile_data_root(&self) -> PathBuf {
146        self.profiles_dir.join("data")
147    }
148
149    fn legacy_metadata_path(&self, name: &str) -> PathBuf {
150        self.profiles_dir.join(format!("{name}.json"))
151    }
152
153    #[cfg(test)]
154    fn with_profiles_dir(profiles_dir: PathBuf) -> Self {
155        Self { profiles_dir }
156    }
157}
158
159fn remove_dir_if_exists(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
160    if path.exists() {
161        std::fs::remove_dir_all(path)?;
162    }
163    Ok(())
164}
165
166impl Default for ProfileManager {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::sync::atomic::{AtomicU64, Ordering};
176
177    static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0);
178
179    fn test_directory() -> PathBuf {
180        let id = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed);
181        std::env::temp_dir().join(format!("glass-profile-test-{}-{id}", std::process::id()))
182    }
183
184    #[test]
185    fn validates_profile_names() {
186        assert!(ProfileManager::validate_name("work").is_ok());
187        assert!(ProfileManager::validate_name("personal_2026").is_ok());
188        assert!(ProfileManager::validate_name("../escape").is_err());
189        assert!(ProfileManager::validate_name("with space").is_err());
190    }
191
192    #[test]
193    fn profiles_are_chrome_data_directories_and_delete_removes_legacy_data() {
194        let profiles_dir = test_directory();
195        let manager = ProfileManager::with_profiles_dir(profiles_dir.clone());
196        let profile_dir = manager.create_profile("work").unwrap();
197        std::fs::write(profile_dir.join("Cookies"), "persisted-by-chrome").unwrap();
198        std::fs::create_dir_all(manager.legacy_chrome_data_dir("work")).unwrap();
199        std::fs::write(
200            manager.legacy_chrome_data_dir("work").join("History"),
201            "legacy",
202        )
203        .unwrap();
204        std::fs::write(manager.legacy_metadata_path("work"), "{}\n").unwrap();
205
206        assert_eq!(manager.list_profiles().unwrap(), vec!["work"]);
207        manager.delete_profile("work").unwrap();
208
209        assert!(!profile_dir.exists());
210        assert!(!manager.legacy_chrome_data_dir("work").exists());
211        assert!(!manager.legacy_metadata_path("work").exists());
212        assert!(manager.list_profiles().unwrap().is_empty());
213        let _ = std::fs::remove_dir_all(profiles_dir);
214    }
215
216    #[test]
217    fn create_profile_migrates_legacy_chrome_data() {
218        let profiles_dir = test_directory();
219        let manager = ProfileManager::with_profiles_dir(profiles_dir.clone());
220        std::fs::create_dir_all(manager.legacy_chrome_data_dir("work")).unwrap();
221        std::fs::write(
222            manager.legacy_chrome_data_dir("work").join("Cookies"),
223            "legacy",
224        )
225        .unwrap();
226
227        let profile_dir = manager.create_profile("work").unwrap();
228
229        assert!(profile_dir.join("Cookies").exists());
230        assert!(!manager.legacy_chrome_data_dir("work").exists());
231        let _ = std::fs::remove_dir_all(profiles_dir);
232    }
233
234    #[test]
235    fn profile_names_ending_in_chrome_are_not_confused_with_legacy_directories() {
236        let profiles_dir = test_directory();
237        let manager = ProfileManager::with_profiles_dir(profiles_dir.clone());
238
239        manager.create_profile("work_chrome").unwrap();
240
241        assert_eq!(manager.list_profiles().unwrap(), vec!["work_chrome"]);
242        let _ = std::fs::remove_dir_all(profiles_dir);
243    }
244}