support login data

This commit is contained in:
Julian Freeman
2026-04-16 22:43:17 -04:00
parent a976dc3fc5
commit 9fe16cd334
11 changed files with 519 additions and 20 deletions

74
src-tauri/Cargo.lock generated
View File

@@ -8,6 +8,18 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -449,6 +461,7 @@ name = "chrom-tool"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"rusqlite",
"serde",
"serde_json",
"tauri",
@@ -938,6 +951,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1446,6 +1471,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1461,6 +1495,15 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -1990,6 +2033,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -3033,6 +3087,20 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -4421,6 +4489,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"

View File

@@ -24,4 +24,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
tauri-plugin-dialog = "2.7.0"
rusqlite = { version = "0.32", features = ["bundled"] }

View File

@@ -19,6 +19,7 @@ pub struct BrowserView {
pub profiles: Vec<ProfileSummary>,
pub extensions: Vec<ExtensionSummary>,
pub bookmarks: Vec<BookmarkSummary>,
pub password_sites: Vec<PasswordSiteSummary>,
pub stats: BrowserStats,
}
@@ -28,6 +29,7 @@ pub struct BrowserStats {
pub profile_count: usize,
pub extension_count: usize,
pub bookmark_count: usize,
pub password_site_count: usize,
}
#[derive(Serialize)]
@@ -64,6 +66,15 @@ pub struct BookmarkSummary {
pub profiles: Vec<BookmarkAssociatedProfileSummary>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasswordSiteSummary {
pub url: String,
pub domain: String,
pub profile_ids: Vec<String>,
pub profiles: Vec<AssociatedProfileSummary>,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AssociatedProfileSummary {
@@ -165,3 +176,10 @@ pub struct TempBookmark {
pub profile_ids: BTreeSet<String>,
pub profiles: BTreeMap<String, BookmarkAssociatedProfileSummary>,
}
pub struct TempPasswordSite {
pub url: String,
pub domain: String,
pub profile_ids: BTreeSet<String>,
pub profiles: BTreeMap<String, AssociatedProfileSummary>,
}

View File

@@ -3,6 +3,7 @@ use std::{
path::{Path, PathBuf},
};
use rusqlite::{Connection, OpenFlags};
use serde_json::Value;
use tauri::AppHandle;
@@ -10,10 +11,12 @@ use crate::{
config_store,
models::{
AssociatedProfileSummary, BookmarkAssociatedProfileSummary, BookmarkSummary,
BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, ProfileSummary,
ScanResponse, TempBookmark, TempExtension,
BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, PasswordSiteSummary,
ProfileSummary, ScanResponse, TempBookmark, TempExtension, TempPasswordSite,
},
utils::{
copy_sqlite_database_to_temp, first_non_empty, load_image_as_data_url, read_json_file,
},
utils::{first_non_empty, load_image_as_data_url, read_json_file},
};
pub fn scan_browsers(app: &AppHandle) -> Result<ScanResponse, String> {
@@ -43,6 +46,7 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
let mut profiles = Vec::new();
let mut extensions = BTreeMap::<String, TempExtension>::new();
let mut bookmarks = BTreeMap::<String, TempBookmark>::new();
let mut password_sites = BTreeMap::<String, TempPasswordSite>::new();
for profile_id in profile_ids {
let profile_path = root.join(&profile_id);
@@ -51,14 +55,11 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
}
let profile_info = profile_cache.and_then(|cache| cache.get(&profile_id));
let profile_summary = build_profile_summary(
&root,
&profile_path,
&profile_id,
profile_info,
);
let profile_summary =
build_profile_summary(&root, &profile_path, &profile_id, profile_info);
scan_extensions_for_profile(&profile_path, &profile_summary, &mut extensions);
scan_bookmarks_for_profile(&profile_path, &profile_summary, &mut bookmarks);
scan_password_sites_for_profile(&profile_path, &profile_summary, &mut password_sites);
profiles.push(profile_summary);
}
@@ -83,6 +84,15 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
profiles: entry.profiles.into_values().collect(),
})
.collect::<Vec<_>>();
let password_sites = password_sites
.into_values()
.map(|entry| PasswordSiteSummary {
url: entry.url,
domain: entry.domain,
profile_ids: entry.profile_ids.into_iter().collect(),
profiles: entry.profiles.into_values().collect(),
})
.collect::<Vec<_>>();
Some(BrowserView {
browser_id: config.id,
@@ -94,10 +104,12 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
profile_count: profiles.len(),
extension_count: extensions.len(),
bookmark_count: bookmarks.len(),
password_site_count: password_sites.len(),
},
profiles,
extensions: sort_extensions(extensions),
bookmarks: sort_bookmarks(bookmarks),
password_sites: sort_password_sites(password_sites),
})
}
@@ -291,7 +303,10 @@ fn resolve_extension_install_dir(
} else if candidate.is_absolute() {
(candidate, ExtensionInstallSource::ExternalAbsolute)
} else {
(PathBuf::from(raw_path), ExtensionInstallSource::ExternalAbsolute)
(
PathBuf::from(raw_path),
ExtensionInstallSource::ExternalAbsolute,
)
};
resolved.is_dir().then_some((resolved, source))
@@ -451,10 +466,8 @@ fn collect_bookmarks(
} else {
ancestors.join(" > ")
};
entry
.profiles
.entry(profile.id.clone())
.or_insert_with(|| BookmarkAssociatedProfileSummary {
entry.profiles.entry(profile.id.clone()).or_insert_with(|| {
BookmarkAssociatedProfileSummary {
id: profile.id.clone(),
name: profile.name.clone(),
avatar_data_url: profile.avatar_data_url.clone(),
@@ -463,7 +476,8 @@ fn collect_bookmarks(
default_avatar_stroke_color: profile.default_avatar_stroke_color,
avatar_label: profile.avatar_label.clone(),
bookmark_path,
});
}
});
}
Some("folder") => {
if let Some(children) = node.get("children").and_then(Value::as_array) {
@@ -526,3 +540,103 @@ fn sort_bookmarks(mut bookmarks: Vec<BookmarkSummary>) -> Vec<BookmarkSummary> {
});
bookmarks
}
fn scan_password_sites_for_profile(
profile_path: &Path,
profile: &ProfileSummary,
password_sites: &mut BTreeMap<String, TempPasswordSite>,
) {
let login_data_path = profile_path.join("Login Data");
if !login_data_path.is_file() {
return;
}
let Some(temp_copy) = copy_sqlite_database_to_temp(&login_data_path) else {
return;
};
let Ok(connection) = Connection::open_with_flags(
temp_copy.path(),
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) else {
return;
};
let Ok(mut statement) = connection
.prepare("SELECT origin_url, signon_realm FROM logins WHERE blacklisted_by_user = 0")
else {
return;
};
let Ok(rows) = statement.query_map([], |row| {
Ok((
row.get::<_, Option<String>>(0)?,
row.get::<_, Option<String>>(1)?,
))
}) else {
return;
};
for row in rows.flatten() {
let Some(url) = normalize_login_site(row.0.as_deref(), row.1.as_deref()) else {
continue;
};
let domain = domain_from_url(&url).unwrap_or_else(|| url.clone());
let entry = password_sites
.entry(url.clone())
.or_insert_with(|| TempPasswordSite {
url: url.clone(),
domain: domain.clone(),
profile_ids: BTreeSet::new(),
profiles: BTreeMap::new(),
});
if entry.domain == entry.url && domain != entry.url {
entry.domain = domain.clone();
}
entry.profile_ids.insert(profile.id.clone());
entry
.profiles
.entry(profile.id.clone())
.or_insert_with(|| AssociatedProfileSummary {
id: profile.id.clone(),
name: profile.name.clone(),
avatar_data_url: profile.avatar_data_url.clone(),
avatar_icon: profile.avatar_icon.clone(),
default_avatar_fill_color: profile.default_avatar_fill_color,
default_avatar_stroke_color: profile.default_avatar_stroke_color,
avatar_label: profile.avatar_label.clone(),
});
}
}
fn normalize_login_site(origin_url: Option<&str>, signon_realm: Option<&str>) -> Option<String> {
let candidate = [signon_realm, origin_url]
.into_iter()
.flatten()
.map(str::trim)
.find(|value| {
!value.is_empty() && (value.starts_with("http://") || value.starts_with("https://"))
})?;
Some(candidate.to_string())
}
fn domain_from_url(url: &str) -> Option<String> {
let (_, remainder) = url.split_once("://")?;
let host = remainder.split('/').next()?.trim();
if host.is_empty() {
return None;
}
Some(host.to_string())
}
fn sort_password_sites(mut password_sites: Vec<PasswordSiteSummary>) -> Vec<PasswordSiteSummary> {
password_sites.sort_by(|left, right| {
left.domain
.to_lowercase()
.cmp(&right.domain.to_lowercase())
.then_with(|| left.url.cmp(&right.url))
});
password_sites
}

View File

@@ -1,6 +1,8 @@
use std::{
env, fs,
path::{Path, PathBuf},
process,
time::{SystemTime, UNIX_EPOCH},
};
use base64::{engine::general_purpose::STANDARD, Engine as _};
@@ -46,3 +48,48 @@ pub fn first_non_empty<'a>(values: impl IntoIterator<Item = Option<&'a str>>) ->
.flatten()
.find(|value| !value.trim().is_empty())
}
pub struct TempSqliteCopy {
path: PathBuf,
directory: PathBuf,
}
impl TempSqliteCopy {
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempSqliteCopy {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.directory);
}
}
pub fn copy_sqlite_database_to_temp(path: &Path) -> Option<TempSqliteCopy> {
let file_name = path.file_name()?.to_str()?;
let unique_id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()?
.as_nanos();
let directory =
env::temp_dir().join(format!("chrom-tool-scan-{}-{}", process::id(), unique_id));
fs::create_dir_all(&directory).ok()?;
let main_target = directory.join(file_name);
fs::copy(path, &main_target).ok()?;
for suffix in ["-wal", "-shm"] {
let source = PathBuf::from(format!("{}{}", path.display(), suffix));
if source.is_file() {
let target = directory.join(format!("{file_name}{suffix}"));
let _ = fs::copy(source, target);
}
}
Some(TempSqliteCopy {
path: main_target,
directory,
})
}