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
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
139 changes: 139 additions & 0 deletions rust/src/browser/storage_discovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! Locates raw Chromium profile stores (Local Storage, Session Storage, IndexedDB) across the
//! installed Chromium-family browsers.
//!
//! Each consumer supplies its own decoder; this module only answers "which directories could hold
//! the data". It never opens, locks, or decrypts anything, so it is safe to run while the browser
//! is open and needs no credential-store access.
//!
//! The browser catalog is [`super::detection::BrowserType`] (via [`BrowserDetector`]); a
//! Chromium-family browser that is not in that catalog is not visited.

use std::path::{Path, PathBuf};

use super::detection::BrowserDetector;
use super::leveldb::local_storage::local_storage_dir;

const INDEXED_DB_DIR: &str = "IndexedDB";
const SESSION_STORAGE_DIR: &str = "Session Storage";
const INDEXED_DB_SUFFIX: &str = ".indexeddb.leveldb";

/// Which per-profile store to locate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageKind {
/// `<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_user_data_dir(
&browser.user_data_dir,
browser.browser_type.display_name(),
kind,
)
})
.collect()
}

/// Candidates for `kind` in one browser `User Data` directory, profiles sorted by name.
///
/// Only `Default`, `Profile *`, and `user-*` directories count as profiles; guest and system
/// profiles are ignored.
pub fn candidates_in_user_data_dir(
user_data_dir: &Path,
label_prefix: &str,
kind: StorageKind,
) -> Vec<StorageCandidate> {
sorted_child_dirs(user_data_dir, is_profile_dir_name)
.into_iter()
.flat_map(|(name, profile_dir)| {
let label = format!("{label_prefix} {name}{}", kind.label_suffix());
profile_store_paths(&profile_dir, kind)
.into_iter()
.map(move |path| StorageCandidate {
label: label.clone(),
path,
})
})
.collect()
}

fn profile_store_paths(profile_dir: &Path, kind: StorageKind) -> Vec<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.exists() {
vec![path]
} else {
Vec::new()
}
}

fn is_profile_dir_name(name: &str) -> bool {
name == "Default" || name.starts_with("Profile ") || name.starts_with("user-")
}

/// Child directories of `dir` whose (non-hidden, valid UTF-8) name passes `keep`, sorted by name.
/// A missing or unreadable `dir` yields nothing.
fn sorted_child_dirs(dir: &Path, keep: impl Fn(&str) -> bool) -> Vec<(String, PathBuf)> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut children: Vec<(String, PathBuf)> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
let is_dir = entry.file_type().is_ok_and(|kind| kind.is_dir());
(is_dir && !name.starts_with('.') && keep(&name)).then(|| (name, entry.path()))
})
.collect();
children.sort_by(|left, right| left.0.cmp(&right.0));
children
}

#[cfg(test)]
mod tests;
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