ffbm/
profile.rs

1//! Firefox profile discovery and validation.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use rusqlite::Connection;
8
9use crate::error::{Error, Result};
10use crate::types::Profile;
11
12/// Returns the Firefox application directory.
13///
14/// On macOS, this is `~/Library/Application Support/Firefox`.
15///
16/// # Errors
17///
18/// Returns `FirefoxDirNotFound` if the home directory cannot be determined or
19/// the Firefox directory does not exist.
20fn firefox_dir() -> Result<PathBuf> {
21    let home = dirs::home_dir().ok_or(Error::FirefoxDirNotFound)?;
22    let firefox = home.join("Library/Application Support/Firefox");
23    if firefox.is_dir() {
24        Ok(firefox)
25    } else {
26        Err(Error::FirefoxDirNotFound)
27    }
28}
29
30/// List all Firefox profiles from Profile Groups databases.
31///
32/// Scans `~/Library/Application Support/Firefox/Profile Groups/*.sqlite` for
33/// profile information. Each profile must have a valid `places.sqlite` to be
34/// included.
35///
36/// # Errors
37///
38/// Returns an error if the Firefox directory is not found or no Profile Groups
39/// databases exist.
40pub fn list_profiles() -> Result<Vec<Profile>> {
41    let firefox = firefox_dir()?;
42    let groups_dir = firefox.join("Profile Groups");
43
44    if !groups_dir.is_dir() {
45        return Err(Error::NoProfileGroups);
46    }
47
48    // Find all .sqlite files in Profile Groups directory
49    let entries = fs::read_dir(&groups_dir)?;
50    let mut sqlite_files: Vec<PathBuf> = Vec::new();
51    for entry in entries {
52        let entry = entry?;
53        let path = entry.path();
54        if path.extension().is_some_and(|ext| ext == "sqlite") {
55            sqlite_files.push(path);
56        }
57    }
58
59    if sqlite_files.is_empty() {
60        return Err(Error::NoProfileGroups);
61    }
62
63    // Collect profiles from all databases, deduplicating by path
64    let mut profiles_by_path: HashMap<PathBuf, Profile> = HashMap::new();
65
66    for db_path in &sqlite_files {
67        if let Ok(conn) = Connection::open(db_path)
68            && let Ok(mut stmt) = conn.prepare("SELECT name, path FROM Profiles")
69        {
70            let rows = stmt.query_map([], |row| {
71                let name: String = row.get(0)?;
72                let rel_path: String = row.get(1)?;
73                Ok((name, rel_path))
74            });
75
76            if let Ok(rows) = rows {
77                for row in rows.flatten() {
78                    let (name, rel_path) = row;
79                    // Convert relative path to absolute
80                    let abs_path = firefox.join(&rel_path);
81
82                    // Only include profiles with places.sqlite
83                    if abs_path.join("places.sqlite").exists() {
84                        profiles_by_path.insert(
85                            abs_path.clone(),
86                            Profile {
87                                name,
88                                path: abs_path,
89                            },
90                        );
91                    }
92                }
93            }
94        }
95    }
96
97    if profiles_by_path.is_empty() {
98        return Err(Error::NoProfileGroups);
99    }
100
101    // Sort by name for consistent output
102    let mut profiles: Vec<Profile> = profiles_by_path.into_values().collect();
103    profiles.sort_by(|a, b| a.name.cmp(&b.name));
104
105    Ok(profiles)
106}
107
108/// Find a profile by name (case-insensitive exact match).
109///
110/// # Errors
111///
112/// Returns `ProfileNotFound` if no profile matches, or `AmbiguousProfile` if
113/// multiple profiles have the same name (case-insensitively).
114pub fn find_profile(name: &str) -> Result<Profile> {
115    let profiles = list_profiles()?;
116    let name_lower = name.to_lowercase();
117
118    let matches: Vec<&Profile> = profiles
119        .iter()
120        .filter(|p| p.name.to_lowercase() == name_lower)
121        .collect();
122
123    match matches.len() {
124        0 => Err(Error::ProfileNotFound {
125            name: name.to_string(),
126        }),
127        1 => Ok(matches[0].clone()),
128        _ => Err(Error::AmbiguousProfile {
129            name: name.to_string(),
130            matches: matches.iter().map(|p| p.name.clone()).collect(),
131        }),
132    }
133}
134
135/// Check if Firefox is currently running by examining the profile.
136///
137/// Returns `Ok(())` if Firefox is not running, `Err(FirefoxRunning)` if it is.
138///
139/// # Errors
140///
141/// Returns `FirefoxRunning` if Firefox is running, or a database error if the
142/// check fails for other reasons.
143pub fn check_firefox_not_running(profile_path: &Path) -> Result<()> {
144    // Check for .parentlock file with non-zero size or locked
145    let parentlock = profile_path.join(".parentlock");
146    if parentlock.exists() {
147        // On macOS, the file exists but may be empty when Firefox is closed.
148        // When Firefox is running, it holds a lock on the file.
149        // We can try to open the places.sqlite and check for SQLITE_BUSY.
150        let places = profile_path.join("places.sqlite");
151        if places.exists() {
152            // Try to open with exclusive access
153            match Connection::open_with_flags(
154                &places,
155                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
156            ) {
157                Ok(conn) => {
158                    // Try a simple query to see if we get SQLITE_BUSY
159                    match conn.execute_batch("SELECT 1 FROM moz_bookmarks LIMIT 1") {
160                        Ok(()) => Ok(()),
161                        Err(e) => {
162                            if is_busy_error(&e) {
163                                Err(Error::FirefoxRunning)
164                            } else {
165                                Err(Error::Sqlite(e))
166                            }
167                        }
168                    }
169                }
170                Err(e) => {
171                    if is_busy_error(&e) {
172                        Err(Error::FirefoxRunning)
173                    } else {
174                        Err(Error::Sqlite(e))
175                    }
176                }
177            }
178        } else {
179            Ok(())
180        }
181    } else {
182        Ok(())
183    }
184}
185
186/// Check if a rusqlite error indicates the database is locked/busy.
187fn is_busy_error(e: &rusqlite::Error) -> bool {
188    matches!(
189        e,
190        rusqlite::Error::SqliteFailure(
191            rusqlite::ffi::Error {
192                code: rusqlite::ffi::ErrorCode::DatabaseBusy
193                    | rusqlite::ffi::ErrorCode::DatabaseLocked,
194                ..
195            },
196            _
197        )
198    )
199}