diff --git a/rust/src/browser/detection.rs b/rust/src/browser/detection.rs index 0dc39f27d7..cac201ab5e 100755 --- a/rust/src/browser/detection.rs +++ b/rust/src/browser/detection.rs @@ -206,36 +206,26 @@ impl BrowserDetector { } /// Detect Chromium-based browser profiles - fn detect_chromium_profiles(user_data_dir: &PathBuf) -> Vec { - let mut profiles = Vec::new(); - - // Default profile - let default_path = user_data_dir.join("Default"); - if default_path.exists() { - profiles.push(BrowserProfile { - name: "Default".to_string(), - path: default_path, - is_default: true, - }); - } - - // Additional profiles (Profile 1, Profile 2, etc.) - if let Ok(entries) = std::fs::read_dir(user_data_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("Profile ") { - let path = entry.path(); - if path.is_dir() { - profiles.push(BrowserProfile { - name, - path, - is_default: false, - }); - } - } - } - } + pub(super) fn detect_chromium_profiles(user_data_dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(user_data_dir) else { + return Vec::new(); + }; + let mut profiles: Vec<_> = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + let path = entry.path(); + let is_profile = + name == "Default" || name.starts_with("Profile ") || name.starts_with("user-"); + (is_profile && path.is_dir()).then(|| BrowserProfile { + is_default: name == "Default", + name, + path, + }) + }) + .collect(); + profiles.sort_by(|left, right| left.name.cmp(&right.name)); profiles } diff --git a/rust/src/browser/leveldb/local_storage.rs b/rust/src/browser/leveldb/local_storage.rs index a2e3d5236e..04ede1d75e 100644 --- a/rust/src/browser/leveldb/local_storage.rs +++ b/rust/src/browser/leveldb/local_storage.rs @@ -32,7 +32,20 @@ pub fn read_local_storage_entries( dir: &Path, origin: &str, ) -> Result, LevelDbError> { - Ok(decode_origin_entries(&read_entries(dir)?, origin)) + read_local_storage_entries_for_origins(dir, &[origin]) +} + +/// Read `localStorage` items for any of `origins` with one LevelDB scan, sorted by key within +/// each origin. A trailing slash on an origin is ignored. +pub fn read_local_storage_entries_for_origins( + dir: &Path, + origins: &[&str], +) -> Result, LevelDbError> { + let entries = read_entries(dir)?; + Ok(origins + .iter() + .flat_map(|origin| decode_origin_entries(&entries, origin)) + .collect()) } pub(super) fn decode_origin_entries(entries: &[Entry], origin: &str) -> Vec { 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..ab6214e43b --- /dev/null +++ b/rust/src/browser/storage_discovery.rs @@ -0,0 +1,141 @@ +//! 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_profiles(&browser.profiles, 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. +#[cfg(test)] +fn candidates_in_user_data_dir( + user_data_dir: &Path, + label_prefix: &str, + kind: StorageKind, +) -> Vec { + let profiles = BrowserDetector::detect_chromium_profiles(user_data_dir); + candidates_in_profiles(&profiles, label_prefix, kind) +} + +fn candidates_in_profiles( + profiles: &[super::detection::BrowserProfile], + label_prefix: &str, + kind: StorageKind, +) -> Vec { + profiles + .iter() + .flat_map(|profile| { + let label = format!("{label_prefix} {}{}", profile.name, kind.label_suffix()); + profile_store_paths(&profile.path, 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.is_dir() { + vec![path] + } else { + Vec::new() + } +} + +/// 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..3225be47e1 100755 --- a/rust/src/providers/minimax/local_storage.rs +++ b/rust/src/providers/minimax/local_storage.rs @@ -1,10 +1,16 @@ //! 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::leveldb::local_storage::{ + LocalStorageEntry, read_local_storage_entries_for_origins, +}; +use crate::browser::leveldb::{self, Entry}; +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,154 +46,135 @@ 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_", +]; + +const MINIMAX_LOCAL_STORAGE_ORIGINS: &[&str] = &[ + "https://platform.minimax.io", + "https://www.minimax.io", + "https://minimax.io", + "https://platform.minimaxi.com", + "https://www.minimaxi.com", + "https://minimaxi.com", +]; + +const MINIMAX_SESSION_PATTERNS: &[&str] = &[ + "minimax_user", + "minimax_session", + "platform.minimaxi.com", + "mm_token", + "mm_user_info", +]; + +/// 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(); + Self::import_with(storage_discovery::discover) + } - for (browser_name, ls_path) in browsers { - if let Ok(session) = Self::extract_from_path(&ls_path, &browser_name) { - return Ok(session); + fn import_with( + discover: impl Fn(StorageKind) -> Vec, + ) -> Result { + let mut found_any_store = false; + let mut last_error = None; + for kind in STORAGE_ORDER { + for candidate in discover(kind) { + found_any_store = true; + match Self::extract_from_path(&candidate.path, &candidate.label, kind) { + Ok(session) => return Ok(session), + Err(ImportError::StorageNotFound) => {} + Err(error) => last_error = Some(error), + } } } - Err(ImportError::BrowserNotFound) + Err(if found_any_store { + last_error.unwrap_or(ImportError::StorageNotFound) + } else { + ImportError::BrowserNotFound + }) } - /// 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)); - } + /// Extract a MiniMax session from one decoded Chromium storage directory. + fn extract_from_path( + path: &Path, + source_label: &str, + kind: StorageKind, + ) -> Result { + let minimax_data = match kind { + StorageKind::LocalStorage => { + let entries = + read_local_storage_entries_for_origins(path, MINIMAX_LOCAL_STORAGE_ORIGINS) + .map_err(|error| ImportError::AccessDenied(error.to_string()))?; + entries.iter().find_map(Self::extract_local_storage_json) } + StorageKind::SessionStorage | StorageKind::IndexedDb { .. } => { + let entries = leveldb::read_entries(path) + .map_err(|error| ImportError::AccessDenied(error.to_string()))?; + entries.iter().find_map(Self::extract_minimax_entry) + } + }; + + match minimax_data { + Some(json) => Self::parse_session_from_json(&json, source_label), + None => Err(ImportError::StorageNotFound), } + } - #[cfg(target_os = "macos")] + fn extract_local_storage_json(entry: &LocalStorageEntry) -> Option { + if MINIMAX_SESSION_PATTERNS + .iter() + .any(|pattern| entry.key.contains(pattern)) { - 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)); - } - } + return serde_json::from_str(&entry.value) + .ok() + .or_else(|| Self::extract_minimax_json(entry.value.as_bytes())); } - paths + Self::extract_minimax_json(entry.value.as_bytes()) } - /// Extract session from a localStorage path - fn extract_from_path( - path: &PathBuf, - browser_name: &str, - ) -> Result { - // Look for .ldb or .log files - let entries = - std::fs::read_dir(path).map_err(|e| ImportError::AccessDenied(e.to_string()))?; - - let mut minimax_data: Option = None; - - for entry in entries.flatten() { - let entry_path = entry.path(); - if let Some(ext) = entry_path.extension() - && (ext == "ldb" || ext == "log") - { - // Read file and search for MiniMax data - if let Ok(contents) = std::fs::read(&entry_path) { - // Search for MiniMax-related JSON in the binary content - if let Some(data) = Self::extract_minimax_json(&contents) { - minimax_data = Some(data); - break; - } - } - } + fn extract_minimax_entry(entry: &Entry) -> Option { + let key = String::from_utf8_lossy(&entry.key); + let value = String::from_utf8_lossy(&entry.value); + if MINIMAX_SESSION_PATTERNS + .iter() + .any(|pattern| key.contains(pattern)) + { + return serde_json::from_str(&value) + .ok() + .or_else(|| Self::extract_minimax_json(&entry.value)); } - match minimax_data { - Some(json) => Self::parse_session_from_json(&json, browser_name), - None => Err(ImportError::StorageNotFound), - } + Self::extract_minimax_json(&entry.value).or_else(|| Self::extract_minimax_json(&entry.key)) } - /// Extract MiniMax JSON from binary localStorage data + /// Find MiniMax JSON embedded in a decoded LevelDB key or value. fn extract_minimax_json(data: &[u8]) -> Option { - // Convert to string, handling binary data let content = String::from_utf8_lossy(data); - - // Look for patterns that indicate MiniMax session data - let patterns = [ - "minimax_user", - "minimax_session", - "platform.minimaxi.com", - "mm_token", - "mm_user_info", - ]; - - for pattern in patterns { + for pattern in MINIMAX_SESSION_PATTERNS { if let Some(parsed) = Self::extract_json_after_pattern(&content, pattern) { return Some(parsed); } @@ -212,8 +199,19 @@ impl MiniMaxLocalStorageImporter { fn matching_json_object_end(content: &str) -> Option { let mut depth = 0; + let mut in_string = false; + let mut escaped = false; for (i, c) in content.char_indices() { + if in_string { + match c { + '\\' if !escaped => escaped = true, + '"' if !escaped => in_string = false, + _ => escaped = false, + } + continue; + } match c { + '"' => in_string = true, '{' => depth += 1, '}' => { depth -= 1; @@ -230,7 +228,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 +288,7 @@ impl MiniMaxLocalStorageImporter { email, phone, plan_type, - source_label: format!("{} localStorage", browser_name), + source_label: source_label.to_string(), }) } } @@ -325,4 +323,136 @@ 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 (key, value, local_key, local_value) = match token { + Some(token) => { + let json = format!(r#"{{"access_token":"{token}","user_id":"1"}}"#); + let mut local_value = vec![1]; + local_value.extend_from_slice(json.as_bytes()); + ( + b"minimax_user".to_vec(), + json.as_bytes().to_vec(), + b"_https://platform.minimax.io\0\x01minimax_user".to_vec(), + local_value, + ) + } + None => ( + b"unrelated".to_vec(), + b"unrelated".to_vec(), + b"_https://platform.minimax.io\0\x01unrelated".to_vec(), + b"\x01unrelated".to_vec(), + ), + }; + + let mut batch = Vec::new(); + batch.extend_from_slice(&1u64.to_le_bytes()); + batch.extend_from_slice(&2u32.to_le_bytes()); + for (key, value) in [(key, value), (local_key, local_value)] { + batch.push(1); // put + append_varint(&mut batch, key.len()); + batch.extend_from_slice(&key); + append_varint(&mut batch, value.len()); + batch.extend_from_slice(&value); + } + let mut log = vec![0; 4]; // the best-effort reader does not verify checksums + log.extend_from_slice(&u16::try_from(batch.len()).unwrap().to_le_bytes()); + log.push(1); // full physical record + log.extend_from_slice(&batch); + std::fs::write(dir.join("000003.log"), log).unwrap(); + dir.to_path_buf() + } + + fn append_varint(output: &mut Vec, mut value: usize) { + while value >= 0x80 { + output.push(u8::try_from(value & 0x7f).unwrap() | 0x80); + value >>= 7; + } + output.push(u8::try_from(value).unwrap()); + } + 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))); + } }