use std::path::{Path, PathBuf};
use tracing::info;
pub struct ProfileManager {
profiles_dir: PathBuf,
}
impl ProfileManager {
pub fn new() -> Self {
let profiles_dir = std::env::var_os("GLASS_CONFIG_HOME")
.map(PathBuf::from)
.or_else(dirs::config_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join("glass")
.join("profiles");
Self { profiles_dir }
}
pub fn validate_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
if name.is_empty()
|| name == "."
|| name == ".."
|| name.len() > 64
|| !name
.chars()
.all(|character| character.is_ascii_alphanumeric() || "-_".contains(character))
{
return Err(
"profile names must be 1-64 characters of A-Z, a-z, 0-9, '-' or '_'".into(),
);
}
Ok(())
}
pub fn profile_dir(&self, name: &str) -> PathBuf {
self.profile_data_root().join(name)
}
pub fn create_profile(&self, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
Self::validate_name(name)?;
std::fs::create_dir_all(self.profile_data_root())?;
let profile_dir = self.profile_dir(name);
let legacy_chrome_dir = self.legacy_chrome_data_dir(name);
if !profile_dir.exists() && legacy_chrome_dir.is_dir() {
std::fs::rename(&legacy_chrome_dir, &profile_dir)?;
info!(%name, "migrated legacy Chrome profile directory");
}
std::fs::create_dir_all(&profile_dir)?;
info!(%name, path = %profile_dir.display(), "ensured Chrome profile directory");
Ok(profile_dir)
}
pub fn ensure_profile_dir(&self, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
self.create_profile(name)
}
pub fn list_profiles(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
let profile_data_root = self.profile_data_root();
if profile_data_root.exists() {
for entry in std::fs::read_dir(profile_data_root)? {
let entry = entry?;
if entry.path().is_dir()
&& let Some(name) = entry.file_name().to_str()
&& Self::validate_name(name).is_ok()
{
profiles.push(name.to_string());
}
}
}
if self.profiles_dir.exists() {
for entry in std::fs::read_dir(&self.profiles_dir)? {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name();
let Some(file_name) = file_name.to_str() else {
continue;
};
let name = if path.is_dir() {
file_name.strip_suffix("_chrome").map(str::to_string)
} else if path.extension().and_then(|extension| extension.to_str()) == Some("json")
{
Some(file_name.trim_end_matches(".json").to_string())
} else {
None
};
if let Some(name) = name
&& Self::validate_name(&name).is_ok()
{
profiles.push(name);
}
}
}
profiles.sort();
profiles.dedup();
Ok(profiles)
}
pub fn delete_profile(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
Self::validate_name(name)?;
remove_dir_if_exists(&self.profile_dir(name))?;
remove_dir_if_exists(&self.legacy_chrome_data_dir(name))?;
let legacy_metadata = self.legacy_metadata_path(name);
if legacy_metadata.exists() {
std::fs::remove_file(legacy_metadata)?;
}
info!(%name, "deleted persisted Chrome profile data");
Ok(())
}
fn legacy_chrome_data_dir(&self, name: &str) -> PathBuf {
self.profiles_dir.join(format!("{name}_chrome"))
}
fn profile_data_root(&self) -> PathBuf {
self.profiles_dir.join("data")
}
fn legacy_metadata_path(&self, name: &str) -> PathBuf {
self.profiles_dir.join(format!("{name}.json"))
}
#[cfg(test)]
fn with_profiles_dir(profiles_dir: PathBuf) -> Self {
Self { profiles_dir }
}
}
fn remove_dir_if_exists(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
if path.exists() {
std::fs::remove_dir_all(path)?;
}
Ok(())
}
impl Default for ProfileManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0);
fn test_directory() -> PathBuf {
let id = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("glass-profile-test-{}-{id}", std::process::id()))
}
#[test]
fn validates_profile_names() {
assert!(ProfileManager::validate_name("work").is_ok());
assert!(ProfileManager::validate_name("personal_2026").is_ok());
assert!(ProfileManager::validate_name("../escape").is_err());
assert!(ProfileManager::validate_name("with space").is_err());
}
#[test]
fn profiles_are_chrome_data_directories_and_delete_removes_legacy_data() {
let profiles_dir = test_directory();
let manager = ProfileManager::with_profiles_dir(profiles_dir.clone());
let profile_dir = manager.create_profile("work").unwrap();
std::fs::write(profile_dir.join("Cookies"), "persisted-by-chrome").unwrap();
std::fs::create_dir_all(manager.legacy_chrome_data_dir("work")).unwrap();
std::fs::write(
manager.legacy_chrome_data_dir("work").join("History"),
"legacy",
)
.unwrap();
std::fs::write(manager.legacy_metadata_path("work"), "{}\n").unwrap();
assert_eq!(manager.list_profiles().unwrap(), vec!["work"]);
manager.delete_profile("work").unwrap();
assert!(!profile_dir.exists());
assert!(!manager.legacy_chrome_data_dir("work").exists());
assert!(!manager.legacy_metadata_path("work").exists());
assert!(manager.list_profiles().unwrap().is_empty());
let _ = std::fs::remove_dir_all(profiles_dir);
}
#[test]
fn create_profile_migrates_legacy_chrome_data() {
let profiles_dir = test_directory();
let manager = ProfileManager::with_profiles_dir(profiles_dir.clone());
std::fs::create_dir_all(manager.legacy_chrome_data_dir("work")).unwrap();
std::fs::write(
manager.legacy_chrome_data_dir("work").join("Cookies"),
"legacy",
)
.unwrap();
let profile_dir = manager.create_profile("work").unwrap();
assert!(profile_dir.join("Cookies").exists());
assert!(!manager.legacy_chrome_data_dir("work").exists());
let _ = std::fs::remove_dir_all(profiles_dir);
}
#[test]
fn profile_names_ending_in_chrome_are_not_confused_with_legacy_directories() {
let profiles_dir = test_directory();
let manager = ProfileManager::with_profiles_dir(profiles_dir.clone());
manager.create_profile("work_chrome").unwrap();
assert_eq!(manager.list_profiles().unwrap(), vec!["work_chrome"]);
let _ = std::fs::remove_dir_all(profiles_dir);
}
}