Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 19 additions & 29 deletions rust/src/browser/detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,36 +206,26 @@ impl BrowserDetector {
}

/// Detect Chromium-based browser profiles
fn detect_chromium_profiles(user_data_dir: &PathBuf) -> Vec<BrowserProfile> {
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<BrowserProfile> {
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
}

Expand Down
15 changes: 14 additions & 1 deletion rust/src/browser/leveldb/local_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,20 @@ pub fn read_local_storage_entries(
dir: &Path,
origin: &str,
) -> Result<Vec<LocalStorageEntry>, 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<Vec<LocalStorageEntry>, 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<LocalStorageEntry> {
Expand Down
1 change: 1 addition & 0 deletions rust/src/browser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
141 changes: 141 additions & 0 deletions rust/src/browser/storage_discovery.rs
Original file line number Diff line number Diff line change
@@ -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 {
/// `<profile>/Local Storage/leveldb`
LocalStorage,
/// `<profile>/Session Storage`
SessionStorage,
/// `<profile>/IndexedDB/<origin>.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<StorageCandidate> {
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<StorageCandidate> {
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<StorageCandidate> {
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<PathBuf> {
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<PathBuf> {
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;
158 changes: 158 additions & 0 deletions rust/src/browser/storage_discovery/tests.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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::<Vec<_>>(),
["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)"))
);
}
}
Loading