diff --git a/docs/COOKIES.md b/docs/COOKIES.md index 804562d077..22b6c73dc7 100644 --- a/docs/COOKIES.md +++ b/docs/COOKIES.md @@ -37,6 +37,10 @@ If automatic extraction fails (for example, Chromium App-Bound Encryption is act 4. Copy the `Cookie` header value from **Request Headers** 5. In CodexBar Settings → provider detail → **Browser Cookies**, paste the value +## Kimi local-storage tokens + +With the Kimi cookie source set to automatic, CodexBar also reads `access_token` from Chromium browsers' `Local Storage` for the selected Kimi region (`www.kimi.com` or `www.kimi.ai`), after the Kimi Desktop session and browser cookies. Local storage is not App-Bound encrypted, so this can work when cookie decryption is blocked. Only unexpired three-segment JWTs are used; refresh tokens are never read. A manual Cookie header always wins, and Cookie source Off or Manual skips this step. Open Kimi in the browser to renew an expired session. Firefox and Safari local storage are not read, and only the `Default` and `Profile N` profiles are scanned. + ## Troubleshooting - **"Chromium App-Bound Encryption"**: Modern Chrome, Edge, Brave, and other Chromium-based profiles can protect cookies with ABE. Closing the browser does not remove ABE; use a manual Cookie header or Firefox for the same login 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..0491a4278f 100644 --- a/rust/src/browser/leveldb/local_storage.rs +++ b/rust/src/browser/leveldb/local_storage.rs @@ -6,7 +6,7 @@ //! the same format byte. Other keys in the database (`VERSION`, `META:`, //! `METAACCESS:`) are bookkeeping and are ignored. -use super::{Entry, LevelDbError, read_entries}; +use super::{Entry, LevelDbError, read_entries_with_budget}; use std::path::{Path, PathBuf}; const KEY_PREFIX: u8 = b'_'; @@ -15,12 +15,22 @@ const FORMAT_UTF16LE: u8 = 0; const FORMAT_LATIN1: u8 = 1; /// One decoded `localStorage` item. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct LocalStorageEntry { pub key: String, pub value: String, } +impl std::fmt::Debug for LocalStorageEntry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LocalStorageEntry") + .field("key", &self.key) + .field("value", &"[REDACTED]") + .finish() + } +} + /// Directory holding Local Storage for a Chromium profile directory (`Default`, `Profile 1`, ...). pub fn local_storage_dir(profile_dir: &Path) -> PathBuf { profile_dir.join("Local Storage").join("leveldb") @@ -32,14 +42,35 @@ 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 = super::read_entries(dir)?; + Ok(origins + .iter() + .flat_map(|origin| decode_origin_entries(&entries, origin)) + .collect()) +} + +pub(crate) fn read_local_storage_entries_with_budget( + dir: &Path, + origin: &str, + max_total_bytes: u64, +) -> Result<(Vec, u64), LevelDbError> { + let prefix = origin_key_prefix(origin); + let (entries, scanned_bytes) = + read_entries_with_budget(dir, max_total_bytes, Some(prefix.as_slice()))?; + Ok((decode_origin_entries(&entries, origin), scanned_bytes)) } pub(super) fn decode_origin_entries(entries: &[Entry], origin: &str) -> Vec { - let mut prefix = Vec::with_capacity(origin.len() + 2); - prefix.push(KEY_PREFIX); - prefix.extend_from_slice(origin.trim_end_matches('/').as_bytes()); - prefix.push(ORIGIN_TERMINATOR); + let prefix = origin_key_prefix(origin); entries .iter() @@ -51,6 +82,14 @@ pub(super) fn decode_origin_entries(entries: &[Entry], origin: &str) -> Vec Vec { + let mut prefix = Vec::with_capacity(origin.len() + 2); + prefix.push(KEY_PREFIX); + prefix.extend_from_slice(origin.trim_end_matches('/').as_bytes()); + prefix.push(ORIGIN_TERMINATOR); + prefix +} + /// Decode a format-byte-prefixed Chromium string; `None` for an unknown format or bad UTF-16. fn decode_text(bytes: &[u8]) -> Option { let (&format, data) = bytes.split_first()?; diff --git a/rust/src/browser/leveldb/log.rs b/rust/src/browser/leveldb/log.rs index 1256a818cb..d8e01b4a14 100644 --- a/rust/src/browser/leveldb/log.rs +++ b/rust/src/browser/leveldb/log.rs @@ -9,12 +9,13 @@ //! malformed tail ends the scan quietly and everything before it is kept. Record checksums are //! not verified: this is a best-effort read of another program's cache, not a database recovery. -use super::Record; use super::varint::{read_length_prefixed, read_u32_le, read_u64_le}; +use super::{MAX_RECORDS_PER_DIRECTORY, Record}; const BLOCK_SIZE: usize = 32 * 1024; const HEADER_SIZE: usize = 7; const BATCH_HEADER_SIZE: usize = 12; +const MAX_LOG_RECORD_BYTES: usize = 16 * 1024 * 1024; const TYPE_ZERO: u8 = 0; const TYPE_FULL: u8 = 1; @@ -24,9 +25,19 @@ const TYPE_LAST: u8 = 4; const OP_DELETE: u8 = 0; const OP_PUT: u8 = 1; +const MAX_SEQUENCE: u64 = (1 << 56) - 1; /// Feed every put/delete found in `data` to `emit`. +#[cfg(test)] pub(super) fn read_log(data: &[u8], emit: &mut impl FnMut(Record)) { + let _ = read_log_until(data, &mut |record| { + emit(record); + true + }); +} + +/// Read a log until it is malformed or `emit` asks the scan to stop. +pub(super) fn read_log_until(data: &[u8], emit: &mut impl FnMut(Record) -> bool) -> bool { let mut assembled: Vec = Vec::new(); let mut in_fragmented = false; @@ -38,7 +49,7 @@ pub(super) fn read_log(data: &[u8], emit: &mut impl FnMut(Record)) { continue; } let Some(header) = data.get(offset..offset + HEADER_SIZE) else { - return; + return false; }; let length = usize::from(u16::from_le_bytes([header[4], header[5]])); let kind = header[6]; @@ -49,10 +60,10 @@ pub(super) fn read_log(data: &[u8], emit: &mut impl FnMut(Record)) { } let payload_start = offset + HEADER_SIZE; if HEADER_SIZE + length > block_remaining { - return; + return false; } let Some(payload) = data.get(payload_start..payload_start + length) else { - return; + return false; }; offset = payload_start + length; @@ -60,63 +71,106 @@ pub(super) fn read_log(data: &[u8], emit: &mut impl FnMut(Record)) { TYPE_FULL => { assembled.clear(); in_fragmented = false; - read_batch(payload, emit); + if !read_batch(payload, emit) { + return false; + } } TYPE_FIRST => { assembled.clear(); - assembled.extend_from_slice(payload); + if !append_fragment(&mut assembled, payload) { + return false; + } in_fragmented = true; } - TYPE_MIDDLE if in_fragmented => assembled.extend_from_slice(payload), + TYPE_MIDDLE if in_fragmented => { + if !append_fragment(&mut assembled, payload) { + return false; + } + } TYPE_LAST if in_fragmented => { - assembled.extend_from_slice(payload); + if !append_fragment(&mut assembled, payload) { + return false; + } in_fragmented = false; - read_batch(&assembled, emit); + if !read_batch(&assembled, emit) { + return false; + } assembled.clear(); } - _ => return, + _ => return false, } } + true +} + +fn append_fragment(assembled: &mut Vec, payload: &[u8]) -> bool { + let Some(new_len) = assembled.len().checked_add(payload.len()) else { + return false; + }; + if new_len > MAX_LOG_RECORD_BYTES || assembled.try_reserve(payload.len()).is_err() { + return false; + } + assembled.extend_from_slice(payload); + true +} + +fn read_batch(batch: &[u8], emit: &mut impl FnMut(Record) -> bool) -> bool { + if !visit_batch(batch, &mut |_, _, _| true) { + return true; + } + visit_batch(batch, &mut |sequence, key, value| { + emit(Record { + key: key.to_vec(), + sequence, + value: value.map(|value| value.to_vec()), + }) + }) } -fn read_batch(batch: &[u8], emit: &mut impl FnMut(Record)) { +/// Validate the whole batch before applying any of its operations. A torn or malformed write +/// batch must not leave a valid-looking prefix in the returned database state. +fn visit_batch<'a>( + batch: &'a [u8], + emit: &mut impl FnMut(u64, &'a [u8], Option<&'a [u8]>) -> bool, +) -> bool { let (Some(sequence), Some(count)) = (read_u64_le(batch, 0), read_u32_le(batch, 8)) else { - return; + return false; + }; + if sequence > MAX_SEQUENCE || u64::from(count) > MAX_RECORDS_PER_DIRECTORY as u64 { + return false; + } + let mut rest = match batch.get(BATCH_HEADER_SIZE..) { + Some(rest) => rest, + None => return false, }; - let mut rest = batch.get(BATCH_HEADER_SIZE..).unwrap_or_default(); - let mut records = Vec::new(); for index in 0..u64::from(count) { let Some((&op, after_op)) = rest.split_first() else { - return; + return false; }; let Some((key, after_key)) = read_length_prefixed(after_op) else { - return; + return false; }; let value = match op { OP_PUT => { let Some((value, after_value)) = read_length_prefixed(after_key) else { - return; + return false; }; rest = after_value; - Some(value.to_vec()) + Some(value) } OP_DELETE => { rest = after_key; None } - _ => return, + _ => return false, }; - let Some(sequence) = sequence.checked_add(index) else { - return; + let Some(record_sequence) = sequence.checked_add(index).filter(|s| *s <= MAX_SEQUENCE) + else { + return false; }; - records.push(Record { - key: key.to_vec(), - sequence, - value, - }); - } - if !rest.is_empty() { - return; + if !emit(record_sequence, key, value) { + return false; + } } - records.into_iter().for_each(emit); + rest.is_empty() } diff --git a/rust/src/browser/leveldb/mod.rs b/rust/src/browser/leveldb/mod.rs index aa95a9cb42..ea5bbefd13 100644 --- a/rust/src/browser/leveldb/mod.rs +++ b/rust/src/browser/leveldb/mod.rs @@ -13,9 +13,9 @@ //! - Checksums are not verified and only the bytewise comparator is assumed (which is what //! `Local Storage` uses; a full scan does not depend on ordering anyway). //! - Blocks using a compression type other than none/Snappy are skipped. -//! - Files larger than [`MAX_FILE_BYTES`] and decoded table blocks larger than -//! [`MAX_BLOCK_BYTES`] are skipped to bound each input allocation. The returned snapshot still -//! grows with the database's live data. +//! - Files larger than [`MAX_FILE_BYTES`], scans larger than [`MAX_TOTAL_SCAN_BYTES`], and blocks +//! that decode beyond [`MAX_BLOCK_BYTES`] are skipped so a corrupt profile cannot exhaust +//! memory or monopolize a refresh. pub mod local_storage; mod log; @@ -33,21 +33,39 @@ use std::path::Path; /// Largest single log or table file that will be read into memory. pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; -/// Largest decoded table block accepted. +/// Largest total number of bytes scanned across a LevelDB directory. +pub const MAX_TOTAL_SCAN_BYTES: u64 = 256 * 1024 * 1024; +/// Largest decompressed table block accepted. pub const MAX_BLOCK_BYTES: usize = 16 * 1024 * 1024; +const MAX_DIRECTORY_ENTRIES: usize = 8192; +const MAX_RECORDS_PER_DIRECTORY: usize = 250_000; +const MAX_LIVE_ENTRIES: usize = 100_000; +const MAX_LIVE_ENTRY_BYTES: usize = 64 * 1024 * 1024; /// One live key/value pair of the database. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct Entry { pub key: Vec, pub value: Vec, } +impl std::fmt::Debug for Entry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Entry") + .field("key", &"[REDACTED]") + .field("value", &"[REDACTED]") + .finish() + } +} + /// Failure to read a LevelDB directory at all (per-file problems are skipped, not reported). #[derive(Debug, thiserror::Error)] pub enum LevelDbError { #[error("cannot read LevelDB directory: {0}")] Io(#[from] std::io::Error), + #[error("LevelDB directory exceeds safe scan limits")] + ResourceLimit, } /// A put (`value: Some`) or delete (`value: None`) with the sequence number it was written at. @@ -57,53 +75,154 @@ pub(crate) struct Record { pub(crate) value: Option>, } +#[derive(Default)] +struct EntryAccumulator<'a> { + key_prefix: Option<&'a [u8]>, + newest: BTreeMap, (u64, Option>)>, + record_count: usize, + retained_bytes: usize, + resource_limit_exceeded: bool, +} + +impl<'a> EntryAccumulator<'a> { + fn absorb(&mut self, record: Record) -> bool { + if self.record_count == MAX_RECORDS_PER_DIRECTORY { + self.resource_limit_exceeded = true; + return false; + } + self.record_count += 1; + if self + .key_prefix + .is_some_and(|prefix| !record.key.starts_with(prefix)) + { + return true; + } + + let current_entry_count = self.newest.len(); + match self.newest.entry(record.key) { + std::collections::btree_map::Entry::Occupied(mut entry) => { + let existing = entry.get_mut(); + if existing.0 >= record.sequence { + return true; + } + let old_value_bytes = existing.1.as_ref().map_or(0, Vec::len); + let new_value_bytes = record.value.as_ref().map_or(0, Vec::len); + let next_retained_bytes = self + .retained_bytes + .checked_sub(old_value_bytes) + .and_then(|bytes| bytes.checked_add(new_value_bytes)); + let Some(next_retained_bytes) = next_retained_bytes else { + self.resource_limit_exceeded = true; + return false; + }; + if next_retained_bytes > MAX_LIVE_ENTRY_BYTES { + self.resource_limit_exceeded = true; + return false; + } + self.retained_bytes = next_retained_bytes; + *existing = (record.sequence, record.value); + true + } + std::collections::btree_map::Entry::Vacant(entry) => { + let Some(entry_bytes) = entry + .key() + .len() + .checked_add(record.value.as_ref().map_or(0, Vec::len)) + else { + self.resource_limit_exceeded = true; + return false; + }; + let Some(next_retained_bytes) = self.retained_bytes.checked_add(entry_bytes) else { + self.resource_limit_exceeded = true; + return false; + }; + if current_entry_count == MAX_LIVE_ENTRIES + || next_retained_bytes > MAX_LIVE_ENTRY_BYTES + { + self.resource_limit_exceeded = true; + return false; + } + self.retained_bytes = next_retained_bytes; + entry.insert((record.sequence, record.value)); + true + } + } + } + + fn into_entries(self) -> Vec { + self.newest + .into_iter() + .filter_map(|(key, (_, value))| value.map(|value| Entry { key, value })) + .collect() + } +} + /// Read every live entry of the LevelDB directory `dir`, sorted by key. /// /// Keys that were deleted (or whose newest record is a delete) are omitted. Unreadable or corrupt -/// files are skipped with a debug log so the remaining data is still returned. +/// files are skipped with a debug log; scans over the configured resource limits fail as a whole. pub fn read_entries(dir: &Path) -> Result, LevelDbError> { - let mut newest: BTreeMap, (u64, Option>)> = BTreeMap::new(); - let mut absorb = |record: Record| match newest.get_mut(&record.key) { - Some(existing) if existing.0 >= record.sequence => {} - Some(existing) => *existing = (record.sequence, record.value), - None => { - newest.insert(record.key, (record.sequence, record.value)); - } + read_entries_with_budget(dir, MAX_TOTAL_SCAN_BYTES, None).map(|(entries, _)| entries) +} + +pub(crate) fn read_entries_with_budget( + dir: &Path, + max_total_bytes: u64, + key_prefix: Option<&[u8]>, +) -> Result<(Vec, u64), LevelDbError> { + let mut entries = EntryAccumulator { + key_prefix, + ..EntryAccumulator::default() }; + let mut scanned_bytes = 0u64; - for dir_entry in std::fs::read_dir(dir)? { + for (directory_entry_count, dir_entry) in std::fs::read_dir(dir)?.enumerate() { + if directory_entry_count == MAX_DIRECTORY_ENTRIES { + return Err(LevelDbError::ResourceLimit); + } let Ok(dir_entry) = dir_entry else { continue }; let path = dir_entry.path(); let Some(kind) = FileKind::of(&path) else { continue; }; - let data = match read_bounded_file(&path) { + let remaining_bytes = max_total_bytes.saturating_sub(scanned_bytes); + let data = match read_bounded_file(&path, remaining_bytes) { Ok(data) => data, - Err(error) => { + Err(BoundedReadError::FileTooLarge) => continue, + Err(BoundedReadError::BudgetExceeded) => return Err(LevelDbError::ResourceLimit), + Err(BoundedReadError::Io(error)) => { tracing::debug!(file = ?path.file_name(), %error, "skipping unreadable LevelDB file"); continue; } }; + let file_bytes = u64::try_from(data.len()).map_err(|_| LevelDbError::ResourceLimit)?; + scanned_bytes = scanned_bytes + .checked_add(file_bytes) + .ok_or(LevelDbError::ResourceLimit)?; match kind { - FileKind::Log => log::read_log(&data, &mut absorb), - FileKind::Table => match table::read_table(&data, &mut absorb) { - Ok(0) => {} - Ok(skipped) => tracing::debug!( - file = ?path.file_name(), - skipped, - "skipped undecodable LevelDB table blocks" - ), - Err(error) => { - tracing::debug!(file = ?path.file_name(), %error, "skipping malformed LevelDB table"); + FileKind::Log => { + let _ = log::read_log_until(&data, &mut |record| entries.absorb(record)); + } + FileKind::Table => { + match table::read_table_until(&data, &mut |record| entries.absorb(record)) { + Ok((0, _)) => {} + Ok((skipped, _)) => tracing::debug!( + file = ?path.file_name(), + skipped, + "skipped undecodable LevelDB table blocks" + ), + Err(error) => { + tracing::debug!(file = ?path.file_name(), %error, "skipping malformed LevelDB table"); + } } - }, + } + } + if entries.resource_limit_exceeded { + return Err(LevelDbError::ResourceLimit); } } - Ok(newest - .into_iter() - .filter_map(|(key, (_, value))| value.map(|value| Entry { key, value })) - .collect()) + Ok((entries.into_entries(), scanned_bytes)) } #[derive(Clone, Copy)] @@ -125,20 +244,36 @@ impl FileKind { } } -fn read_bounded_file(path: &Path) -> std::io::Result> { - let file = std::fs::File::open(path)?; - let size = file.metadata()?.len(); +enum BoundedReadError { + Io(std::io::Error), + FileTooLarge, + BudgetExceeded, +} + +fn read_bounded_file(path: &Path, remaining_bytes: u64) -> Result, BoundedReadError> { + let file = std::fs::File::open(path).map_err(BoundedReadError::Io)?; + let size = file.metadata().map_err(BoundedReadError::Io)?.len(); if size > MAX_FILE_BYTES { - return Err(std::io::Error::other(format!( - "file is {size} bytes, over the {MAX_FILE_BYTES} byte limit" - ))); + return Err(BoundedReadError::FileTooLarge); } + if size > remaining_bytes { + return Err(BoundedReadError::BudgetExceeded); + } + + let read_limit = MAX_FILE_BYTES.min(remaining_bytes); + let reserve = usize::try_from(size).map_err(|_| BoundedReadError::FileTooLarge)?; let mut data = Vec::new(); - file.take(MAX_FILE_BYTES + 1).read_to_end(&mut data)?; - if data.len() as u64 > MAX_FILE_BYTES { - return Err(std::io::Error::other(format!( - "file grew beyond the {MAX_FILE_BYTES} byte limit while being read" - ))); + data.try_reserve_exact(reserve) + .map_err(|error| BoundedReadError::Io(std::io::Error::other(error)))?; + file.take(read_limit + 1) + .read_to_end(&mut data) + .map_err(BoundedReadError::Io)?; + let data_len = u64::try_from(data.len()).map_err(|_| BoundedReadError::FileTooLarge)?; + if data_len > MAX_FILE_BYTES { + return Err(BoundedReadError::FileTooLarge); + } + if data_len > remaining_bytes { + return Err(BoundedReadError::BudgetExceeded); } Ok(data) } diff --git a/rust/src/browser/leveldb/snappy.rs b/rust/src/browser/leveldb/snappy.rs index 3436a621df..d61542e015 100644 --- a/rust/src/browser/leveldb/snappy.rs +++ b/rust/src/browser/leveldb/snappy.rs @@ -17,6 +17,8 @@ pub enum SnappyError { Truncated, #[error("snappy copy references data before the start of the output")] BadOffset, + #[error("snappy output buffer could not be allocated")] + AllocationFailed, #[error("snappy stream length does not match its preamble")] LengthMismatch, } @@ -29,7 +31,9 @@ pub fn decompress(input: &[u8], max_len: usize) -> Result, SnappyError> return Err(SnappyError::TooLarge(expected, max_len)); } - let mut out = Vec::with_capacity(expected); + let mut out = Vec::new(); + out.try_reserve_exact(expected) + .map_err(|_| SnappyError::AllocationFailed)?; while pos < input.len() { let tag = input[pos]; pos += 1; @@ -50,7 +54,10 @@ pub fn decompress(input: &[u8], max_len: usize) -> Result, SnappyError> .checked_add(len) .and_then(|end| input.get(pos..end)) .ok_or(SnappyError::Truncated)?; - if out.len() + literal.len() > expected { + let Some(new_len) = out.len().checked_add(literal.len()) else { + return Err(SnappyError::LengthMismatch); + }; + if new_len > expected { return Err(SnappyError::LengthMismatch); } out.extend_from_slice(literal); @@ -105,7 +112,10 @@ fn copy_within_output( if offset == 0 || offset > out.len() { return Err(SnappyError::BadOffset); } - if out.len() + len > expected { + let Some(new_len) = out.len().checked_add(len) else { + return Err(SnappyError::LengthMismatch); + }; + if new_len > expected { return Err(SnappyError::LengthMismatch); } let start = out.len() - offset; diff --git a/rust/src/browser/leveldb/table.rs b/rust/src/browser/leveldb/table.rs index 30e8bacfe4..314cdee15c 100644 --- a/rust/src/browser/leveldb/table.rs +++ b/rust/src/browser/leveldb/table.rs @@ -13,7 +13,7 @@ use super::snappy; use super::varint::{read_u32_le, read_u64_le, read_varint32, read_varint64}; -use super::{MAX_BLOCK_BYTES, Record}; +use super::{MAX_BLOCK_BYTES, MAX_RECORDS_PER_DIRECTORY, Record}; const FOOTER_SIZE: usize = 48; const TABLE_MAGIC: u64 = 0xdb47_7524_8b80_fb57; @@ -31,12 +31,14 @@ pub(super) enum TableError { BadFooter, #[error("block handle points outside the file")] BadHandle, - #[error("table block is {0} bytes, over the {1} byte limit")] - BlockTooLarge(usize, usize), #[error("unsupported block compression type {0}")] UnsupportedCompression(u8), #[error("block is malformed")] BadBlock, + #[error("block exceeds the configured size limit")] + BlockTooLarge, + #[error("block output buffer could not be allocated")] + AllocationFailed, #[error("snappy block failed to decode: {0}")] Snappy(#[from] snappy::SnappyError), } @@ -66,7 +68,19 @@ impl BlockHandle { /// /// A data block that cannot be decoded is skipped (and counted in the returned failure count) so /// one bad block does not hide the rest of the table; a bad footer or index block is an error. +#[cfg(test)] pub(super) fn read_table(data: &[u8], emit: &mut impl FnMut(Record)) -> Result { + read_table_until(data, &mut |record| { + emit(record); + true + }) + .map(|(skipped, _)| skipped) +} + +pub(super) fn read_table_until( + data: &[u8], + emit: &mut impl FnMut(Record) -> bool, +) -> Result<(usize, bool), TableError> { let footer_start = data .len() .checked_sub(FOOTER_SIZE) @@ -75,64 +89,107 @@ pub(super) fn read_table(data: &[u8], emit: &mut impl FnMut(Record)) -> Result = BlockEntries::new(&index_block)?.collect::>()?; + let index_block = read_block(data, index)?; + validate_index_block(&index_block)?; let mut skipped_blocks = 0usize; - for (_separator, handle_bytes) in index_entries { - let entry = BlockHandle::parse(handle_bytes) - .and_then(|(handle, used)| (used == handle_bytes.len()).then_some(handle)) - .ok_or(TableError::BadBlock); - let block = entry.and_then(|handle| read_block(data, handle, index.offset)); + for (_separator, handle_bytes) in BlockEntries::new(&index_block)? { + let (handle, _) = BlockHandle::parse(handle_bytes).ok_or(TableError::BadBlock)?; + let block = read_block(data, handle); let Ok(block) = block else { skipped_blocks += 1; continue; }; - match decode_block_records(&block) { - Ok(records) => records.into_iter().for_each(&mut *emit), + match validate_data_block(&block).and_then(|_| emit_block_records(&block, emit)) { + Ok(true) => {} + Ok(false) => return Ok((skipped_blocks, false)), Err(_) => skipped_blocks += 1, } } - Ok(skipped_blocks) + Ok((skipped_blocks, true)) +} + +fn validate_index_block(block: &[u8]) -> Result<(), TableError> { + let mut entries = BlockEntries::new(block)?; + let mut entry_count = 0usize; + for (_separator, handle_bytes) in entries.by_ref() { + entry_count += 1; + if entry_count > MAX_RECORDS_PER_DIRECTORY { + return Err(TableError::BadBlock); + } + let (_, used) = BlockHandle::parse(handle_bytes).ok_or(TableError::BadBlock)?; + if used != handle_bytes.len() { + return Err(TableError::BadBlock); + } + } + if entries.failed { + return Err(TableError::BadBlock); + } + Ok(()) } -fn decode_block_records(block: &[u8]) -> Result, TableError> { - let mut records = Vec::new(); - for entry in BlockEntries::new(block)? { - let (internal_key, value) = entry?; +fn validate_data_block(block: &[u8]) -> Result<(), TableError> { + let mut entries = BlockEntries::new(block)?; + let mut entry_count = 0usize; + for (internal_key, _value) in entries.by_ref() { + entry_count += 1; + if entry_count > MAX_RECORDS_PER_DIRECTORY { + return Err(TableError::BadBlock); + } + if internal_key.len() < 8 { + return Err(TableError::BadBlock); + } + } + if entries.failed { + return Err(TableError::BadBlock); + } + Ok(()) +} + +fn emit_block_records( + block: &[u8], + emit: &mut impl FnMut(Record) -> bool, +) -> Result { + let mut entries = BlockEntries::new(block)?; + for (internal_key, value) in entries.by_ref() { let Some(split) = internal_key.len().checked_sub(8) else { return Err(TableError::BadBlock); }; let (user_key, trailer) = internal_key.split_at(split); let packed = read_u64_le(trailer, 0).ok_or(TableError::BadBlock)?; let value = match packed & 0xff { - KIND_VALUE => Some(value.to_vec()), + KIND_VALUE => Some(copy_bytes(value)?), KIND_DELETE => None, - _ => return Err(TableError::BadBlock), + _ => continue, }; - records.push(Record { - key: user_key.to_vec(), + if !emit(Record { + key: copy_bytes(user_key)?, sequence: packed >> 8, value, - }); + }) { + return Ok(false); + } + } + if entries.failed { + return Err(TableError::BadBlock); } - Ok(records) + Ok(true) } /// Return the decompressed contents of the block at `handle` (without its trailer). -fn read_block(data: &[u8], handle: BlockHandle, upper_bound: usize) -> Result, TableError> { - if handle.offset >= upper_bound { - return Err(TableError::BadHandle); - } +fn read_block(data: &[u8], handle: BlockHandle) -> Result, TableError> { + let table_data_end = data + .len() + .checked_sub(FOOTER_SIZE) + .ok_or(TableError::BadHandle)?; let end = handle .offset .checked_add(handle.size) .and_then(|end| end.checked_add(BLOCK_TRAILER_SIZE)) .ok_or(TableError::BadHandle)?; - if end > upper_bound || data.len() < end { + if end > table_data_end { return Err(TableError::BadHandle); } let contents_end = end - BLOCK_TRAILER_SIZE; @@ -141,13 +198,22 @@ fn read_block(data: &[u8], handle: BlockHandle, upper_bound: usize) -> Result Ok(raw.to_vec()), - COMPRESSION_NONE => Err(TableError::BlockTooLarge(raw.len(), MAX_BLOCK_BYTES)), + COMPRESSION_NONE if raw.len() <= MAX_BLOCK_BYTES => copy_bytes(raw), + COMPRESSION_NONE => Err(TableError::BlockTooLarge), COMPRESSION_SNAPPY => Ok(snappy::decompress(raw, MAX_BLOCK_BYTES)?), other => Err(TableError::UnsupportedCompression(other)), } } +fn copy_bytes(bytes: &[u8]) -> Result, TableError> { + let mut output = Vec::new(); + output + .try_reserve_exact(bytes.len()) + .map_err(|_| TableError::AllocationFailed)?; + output.extend_from_slice(bytes); + Ok(output) +} + /// Iterator over the `(key, value)` entries of a decoded block, undoing prefix compression. struct BlockEntries<'a> { entries: &'a [u8], @@ -159,7 +225,7 @@ impl<'a> BlockEntries<'a> { fn new(block: &'a [u8]) -> Result { let restart_count = read_u32_le(block, block.len().saturating_sub(4)).ok_or(TableError::BadBlock)? as usize; - if restart_count == 0 { + if restart_count == 0 || restart_count > MAX_RECORDS_PER_DIRECTORY { return Err(TableError::BadBlock); } let restarts_len = restart_count @@ -170,18 +236,21 @@ impl<'a> BlockEntries<'a> { .len() .checked_sub(restarts_len) .ok_or(TableError::BadBlock)?; - let mut previous_restart = None; + let mut restart_position = entries_end; + let mut previous_restart = 0usize; for index in 0..restart_count { let restart = - read_u32_le(block, entries_end + index * 4).ok_or(TableError::BadBlock)? as usize; + usize::try_from(read_u32_le(block, restart_position).ok_or(TableError::BadBlock)?) + .map_err(|_| TableError::BadBlock)?; if (index == 0 && restart != 0) + || restart < previous_restart || restart > entries_end || (entries_end > 0 && restart == entries_end) - || previous_restart.is_some_and(|previous| restart <= previous) { return Err(TableError::BadBlock); } - previous_restart = Some(restart); + previous_restart = restart; + restart_position += 4; } Ok(Self { entries: &block[..entries_end], @@ -192,7 +261,7 @@ impl<'a> BlockEntries<'a> { } impl<'a> Iterator for BlockEntries<'a> { - type Item = Result<(Vec, &'a [u8]), TableError>; + type Item = (Vec, &'a [u8]); fn next(&mut self) -> Option { if self.failed || self.entries.is_empty() { @@ -201,13 +270,18 @@ impl<'a> Iterator for BlockEntries<'a> { let parsed = parse_entry(self.entries, &self.key); match parsed { Some((key, value, rest)) => { - self.key.clone_from(&key); + self.key.clear(); + if self.key.try_reserve(key.len()).is_err() { + self.failed = true; + return None; + } + self.key.extend_from_slice(&key); self.entries = rest; - Some(Ok((key, value))) + Some((key, value)) } None => { self.failed = true; - Some(Err(TableError::BadBlock)) + None } } } @@ -228,7 +302,9 @@ fn parse_entry<'a>( if shared > previous_key.len() || body.len() < payload_len { return None; } - let mut key = Vec::with_capacity(shared + non_shared); + let key_len = shared.checked_add(non_shared)?; + let mut key = Vec::new(); + key.try_reserve_exact(key_len).ok()?; key.extend_from_slice(&previous_key[..shared]); key.extend_from_slice(&body[..non_shared]); Some((key, &body[non_shared..payload_len], &body[payload_len..])) 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/kimi/local_storage.rs b/rust/src/providers/kimi/local_storage.rs new file mode 100644 index 0000000000..802f63aff7 --- /dev/null +++ b/rust/src/providers/kimi/local_storage.rs @@ -0,0 +1,200 @@ +//! Kimi web access tokens from Chromium local storage (upstream #3923). +//! +//! Upstream `KimiCookieImporter.localStorageTokens(region:)` reads `access_token` from each +//! Chromium profile's `Local Storage/leveldb` for the selected region's web origin and keeps only +//! current three-segment ASCII JWTs. It never reads or refreshes refresh tokens, and it runs after +//! cookie discovery, so a manual credential or a cookie session always takes precedence. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::KimiRegion; +use crate::browser::detection::BrowserDetector; +use crate::browser::leveldb::local_storage::{ + LocalStorageEntry, local_storage_dir, read_local_storage_entries_with_budget, +}; +use crate::browser::leveldb::{LevelDbError, MAX_TOTAL_SCAN_BYTES}; +use crate::codex_accounts::api::jwt_payload; + +const ACCESS_TOKEN_KEY: &str = "access_token"; +const MAX_PROFILE_DIRECTORIES: usize = 128; +const MAX_STORED_TOKEN_BYTES: usize = 32 * 1024; +const MAX_JWT_BYTES: usize = 16 * 1024; + +/// Current Kimi web access tokens stored by Chromium browsers for `region`, in browser and +/// profile detection order, without duplicates. +pub(super) fn local_storage_tokens(region: KimiRegion) -> Vec { + let Ok(elapsed) = SystemTime::now().duration_since(UNIX_EPOCH) else { + return Vec::new(); + }; + let now = elapsed.as_secs_f64(); + tokens_from_profiles(&chromium_profile_dirs(), region.web_base_url(), now) +} + +fn chromium_profile_dirs() -> Vec { + BrowserDetector::detect_all() + .into_iter() + .filter(|browser| browser.browser_type.is_chromium_based()) + .flat_map(|browser| browser.profiles) + .take(MAX_PROFILE_DIRECTORIES) + .map(|profile| profile.path) + .collect() +} + +fn tokens_from_profiles(profiles: &[PathBuf], origin: &str, now_unix: f64) -> Vec { + let mut seen = HashSet::new(); + let mut tokens = Vec::new(); + let mut remaining_bytes = MAX_TOTAL_SCAN_BYTES; + for profile in profiles.iter().take(MAX_PROFILE_DIRECTORIES) { + let dir = local_storage_dir(profile); + if !dir.is_dir() { + continue; + } + match read_local_storage_entries_with_budget(&dir, origin, remaining_bytes) { + Ok((entries, scanned_bytes)) => { + remaining_bytes = remaining_bytes.saturating_sub(scanned_bytes); + for token in access_tokens(&entries, now_unix) { + if seen.insert(token.clone()) { + tokens.push(token); + } + } + } + Err(LevelDbError::ResourceLimit) => { + tracing::debug!("Kimi local storage scan limit reached"); + break; + } + Err(error) => tracing::debug!(%error, "Kimi local storage is not readable"), + } + } + tokens +} + +fn access_tokens(entries: &[LocalStorageEntry], now_unix: f64) -> impl Iterator { + entries + .iter() + .filter(|entry| entry.key == ACCESS_TOKEN_KEY) + .filter(|entry| entry.value.len() <= MAX_STORED_TOKEN_BYTES) + .map(|entry| normalized_value(&entry.value)) + .filter(move |token| is_current_jwt(token, now_unix)) +} + +/// Web storage may hold the token as a JSON string (`"eyJ..."`) or as bare text. +fn normalized_value(value: &str) -> String { + serde_json::from_str::(value).unwrap_or_else(|_| value.trim().to_string()) +} + +fn is_current_jwt(token: &str, now_unix: f64) -> bool { + token.len() <= MAX_JWT_BYTES + && token.split('.').count() == 3 + && token + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + && jwt_payload(token) + .and_then(|payload| payload.get("exp").and_then(serde_json::Value::as_f64)) + .is_some_and(|exp| exp.is_finite() && exp > now_unix) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: f64 = 1_800_000_000.0; + // Fixture from upstream `KimiLocalStorageTests`: payload {"exp":1800003600}. + const TOKEN: &str = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE4MDAwMDM2MDB9.signature"; + // Payload {"exp":1}. + const EXPIRED: &str = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjF9.signature"; + + fn entry(key: &str, value: &str) -> LocalStorageEntry { + LocalStorageEntry { + key: key.into(), + value: value.into(), + } + } + + fn tokens(entries: &[LocalStorageEntry], now: f64) -> Vec { + access_tokens(entries, now).collect() + } + + #[test] + fn only_current_access_tokens_are_kept() { + let entries = [ + entry("refresh_token", TOKEN), + entry("access_token", EXPIRED), + entry("access_token", "not-a-token"), + entry("access_token", "a.b.c"), + entry("access_token", &format!("{TOKEN}; kimi-auth=x")), + entry("access_token", &format!(" {TOKEN}\n")), + entry("access_token", &format!("\"{TOKEN}\"")), + ]; + + assert_eq!(tokens(&entries, NOW), vec![TOKEN, TOKEN]); + assert!(tokens(&entries, NOW + 3600.0).is_empty()); + } + + #[test] + fn non_finite_or_missing_expiry_is_rejected() { + // Payloads {"exp":"soon"} and {"sub":"x"}. + for payload in ["eyJleHAiOiJzb29uIn0", "eyJzdWIiOiJ4In0"] { + let token = format!("eyJhbGciOiJIUzI1NiJ9.{payload}.signature"); + assert!(tokens(&[entry("access_token", &token)], NOW).is_empty()); + } + } + + /// Build a one-record LevelDB write-ahead log holding one Local Storage `access_token`. + fn write_log(origin: &str, value: &str) -> Vec { + let mut key = format!("_{origin}\0").into_bytes(); + key.push(1); + key.extend_from_slice(ACCESS_TOKEN_KEY.as_bytes()); + let mut value_bytes = vec![1]; + value_bytes.extend_from_slice(value.as_bytes()); + + let mut batch = 1u64.to_le_bytes().to_vec(); + batch.extend_from_slice(&1u32.to_le_bytes()); + batch.push(1); + batch.push(u8::try_from(key.len()).unwrap()); + batch.extend_from_slice(&key); + batch.push(u8::try_from(value_bytes.len()).unwrap()); + batch.extend_from_slice(&value_bytes); + + let mut record = vec![0, 0, 0, 0]; + record.extend_from_slice(&u16::try_from(batch.len()).unwrap().to_le_bytes()); + record.push(1); + record.extend_from_slice(&batch); + record + } + + fn profile_with(origin: &str, value: &str) -> tempfile::TempDir { + let profile = tempfile::tempdir().unwrap(); + let dir = local_storage_dir(profile.path()); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("000003.log"), write_log(origin, value)).unwrap(); + profile + } + + #[test] + fn profiles_are_read_for_the_selected_region_only_and_deduplicated() { + let first = profile_with(KimiRegion::International.web_base_url(), TOKEN); + let second = profile_with(KimiRegion::International.web_base_url(), TOKEN); + let china = profile_with(KimiRegion::China.web_base_url(), TOKEN); + let empty = tempfile::tempdir().unwrap(); + let profiles = [ + empty.path().to_path_buf(), + first.path().to_path_buf(), + second.path().to_path_buf(), + china.path().to_path_buf(), + ]; + + assert_eq!( + tokens_from_profiles(&profiles, KimiRegion::International.web_base_url(), NOW), + vec![TOKEN] + ); + assert_eq!( + tokens_from_profiles(&profiles, KimiRegion::China.web_base_url(), NOW), + vec![TOKEN] + ); + assert!( + tokens_from_profiles(&profiles[..3], KimiRegion::China.web_base_url(), NOW).is_empty() + ); + } +} diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index 5905d9f77f..f7cfee258d 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -5,10 +5,13 @@ //! Provider policies are centralized in the submodules: //! - [`web`]: `kimi.com` cookie auth, browser-import gate (Cookie Source Off, //! upstream #2623), and the shared web-token resolution chain -//! (manual cookie → Kimi Desktop session → browser import). +//! (manual cookie → Kimi Desktop session → browser import → Chromium +//! local-storage `access_token`, upstream #3923). //! - [`code_api`]: Kimi Code API auth/endpoint/CLI-credential policy, plus the //! upstream 0.48.0 monthly-membership enrichment of Code API + CLI usage //! from a signed-in Kimi Desktop session (#2622). +//! - [`local_storage`]: current `access_token` JWTs from Chromium local storage +//! for the selected region, read through the shared LevelDB reader. //! - [`desktop_token`]: read-only, WAL-safe reader for the Kimi Desktop //! (Electron) Chromium cookie store. //! - [`ratio_pool`]: zero-ratio placeholder reconciliation against matching @@ -16,6 +19,7 @@ mod code_api; pub mod desktop_token; +mod local_storage; mod ratio_pool; mod region; mod web; diff --git a/rust/src/providers/kimi/web.rs b/rust/src/providers/kimi/web.rs index b18644640a..300b4067d4 100644 --- a/rust/src/providers/kimi/web.rs +++ b/rust/src/providers/kimi/web.rs @@ -3,13 +3,15 @@ //! Upstream 0.48.0 policies ported here (#2623 / `KimiBrowserImportPolicy`): //! browser cookie import — and reading the Kimi Desktop session store — are //! disabled when the Kimi cookie source is `off`. The shared token chain is -//! manual cookie header → Kimi Desktop session → browser import (upstream -//! `KimiWebEnrichmentTokenResolver`), used both by the web fetch itself and +//! manual cookie header → Kimi Desktop session → browser cookie import → +//! Chromium local-storage `access_token` (upstream `KimiWebEnrichmentTokenResolver` +//! and #3923), used both by the web fetch itself and //! by the Code-API/CLI monthly enrichment. use reqwest::Client; use super::desktop_token::KimiDesktopAuthToken; +use super::local_storage::local_storage_tokens; use super::{ KIMI_SUBSCRIPTION_SERVICE, KIMI_SUBSCRIPTION_STATS_SERVICE, KIMI_WEB_USAGE_SERVICE, KimiProvider, KimiRegion, KimiSubscriptionResponse, KimiSubscriptionStatsResponse, @@ -46,6 +48,7 @@ fn browser_import_error(cookie_source: &str) -> ProviderError { /// 1. Manual cookie header (its `kimi-auth`/auth cookie), source-independent. /// 2. Kimi Desktop session token (automatic source only). /// 3. Browser cookie import (automatic source only). +/// 4. Chromium local-storage `access_token` for the region (automatic source only). pub(crate) fn web_auth_tokens(manual_header: Option<&str>, region: KimiRegion) -> Vec { resolve_web_tokens(WebTokenInput { manual_header, @@ -53,6 +56,7 @@ pub(crate) fn web_auth_tokens(manual_header: Option<&str>, region: KimiRegion) - region, desktop_token: KimiDesktopAuthToken::load_for_region, browser_token: browser_auth_token, + local_storage_tokens, }) .into_iter() .map(|candidate| candidate.token) @@ -65,6 +69,7 @@ struct WebTokenInput<'a> { region: KimiRegion, desktop_token: fn(KimiRegion) -> Option, browser_token: fn(KimiRegion) -> Option, + local_storage_tokens: fn(KimiRegion) -> Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -72,14 +77,25 @@ enum WebTokenSource { Manual, Desktop, Browser, + LocalStorage, } -#[derive(Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] struct WebTokenCandidate { token: String, source: WebTokenSource, } +impl std::fmt::Debug for WebTokenCandidate { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WebTokenCandidate") + .field("token", &"[REDACTED]") + .field("source", &self.source) + .finish() + } +} + fn resolve_web_tokens(input: WebTokenInput) -> Vec { if let Some(header) = input.manual_header && let Ok(token) = KimiProvider::auth_token_from_cookie_header(header) @@ -111,6 +127,14 @@ fn resolve_web_tokens(input: WebTokenInput) -> Vec { source: WebTokenSource::Browser, }); } + for token in (input.local_storage_tokens)(input.region) { + if seen.insert(token.clone()) { + candidates.push(WebTokenCandidate { + token, + source: WebTokenSource::LocalStorage, + }); + } + } candidates } @@ -178,6 +202,18 @@ pub(crate) async fn fetch_via_web( } } + // Local-storage tokens are read last, only after every cookie source was rejected. + for token in local_storage_tokens(region) { + if !seen.insert(token.clone()) { + continue; + } + match fetch_via_web_token(&client, &token, region).await { + Ok(usage) => return Ok(usage), + Err(ProviderError::AuthRequired) => {} + Err(error) => return Err(error), + } + } + Err(ProviderError::AuthRequired) } @@ -366,6 +402,14 @@ mod tests { None } + fn no_local_storage(_: KimiRegion) -> Vec { + Vec::new() + } + + fn static_local_storage(_: KimiRegion) -> Vec { + vec!["browser-token".to_string(), "storage-token".to_string()] + } + fn input<'a>( manual_header: Option<&'a str>, cookie_source: &'a str, @@ -378,6 +422,7 @@ mod tests { region: KimiRegion::China, desktop_token, browser_token, + local_storage_tokens: no_local_storage, } } @@ -467,6 +512,46 @@ mod tests { ); } + #[test] + fn local_storage_tokens_follow_cookie_sources_without_duplicates() { + let candidates = resolve_web_tokens(WebTokenInput { + local_storage_tokens: static_local_storage, + ..input(None, "auto", static_desktop, static_browser) + }); + let ordered: Vec<_> = candidates + .iter() + .map(|candidate| (candidate.source, candidate.token.as_str())) + .collect(); + assert_eq!( + ordered, + vec![ + (WebTokenSource::Desktop, "desktop-token"), + (WebTokenSource::Browser, "browser-token"), + (WebTokenSource::LocalStorage, "storage-token"), + ] + ); + } + + #[test] + fn local_storage_is_not_read_for_manual_credentials_or_blocked_sources() { + let manual = resolve_web_tokens(WebTokenInput { + local_storage_tokens: static_local_storage, + ..input(Some("kimi-auth=manual-token"), "auto", no_token, no_token) + }); + assert_eq!(manual.len(), 1); + assert_eq!(manual[0].source, WebTokenSource::Manual); + + for source in ["off", "manual"] { + assert!( + resolve_web_tokens(WebTokenInput { + local_storage_tokens: static_local_storage, + ..input(None, source, no_token, no_token) + }) + .is_empty() + ); + } + } + #[test] fn browser_import_gate_is_case_insensitive() { assert!(!browser_import_allowed("OFF")); 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))); + } }