diff --git a/rust/src/browser/mod.rs b/rust/src/browser/mod.rs index 035fb6e6d2..b15cbdf3c5 100755 --- a/rust/src/browser/mod.rs +++ b/rust/src/browser/mod.rs @@ -4,6 +4,7 @@ pub mod cookie_cache; pub mod cookies; pub mod detection; pub mod leveldb; +pub mod storage_discovery; pub mod watchdog; pub mod wsl_paths; diff --git a/rust/src/browser/storage_discovery.rs b/rust/src/browser/storage_discovery.rs new file mode 100644 index 0000000000..49d3a63d53 --- /dev/null +++ b/rust/src/browser/storage_discovery.rs @@ -0,0 +1,139 @@ +//! Locates raw Chromium profile stores (Local Storage, Session Storage, IndexedDB) across the +//! installed Chromium-family browsers. +//! +//! Each consumer supplies its own decoder; this module only answers "which directories could hold +//! the data". It never opens, locks, or decrypts anything, so it is safe to run while the browser +//! is open and needs no credential-store access. +//! +//! The browser catalog is [`super::detection::BrowserType`] (via [`BrowserDetector`]); a +//! Chromium-family browser that is not in that catalog is not visited. + +use std::path::{Path, PathBuf}; + +use super::detection::BrowserDetector; +use super::leveldb::local_storage::local_storage_dir; + +const INDEXED_DB_DIR: &str = "IndexedDB"; +const SESSION_STORAGE_DIR: &str = "Session Storage"; +const INDEXED_DB_SUFFIX: &str = ".indexeddb.leveldb"; + +/// Which per-profile store to locate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StorageKind { + /// `/Local Storage/leveldb` + LocalStorage, + /// `/Session Storage` + SessionStorage, + /// `/IndexedDB/.indexeddb.leveldb` for every database whose directory name + /// starts with one of `origin_prefixes` (for example `https_platform.minimax.io_`). + IndexedDb { + origin_prefixes: &'static [&'static str], + }, +} + +impl StorageKind { + fn label_suffix(self) -> &'static str { + match self { + Self::LocalStorage => "", + Self::SessionStorage => " (Session Storage)", + Self::IndexedDb { .. } => " (IndexedDB)", + } + } +} + +/// One directory that may hold data of the requested [`StorageKind`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StorageCandidate { + /// Human-readable source, such as `Google Chrome Default (Session Storage)`. + pub label: String, + pub path: PathBuf, +} + +/// Candidates for `kind` in every installed Chromium-family browser, in catalog order. +pub fn discover(kind: StorageKind) -> Vec { + BrowserDetector::detect_all() + .iter() + .filter(|browser| browser.browser_type.is_chromium_based()) + .flat_map(|browser| { + candidates_in_user_data_dir( + &browser.user_data_dir, + browser.browser_type.display_name(), + kind, + ) + }) + .collect() +} + +/// Candidates for `kind` in one browser `User Data` directory, profiles sorted by name. +/// +/// Only `Default`, `Profile *`, and `user-*` directories count as profiles; guest and system +/// profiles are ignored. +pub fn candidates_in_user_data_dir( + user_data_dir: &Path, + label_prefix: &str, + kind: StorageKind, +) -> Vec { + sorted_child_dirs(user_data_dir, is_profile_dir_name) + .into_iter() + .flat_map(|(name, profile_dir)| { + let label = format!("{label_prefix} {name}{}", kind.label_suffix()); + profile_store_paths(&profile_dir, kind) + .into_iter() + .map(move |path| StorageCandidate { + label: label.clone(), + path, + }) + }) + .collect() +} + +fn profile_store_paths(profile_dir: &Path, kind: StorageKind) -> Vec { + match kind { + StorageKind::LocalStorage => existing(local_storage_dir(profile_dir)), + StorageKind::SessionStorage => existing(profile_dir.join(SESSION_STORAGE_DIR)), + StorageKind::IndexedDb { origin_prefixes } => { + sorted_child_dirs(&profile_dir.join(INDEXED_DB_DIR), |name| { + name.ends_with(INDEXED_DB_SUFFIX) + && origin_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) + }) + .into_iter() + .map(|(_, path)| path) + .collect() + } + } +} + +fn existing(path: PathBuf) -> Vec { + if path.exists() { + vec![path] + } else { + Vec::new() + } +} + +fn is_profile_dir_name(name: &str) -> bool { + name == "Default" || name.starts_with("Profile ") || name.starts_with("user-") +} + +/// Child directories of `dir` whose (non-hidden, valid UTF-8) name passes `keep`, sorted by name. +/// A missing or unreadable `dir` yields nothing. +fn sorted_child_dirs(dir: &Path, keep: impl Fn(&str) -> bool) -> Vec<(String, PathBuf)> { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut children: Vec<(String, PathBuf)> = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + let is_dir = entry.file_type().is_ok_and(|kind| kind.is_dir()); + (is_dir && !name.starts_with('.') && keep(&name)).then(|| (name, entry.path())) + }) + .collect(); + children.sort_by(|left, right| left.0.cmp(&right.0)); + children +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/browser/storage_discovery/tests.rs b/rust/src/browser/storage_discovery/tests.rs new file mode 100644 index 0000000000..e6f2239024 --- /dev/null +++ b/rust/src/browser/storage_discovery/tests.rs @@ -0,0 +1,158 @@ +use super::*; +use std::fs; + +const PREFIXES: &[&str] = &[ + "https_platform.minimax.io_", + "https_www.minimax.io_", + "https_minimax.io_", + "https_platform.minimaxi.com_", + "https_minimaxi.com_", + "https_www.minimaxi.com_", +]; + +fn indexed_db() -> StorageKind { + StorageKind::IndexedDb { + origin_prefixes: PREFIXES, + } +} + +fn mkdir(root: &Path, relative: &str) { + fs::create_dir_all(root.join(relative)).unwrap(); +} + +fn paths(root: &Path, candidates: &[StorageCandidate]) -> Vec { + candidates + .iter() + .map(|candidate| { + candidate + .path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) + .collect() +} + +#[test] +fn each_storage_kind_resolves_to_its_profile_directory() { + let root = tempfile::tempdir().unwrap(); + let database = "IndexedDB/https_platform.minimax.io_0.indexeddb.leveldb"; + for path in ["Local Storage/leveldb", "Session Storage", database] { + mkdir(root.path(), &format!("Default/{path}")); + } + + let cases = [ + ( + StorageKind::LocalStorage, + "Default/Local Storage/leveldb", + "", + ), + ( + StorageKind::SessionStorage, + "Default/Session Storage", + " (Session Storage)", + ), + (indexed_db(), &format!("Default/{database}"), " (IndexedDB)"), + ]; + for (kind, expected_path, suffix) in cases { + let found = candidates_in_user_data_dir(root.path(), "Google Chrome", kind); + assert_eq!(paths(root.path(), &found), [expected_path]); + assert_eq!(found[0].label, format!("Google Chrome Default{suffix}")); + } +} + +#[test] +fn missing_stores_and_missing_user_data_dir_yield_nothing() { + let root = tempfile::tempdir().unwrap(); + mkdir(root.path(), "Default"); + for kind in [ + StorageKind::LocalStorage, + StorageKind::SessionStorage, + indexed_db(), + ] { + assert!(candidates_in_user_data_dir(root.path(), "Chrome", kind).is_empty()); + assert!( + candidates_in_user_data_dir(&root.path().join("absent"), "Chrome", kind).is_empty() + ); + } +} + +#[test] +fn only_default_profile_and_user_profiles_are_visited_in_name_order() { + let root = tempfile::tempdir().unwrap(); + for profile in [ + "user-work", + "Profile 2", + "Default", + "Guest Profile", + "System Profile", + ".hidden", + "Profile1", + ] { + mkdir(root.path(), &format!("{profile}/Local Storage/leveldb")); + } + // A file named like a profile is not a profile. + fs::write(root.path().join("Profile 9"), b"x").unwrap(); + + let found = candidates_in_user_data_dir(root.path(), "Edge", StorageKind::LocalStorage); + assert_eq!( + found + .iter() + .map(|candidate| candidate.label.as_str()) + .collect::>(), + ["Edge Default", "Edge Profile 2", "Edge user-work"] + ); +} + +#[test] +fn indexed_db_keeps_only_allowed_origin_databases_that_are_directories() { + let root = tempfile::tempdir().unwrap(); + let allowed = [ + "https_platform.minimax.io_0", + "https_www.minimax.io_0", + "https_minimax.io_0", + "https_platform.minimaxi.com_0", + "https_www.minimaxi.com_0", + "https_minimaxi.com_0", + ] + .map(|origin| format!("{origin}.indexeddb.leveldb")); + let excluded = [ + "https_other.example_0.indexeddb.leveldb", + "https_minimax.io.evil_0.indexeddb.leveldb", + "https_minimax.io_0.indexeddb.blob", + ".https_minimax.io_0.indexeddb.leveldb", + ]; + for profile in ["Default", "Profile 2", "user-work", "Guest Profile"] { + for database in allowed.iter().map(String::as_str).chain(excluded) { + mkdir(root.path(), &format!("{profile}/IndexedDB/{database}")); + } + } + fs::write( + root.path() + .join("Default/IndexedDB/https_minimax.io_1.indexeddb.leveldb"), + b"x", + ) + .unwrap(); + + let found = candidates_in_user_data_dir(root.path(), "Fixture", indexed_db()); + + assert_eq!(found.len(), 3 * allowed.len()); + let mut expected_names: Vec<&str> = allowed.iter().map(String::as_str).collect(); + expected_names.sort_unstable(); + for (profile, group) in ["Default", "Profile 2", "user-work"] + .iter() + .zip(found.chunks(allowed.len())) + { + let names: Vec<_> = group + .iter() + .map(|candidate| candidate.path.file_name().unwrap().to_str().unwrap()) + .collect(); + assert_eq!(names, expected_names); + assert!( + group + .iter() + .all(|candidate| candidate.label == format!("Fixture {profile} (IndexedDB)")) + ); + } +} diff --git a/rust/src/providers/minimax/local_storage.rs b/rust/src/providers/minimax/local_storage.rs index a4a3d0617a..41ef8c8d29 100755 --- a/rust/src/providers/minimax/local_storage.rs +++ b/rust/src/providers/minimax/local_storage.rs @@ -1,10 +1,12 @@ //! MiniMax LocalStorage Importer //! -//! Extracts session data from browser localStorage for MiniMax platform. -//! Supports Chrome, Edge, Firefox, and Brave browsers. +//! Extracts session data from Chromium browser storage for the MiniMax platform. +//! Storage directories come from `browser::storage_discovery` (every installed Chromium-family +//! browser and profile: Local Storage, then Session Storage, then MiniMax IndexedDB). +use crate::browser::storage_discovery::{self, StorageCandidate, StorageKind}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::Path; /// Session data extracted from MiniMax localStorage #[derive(Debug, Clone, Serialize, Deserialize)] @@ -40,111 +42,59 @@ impl std::fmt::Display for ImportError { impl std::error::Error for ImportError {} +/// Origin prefixes of the MiniMax IndexedDB databases (`__`). +const INDEXED_DB_ORIGIN_PREFIXES: &[&str] = &[ + "https_platform.minimax.io_", + "https_www.minimax.io_", + "https_minimax.io_", + "https_platform.minimaxi.com_", + "https_minimaxi.com_", + "https_www.minimaxi.com_", +]; + +/// Stores tried in order; a later store is read only when earlier ones yield no session. +const STORAGE_ORDER: [StorageKind; 3] = [ + StorageKind::LocalStorage, + StorageKind::SessionStorage, + StorageKind::IndexedDb { + origin_prefixes: INDEXED_DB_ORIGIN_PREFIXES, + }, +]; + /// MiniMax localStorage importer pub struct MiniMaxLocalStorageImporter; impl MiniMaxLocalStorageImporter { - /// Import MiniMax session from browser localStorage + /// Import a MiniMax session from Chromium browser storage. + /// + /// Visits every installed Chromium-family browser and profile: Local Storage first, then + /// Session Storage, then MiniMax-origin IndexedDB when the earlier stores yield nothing. pub fn import_session() -> Result { - // Try browsers in order of preference - let browsers = Self::get_browser_paths(); - - for (browser_name, ls_path) in browsers { - if let Ok(session) = Self::extract_from_path(&ls_path, &browser_name) { - return Ok(session); - } - } - - Err(ImportError::BrowserNotFound) + Self::import_with(storage_discovery::discover) } - /// Get paths to browser localStorage databases - fn get_browser_paths() -> Vec<(String, PathBuf)> { - #[allow( - unused_mut, - reason = "mutability needed for conditional initialization that the compiler cannot prove" - )] - let mut paths = Vec::new(); - - #[cfg(target_os = "windows")] - { - if let Some(local_data) = dirs::data_local_dir() { - // Chrome - let chrome_path = local_data - .join("Google") - .join("Chrome") - .join("User Data") - .join("Default") - .join("Local Storage") - .join("leveldb"); - if chrome_path.exists() { - paths.push(("Chrome".to_string(), chrome_path)); - } - - // Edge - let edge_path = local_data - .join("Microsoft") - .join("Edge") - .join("User Data") - .join("Default") - .join("Local Storage") - .join("leveldb"); - if edge_path.exists() { - paths.push(("Edge".to_string(), edge_path)); - } - - // Brave - let brave_path = local_data - .join("BraveSoftware") - .join("Brave-Browser") - .join("User Data") - .join("Default") - .join("Local Storage") - .join("leveldb"); - if brave_path.exists() { - paths.push(("Brave".to_string(), brave_path)); - } - } - } - - #[cfg(target_os = "macos")] - { - if let Some(home) = dirs::home_dir() { - // Chrome - let chrome_path = home - .join("Library") - .join("Application Support") - .join("Google") - .join("Chrome") - .join("Default") - .join("Local Storage") - .join("leveldb"); - if chrome_path.exists() { - paths.push(("Chrome".to_string(), chrome_path)); - } - - // Edge - let edge_path = home - .join("Library") - .join("Application Support") - .join("Microsoft Edge") - .join("Default") - .join("Local Storage") - .join("leveldb"); - if edge_path.exists() { - paths.push(("Edge".to_string(), edge_path)); + fn import_with( + discover: impl Fn(StorageKind) -> Vec, + ) -> Result { + let mut found_any_store = false; + for kind in STORAGE_ORDER { + for candidate in discover(kind) { + found_any_store = true; + if let Ok(session) = Self::extract_from_path(&candidate.path, &candidate.label) { + return Ok(session); } } } - paths + Err(if found_any_store { + ImportError::StorageNotFound + } else { + ImportError::BrowserNotFound + }) } - /// Extract session from a localStorage path - fn extract_from_path( - path: &PathBuf, - browser_name: &str, - ) -> Result { + /// Extract session from a storage directory + fn extract_from_path(path: &Path, source_label: &str) -> Result { // Look for .ldb or .log files let entries = std::fs::read_dir(path).map_err(|e| ImportError::AccessDenied(e.to_string()))?; @@ -168,7 +118,7 @@ impl MiniMaxLocalStorageImporter { } match minimax_data { - Some(json) => Self::parse_session_from_json(&json, browser_name), + Some(json) => Self::parse_session_from_json(&json, source_label), None => Err(ImportError::StorageNotFound), } } @@ -230,7 +180,7 @@ impl MiniMaxLocalStorageImporter { /// Parse session from extracted JSON fn parse_session_from_json( json: &serde_json::Value, - browser_name: &str, + source_label: &str, ) -> Result { let access_token = json .get("access_token") @@ -290,7 +240,7 @@ impl MiniMaxLocalStorageImporter { email, phone, plan_type, - source_label: format!("{} localStorage", browser_name), + source_label: source_label.to_string(), }) } } @@ -325,4 +275,101 @@ mod tests { let result = MiniMaxLocalStorageImporter::parse_session_from_json(&json, "Chrome"); assert!(result.is_err()); } + + fn candidate(label: &str, path: &Path) -> StorageCandidate { + StorageCandidate { + label: label.to_string(), + path: path.to_path_buf(), + } + } + + fn write_store(dir: &Path, token: Option<&str>) -> std::path::PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let body = match token { + Some(token) => { + format!("minimax_user{{\"access_token\":\"{token}\",\"user_id\":\"1\"}}") + } + None => "unrelated".to_string(), + }; + std::fs::write(dir.join("000003.log"), body).unwrap(); + dir.to_path_buf() + } + + fn discover_from( + local: Vec, + session: Vec, + indexed: Vec, + ) -> impl Fn(StorageKind) -> Vec { + move |kind| match kind { + StorageKind::LocalStorage => local.clone(), + StorageKind::SessionStorage => session.clone(), + StorageKind::IndexedDb { origin_prefixes } => { + assert_eq!(origin_prefixes, INDEXED_DB_ORIGIN_PREFIXES); + indexed.clone() + } + } + } + + #[test] + fn import_prefers_local_storage_over_later_stores() { + let root = tempfile::tempdir().unwrap(); + let local = write_store(&root.path().join("local"), Some("from-local")); + let session = write_store(&root.path().join("session"), Some("from-session")); + + let found = MiniMaxLocalStorageImporter::import_with(discover_from( + vec![candidate("Chrome Default", &local)], + vec![candidate("Chrome Default (Session Storage)", &session)], + Vec::new(), + )) + .unwrap(); + + assert_eq!(found.access_token.as_deref(), Some("from-local")); + assert_eq!(found.source_label, "Chrome Default"); + } + + #[test] + fn import_falls_back_to_session_then_indexed_db_when_earlier_stores_are_empty() { + let root = tempfile::tempdir().unwrap(); + let local = write_store(&root.path().join("local"), None); + let session = write_store(&root.path().join("session"), None); + let indexed = write_store(&root.path().join("indexed"), Some("from-indexed")); + + let found = MiniMaxLocalStorageImporter::import_with(discover_from( + vec![candidate("Edge Default", &local)], + vec![candidate("Edge Default (Session Storage)", &session)], + vec![candidate("Edge Default (IndexedDB)", &indexed)], + )) + .unwrap(); + assert_eq!(found.access_token.as_deref(), Some("from-indexed")); + assert_eq!(found.source_label, "Edge Default (IndexedDB)"); + + let session_token = write_store(&root.path().join("session2"), Some("from-session")); + let found = MiniMaxLocalStorageImporter::import_with(discover_from( + vec![candidate("Edge Default", &local)], + vec![candidate("Edge Default (Session Storage)", &session_token)], + vec![candidate("Edge Default (IndexedDB)", &indexed)], + )) + .unwrap(); + assert_eq!(found.access_token.as_deref(), Some("from-session")); + } + + #[test] + fn import_distinguishes_no_browser_from_no_session() { + let root = tempfile::tempdir().unwrap(); + let empty = write_store(&root.path().join("empty"), None); + + let none = MiniMaxLocalStorageImporter::import_with(discover_from( + Vec::new(), + Vec::new(), + Vec::new(), + )); + assert!(matches!(none, Err(ImportError::BrowserNotFound))); + + let no_session = MiniMaxLocalStorageImporter::import_with(discover_from( + vec![candidate("Brave Default", &empty)], + Vec::new(), + Vec::new(), + )); + assert!(matches!(no_session, Err(ImportError::StorageNotFound))); + } }