diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 92e5d9a52..0c6046a4c 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "base64 0.22.1", "cef", "chrono", + "directories", "env_logger", "flate2", "futures-util", @@ -36,8 +37,10 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-updater", "tauri-runtime-cef", + "tempfile", "tokio", "tokio-tungstenite", + "toml 0.8.2", "zip 2.4.2", ] @@ -967,13 +970,34 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ea539174bb236e0e7dc9c12b19b88eae3cb574dedbd0252a2d43ea7e6de13e2" +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -984,7 +1008,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -3687,6 +3711,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -6134,6 +6169,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6185,6 +6229,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6242,6 +6301,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6260,6 +6325,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6278,6 +6349,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6308,6 +6385,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6326,6 +6409,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6344,6 +6433,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6362,6 +6457,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index a00cbd005..e63d6ed3f 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -53,6 +53,8 @@ tauri-plugin-opener = "2" tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" +directories = "5" # Used by gmail/cdp_fetch for decoding binary IO.read chunks. Base64 is # only emitted by CDP IO.read when the stream contains non-UTF-8 bytes, # but we opt into the feature to stay robust against unexpected responses. @@ -157,6 +159,7 @@ tauri-plugin-notification = { path = "vendor/tauri-plugin-notification" } [dev-dependencies] tokio = { version = "1", features = ["macros", "rt"] } +tempfile = "3" # Fast CI builds: trade runtime perf for compile speed [profile.ci] diff --git a/app/src-tauri/src/cef_preflight.rs b/app/src-tauri/src/cef_preflight.rs index b474b31b7..b6e71d2d5 100644 --- a/app/src-tauri/src/cef_preflight.rs +++ b/app/src-tauri/src/cef_preflight.rs @@ -74,6 +74,15 @@ impl std::error::Error for CefLockError {} /// Resolves the macOS default CEF cache directory and runs the preflight. pub fn check_default_cache() -> Result<(), CefLockError> { + if let Some(configured) = std::env::var_os("OPENHUMAN_CEF_CACHE_PATH") { + let configured = PathBuf::from(configured); + log::debug!( + "[cef-preflight] using configured cache_path={}", + configured.display() + ); + return check_cef_cache_lock(&configured); + } + let home = std::env::var_os("HOME").ok_or(CefLockError::NoHomeDir)?; let cache_path = PathBuf::from(home) .join("Library/Caches") diff --git a/app/src-tauri/src/cef_profile.rs b/app/src-tauri/src/cef_profile.rs new file mode 100644 index 000000000..37eba115c --- /dev/null +++ b/app/src-tauri/src/cef_profile.rs @@ -0,0 +1,490 @@ +use std::collections::BTreeSet; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const CEF_CACHE_PATH_ENV: &str = "OPENHUMAN_CEF_CACHE_PATH"; +const ACTIVE_USER_STATE_FILE: &str = "active_user.toml"; +/// Sibling of the OpenHuman data dir (not under it) so the marker survives +/// `reset_local_data` removing the whole `default_openhuman_dir` tree. +const PENDING_PURGE_STATE_FILE: &str = "openhuman_pending_cef_purge.toml"; +/// Pre–sibling-layout marker (lived under the data root; `reset_local_data` removed it). +const LEGACY_PENDING_PURGE_IN_TREE: &str = "pending_cef_purge.toml"; +const PRE_LOGIN_USER_ID: &str = "local"; + +#[derive(Debug, Deserialize)] +struct ActiveUserState { + user_id: String, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct PendingCefPurgeState { + #[serde(default)] + paths: Vec, +} + +fn default_root_dir_name() -> &'static str { + let app_env = std::env::var("OPENHUMAN_APP_ENV") + .or_else(|_| std::env::var("VITE_OPENHUMAN_APP_ENV")) + .ok() + .map(|value| value.trim().to_ascii_lowercase()); + if matches!(app_env.as_deref(), Some("staging")) { + ".openhuman-staging" + } else { + ".openhuman" + } +} + +fn default_root_openhuman_dir() -> Result { + if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") { + let trimmed = workspace.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + + let home = directories::UserDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .ok_or_else(|| "Could not find home directory".to_string())?; + Ok(home.join(default_root_dir_name())) +} + +fn read_active_user_id(default_openhuman_dir: &Path) -> Option { + let path = default_openhuman_dir.join(ACTIVE_USER_STATE_FILE); + let contents = std::fs::read_to_string(path).ok()?; + let state: ActiveUserState = toml::from_str(&contents).ok()?; + let trimmed = state.user_id.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Returns a single safe path segment for `users//…`. Rejects traversal, separators, +/// and other inputs that would escape the intended profile root. +fn validate_user_id_for_path(user_id: &str) -> Result { + let trimmed = user_id.trim(); + if trimmed.is_empty() { + return Err("user_id is empty after trim".to_string()); + } + if matches!(trimmed, "." | "..") { + return Err("user_id must not be '.' or '..'".to_string()); + } + if trimmed.contains("..") + || trimmed + .chars() + .any(|c| matches!(c, '/' | '\\' | '\0' | char::REPLACEMENT_CHARACTER) || c.is_control()) + { + return Err("user_id must not contain path components or control characters".to_string()); + } + #[cfg(windows)] + if trimmed.contains(':') { + return Err("user_id must not contain ':' (Windows path roots)".to_string()); + } + if !trimmed + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '@' || c == '.') + { + return Err("user_id must only use [A-Za-z0-9._@-] (after trim)".to_string()); + } + Ok(trimmed.to_string()) +} + +fn user_openhuman_dir(default_openhuman_dir: &Path, user_id: &str) -> Result { + let id = validate_user_id_for_path(user_id)?; + Ok(default_openhuman_dir.join("users").join(&id)) +} + +fn cache_dir_for_user(default_openhuman_dir: &Path, user_id: &str) -> Result { + Ok(user_openhuman_dir(default_openhuman_dir, user_id)?.join("cef")) +} + +/// `remove_dir_all` is only safe for CEF profile dirs we queued ourselves (under +/// `.../users//cef`). Rejects absolute paths outside that tree, corrupted +/// TOML, or anything that `canonicalize` would not place under +/// `default_openhuman_dir/users/…/cef`. +fn is_trusted_queued_purge_path(default_openhuman_dir: &Path, target: &Path) -> bool { + if !target.is_absolute() { + log::warn!( + "[cef-profile] refusing purge: path is not absolute (possible cwd-relative TOML injection) path={}", + target.display() + ); + return false; + } + let Ok(data_root) = std::fs::canonicalize(default_openhuman_dir) else { + log::warn!( + "[cef-profile] refusing purge: could not canonicalize data root path={} (cannot validate purge target) target={}", + default_openhuman_dir.display(), + target.display() + ); + return false; + }; + let users_dir = data_root.join("users"); + let Ok(users_canon) = std::fs::canonicalize(&users_dir) else { + log::warn!( + "[cef-profile] refusing purge: could not canonicalize `users` dir under {} (target={})", + data_root.display(), + target.display() + ); + return false; + }; + let Ok(canon) = std::fs::canonicalize(target) else { + log::warn!( + "[cef-profile] refusing purge: could not canonicalize target (symlink/permission?) path={}", + target.display() + ); + return false; + }; + if !canon.starts_with(&users_canon) { + log::warn!( + "[cef-profile] refusing purge: canonical path is not under users tree (possible malicious queue entry) data_root={} target_canon={}", + data_root.display(), + canon.display() + ); + return false; + } + if canon.file_name() != Some(OsStr::new("cef")) { + log::warn!( + "[cef-profile] refusing purge: expected a .../users//cef directory, got file_name={:?} path={}", + canon.file_name(), + canon.display() + ); + return false; + } + true +} + +/// Marker file lives in the **parent** of the OpenHuman data root so a full +/// `remove_dir_all(default_openhuman_dir)` (e.g. from core `reset_local_data`) does +/// not delete the pending-purge list before it is processed. +fn pending_purge_marker_path(default_openhuman_dir: &Path) -> Result { + let parent = default_openhuman_dir.parent().ok_or_else(|| { + "default OpenHuman data dir has no parent; cannot place CEF purge marker outside the data tree" + .to_string() + })?; + Ok(parent.join(PENDING_PURGE_STATE_FILE)) +} + +pub fn configured_cache_path_from_env() -> Option { + std::env::var(CEF_CACHE_PATH_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn load_pending_purge_state(default_openhuman_dir: &Path) -> Result { + let path = pending_purge_marker_path(default_openhuman_dir)?; + if path.exists() { + let raw = std::fs::read_to_string(&path).map_err(|error| { + format!("read pending CEF purge marker {}: {error}", path.display()) + })?; + return toml::from_str(&raw).map_err(|error| { + format!("parse pending CEF purge marker {}: {error}", path.display()) + }); + } + + // One-time read from the legacy in-tree file (older app versions). + let legacy = default_openhuman_dir.join(LEGACY_PENDING_PURGE_IN_TREE); + if !legacy.exists() { + return Ok(PendingCefPurgeState::default()); + } + let raw = std::fs::read_to_string(&legacy).map_err(|error| { + format!( + "read legacy pending CEF purge marker {}: {error}", + legacy.display() + ) + })?; + let state: PendingCefPurgeState = toml::from_str(&raw).map_err(|error| { + format!( + "parse legacy pending CEF purge marker {}: {error}", + legacy.display() + ) + })?; + match save_pending_purge_state(default_openhuman_dir, &state) { + Ok(()) => { + let _ = std::fs::remove_file(&legacy); + log::info!( + "[cef-profile] migrated pending CEF purge list from {} to {}", + legacy.display(), + path.display() + ); + } + Err(err) => log::warn!( + "[cef-profile] could not write migrated pending CEF purge marker to {}: {err}", + path.display() + ), + } + Ok(state) +} + +fn save_pending_purge_state( + default_openhuman_dir: &Path, + state: &PendingCefPurgeState, +) -> Result<(), String> { + std::fs::create_dir_all(default_openhuman_dir).map_err(|error| { + format!( + "create OpenHuman root dir {}: {error}", + default_openhuman_dir.display() + ) + })?; + + let path = pending_purge_marker_path(default_openhuman_dir)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "create parent of pending CEF purge marker {}: {error}", + path.display() + ) + })?; + } + let raw = toml::to_string_pretty(state) + .map_err(|error| format!("serialize pending CEF purge marker: {error}"))?; + std::fs::write(&path, raw) + .map_err(|error| format!("write pending CEF purge marker {}: {error}", path.display())) +} + +pub fn queue_profile_purge_for_user(user_id: Option<&str>) -> Result { + let default_openhuman_dir = default_root_openhuman_dir()?; + let user_id = user_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(PRE_LOGIN_USER_ID); + let purge_path = cache_dir_for_user(&default_openhuman_dir, user_id)?; + + let mut state = load_pending_purge_state(&default_openhuman_dir)?; + let mut unique = BTreeSet::new(); + for path in state.paths { + unique.insert(path); + } + unique.insert(purge_path.display().to_string()); + state = PendingCefPurgeState { + paths: unique.into_iter().collect(), + }; + save_pending_purge_state(&default_openhuman_dir, &state)?; + log::info!( + "[cef-profile] queued purge for user={} path={}", + user_id, + purge_path.display() + ); + Ok(purge_path) +} + +pub fn prepare_process_cache_path() -> Result { + let default_openhuman_dir = default_root_openhuman_dir()?; + drain_pending_purges(&default_openhuman_dir)?; + + let user_id_raw = read_active_user_id(&default_openhuman_dir) + .unwrap_or_else(|| PRE_LOGIN_USER_ID.to_string()); + let user_id = match validate_user_id_for_path(&user_id_raw) { + Ok(id) => id, + Err(why) => { + log::warn!( + "[cef-profile] invalid user_id in active user state: {why}; using {}", + PRE_LOGIN_USER_ID + ); + PRE_LOGIN_USER_ID.to_string() + } + }; + let cache_dir = cache_dir_for_user(&default_openhuman_dir, &user_id)?; + std::fs::create_dir_all(&cache_dir) + .map_err(|error| format!("create CEF cache dir {}: {error}", cache_dir.display()))?; + std::env::set_var(CEF_CACHE_PATH_ENV, &cache_dir); + log::info!( + "[cef-profile] configured CEF cache user={} path={}", + user_id, + cache_dir.display() + ); + Ok(cache_dir) +} + +fn drain_pending_purges(default_openhuman_dir: &Path) -> Result<(), String> { + let marker_path = pending_purge_marker_path(default_openhuman_dir)?; + let mut state = load_pending_purge_state(default_openhuman_dir)?; + if state.paths.is_empty() { + if marker_path.exists() { + let _ = std::fs::remove_file(&marker_path); + } + return Ok(()); + } + + let mut remaining: Vec = Vec::new(); + for raw_path in &state.paths { + let target = PathBuf::from(raw_path); + if !target.exists() { + log::debug!( + "[cef-profile] pending purge target already absent path={}", + target.display() + ); + continue; + } + if !is_trusted_queued_purge_path(default_openhuman_dir, &target) { + log::warn!( + "[cef-profile] skipping unsafe purge and retaining queue entry (will not delete) path={} raw_toml={}", + target.display(), + raw_path + ); + remaining.push(raw_path.clone()); + continue; + } + match std::fs::remove_dir_all(&target) { + Ok(()) => { + log::info!( + "[cef-profile] purged queued CEF cache path={}", + target.display() + ); + } + Err(error) => { + log::warn!( + "[cef-profile] failed to purge queued CEF cache path={} error={}", + target.display(), + error + ); + remaining.push(raw_path.clone()); + } + } + } + + if !remaining.is_empty() { + state.paths = remaining; + save_pending_purge_state(default_openhuman_dir, &state)?; + log::warn!( + "[cef-profile] not removing pending CEF purge marker: {} path(s) still fail purge (will retry) marker={}", + state.paths.len(), + marker_path.display() + ); + return Ok(()); + } + + if marker_path.exists() { + std::fs::remove_file(&marker_path).map_err(|error| { + format!( + "remove pending CEF purge marker {}: {error}", + marker_path.display() + ) + })?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_active_user_id_ignores_empty_values() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(ACTIVE_USER_STATE_FILE), "user_id = \" \"").unwrap(); + assert_eq!(read_active_user_id(tmp.path()), None); + } + + #[test] + fn cache_dir_for_user_nests_under_users_tree() { + let root = PathBuf::from("/tmp/openhuman"); + assert_eq!( + cache_dir_for_user(&root, "u-123").unwrap(), + PathBuf::from("/tmp/openhuman/users/u-123/cef") + ); + } + + #[test] + fn validate_user_id_rejects_path_traversal() { + assert!(validate_user_id_for_path("..").is_err()); + assert!(validate_user_id_for_path("a/../b").is_err()); + assert!(validate_user_id_for_path("x/y").is_err()); + } + + #[test] + fn validate_user_id_accepts_typical_ids() { + assert_eq!(validate_user_id_for_path("u-123").unwrap(), "u-123"); + assert_eq!( + validate_user_id_for_path("user@ex.com").unwrap(), + "user@ex.com" + ); + } + + /// `default_openhuman_dir` must have a parent (sibling marker uses `parent()`). + fn test_data_hierarchy() -> (tempfile::TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let data_root = tmp.path().join("oh_data"); + std::fs::create_dir_all(&data_root).unwrap(); + (tmp, data_root) + } + + #[test] + fn legacy_purge_marker_migrates_to_sibling_file() { + let (_tmp, data_root) = test_data_hierarchy(); + let legacy = data_root.join(LEGACY_PENDING_PURGE_IN_TREE); + let sibling = data_root.parent().unwrap().join(PENDING_PURGE_STATE_FILE); + let body = r#"paths = []"#; + std::fs::write(&legacy, body).unwrap(); + assert!(!sibling.exists()); + + let _ = load_pending_purge_state(&data_root).unwrap(); + + assert!(!legacy.exists()); + assert!(sibling.exists()); + } + + #[test] + fn drain_removes_only_trusted_paths_and_clears_marker() { + let (_tmp, data_root) = test_data_hierarchy(); + let cef = data_root.join("users").join("u1").join("cef"); + std::fs::create_dir_all(&cef).unwrap(); + std::fs::write(cef.join("x.txt"), b"x").unwrap(); + let cef_s = cef.to_string_lossy().to_string(); + + let state = PendingCefPurgeState { paths: vec![cef_s] }; + save_pending_purge_state(&data_root, &state).unwrap(); + + drain_pending_purges(&data_root).unwrap(); + + assert!(!cef.exists()); + let marker = pending_purge_marker_path(&data_root).unwrap(); + assert!(!marker.exists()); + } + + #[test] + fn drain_retains_malicious_queue_path_without_deleting() { + let (tmp, data_root) = test_data_hierarchy(); + let outside = tmp.path().join("outside_sandbox"); + std::fs::create_dir_all(&outside).unwrap(); + let outside_s = outside.to_string_lossy().to_string(); + let state = PendingCefPurgeState { + paths: vec![outside_s.clone()], + }; + save_pending_purge_state(&data_root, &state).unwrap(); + + drain_pending_purges(&data_root).unwrap(); + + assert!(outside.exists()); + let rest = load_pending_purge_state(&data_root).unwrap(); + assert_eq!(rest.paths, vec![outside_s]); + let marker = pending_purge_marker_path(&data_root).unwrap(); + assert!(marker.exists()); + } + + /// Path is under `users/…` but last component is not `cef` (reject, retain in queue). + #[test] + fn drain_does_not_remove_path_without_cef_final_segment() { + let (_tmp, data_root) = test_data_hierarchy(); + let d = data_root.join("users").join("u1").join("data"); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("f"), b"1").unwrap(); + save_pending_purge_state( + &data_root, + &PendingCefPurgeState { + paths: vec![d.to_string_lossy().to_string()], + }, + ) + .unwrap(); + + drain_pending_purges(&data_root).unwrap(); + + assert!(d.exists()); + let after = load_pending_purge_state(&data_root).unwrap(); + assert_eq!(after.paths.len(), 1); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index a4ef23c93..8928272ec 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -5,6 +5,8 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp mod cdp; #[cfg(all(feature = "cef", target_os = "macos"))] mod cef_preflight; +#[cfg(feature = "cef")] +mod cef_profile; mod core_process; mod core_update; #[cfg(feature = "cef")] @@ -330,6 +332,19 @@ async fn restart_core_process( state.inner().restart().await } +#[tauri::command] +async fn restart_app(app: tauri::AppHandle) -> Result<(), String> { + log::info!("[app] restart_app invoked from frontend"); + app.restart(); +} + +#[cfg(feature = "cef")] +#[tauri::command] +async fn schedule_cef_profile_purge(user_id: Option) -> Result { + let queued = cef_profile::queue_profile_purge_for_user(user_id.as_deref())?; + Ok(queued.display().to_string()) +} + /// Fire the proactive welcome agent on a detached core-side task. /// /// Returns immediately; the agent runs in the background and publishes @@ -762,6 +777,17 @@ pub fn run() { // here and exit cleanly with a message that names the lock-holder PID // and the workaround. Stale locks (PID dead) are removed and we // continue, matching Chromium's own startup recovery. + #[cfg(feature = "cef")] + match cef_profile::prepare_process_cache_path() { + Ok(path) => log::debug!("[cef-profile] startup cache path={}", path.display()), + Err(error) => { + log::error!( + "[cef-profile] failed to configure per-user CEF cache; refusing to start with shared/global cache: {error}" + ); + std::process::exit(1); + } + } + #[cfg(all(feature = "cef", target_os = "macos"))] if let Err(e) = cef_preflight::check_default_cache() { eprintln!("\n[openhuman] {e}\n"); @@ -920,8 +946,17 @@ pub fn run() { // providers (gmail, whatsapp, slack, …) already have a live // session cookie. Best-effort — if we can't resolve the path // the core treats every provider as logged_out. - if let Ok(cache_dir) = app.path().app_cache_dir() { - let cookies_db = cache_dir.join("cef").join("Default").join("Cookies"); + if let Some(cache_dir) = { + #[cfg(feature = "cef")] + { + cef_profile::configured_cache_path_from_env() + } + #[cfg(not(feature = "cef"))] + { + None + } + } { + let cookies_db = cache_dir.join("Default").join("Cookies"); log::debug!("[webview_accounts] exposing cookies DB path to core"); std::env::set_var("OPENHUMAN_CEF_COOKIES_DB", &cookies_db); } else { @@ -929,7 +964,7 @@ pub fn run() { // stale path from a previous run or the parent shell. std::env::remove_var("OPENHUMAN_CEF_COOKIES_DB"); log::warn!( - "[webview_accounts] could not resolve app_cache_dir — core \ + "[webview_accounts] could not resolve configured CEF cache dir — core \ will report all webview providers as logged_out" ); } @@ -1266,6 +1301,9 @@ pub fn run() { check_app_update, apply_app_update, restart_core_process, + restart_app, + #[cfg(feature = "cef")] + schedule_cef_profile_purge, spawn_welcome_agent, service_install_direct, service_start_direct, diff --git a/app/src-tauri/vendor/tauri-cef b/app/src-tauri/vendor/tauri-cef index 448d47604..3a4b24b9e 160000 --- a/app/src-tauri/vendor/tauri-cef +++ b/app/src-tauri/vendor/tauri-cef @@ -1 +1 @@ -Subproject commit 448d4760463d5f73fd84183ca541f53d71d73e34 +Subproject commit 3a4b24b9e35bbcd8c61ea1901fdc0ffe138b790e diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index ec7545358..ae783e9e9 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -2,14 +2,18 @@ import { useState } from 'react'; import { useCoreState } from '../../providers/CoreStateProvider'; import { persistor } from '../../store'; -import { resetOpenHumanDataAndRestartCore } from '../../utils/tauriCommands'; +import { + resetOpenHumanDataAndRestartCore, + restartApp, + scheduleCefProfilePurge, +} from '../../utils/tauriCommands'; import SettingsHeader from './components/SettingsHeader'; import SettingsMenuItem from './components/SettingsMenuItem'; import { useSettingsNavigation } from './hooks/useSettingsNavigation'; const SettingsHome = () => { const { navigateToSettings } = useSettingsNavigation(); - const { clearSession } = useCoreState(); + const { clearSession, snapshot } = useCoreState(); const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -24,6 +28,18 @@ const SettingsHome = () => { }; const clearAllAppData = async () => { + const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null; + + // Queue the current user-scoped CEF profile for deletion on next launch. + // The active CEF browser process may still hold SQLite/cache file handles, + // so we delete after the shell restarts rather than relying on in-process + // removal to succeed everywhere. + try { + await scheduleCefProfilePurge(currentUserId); + } catch (err) { + console.warn('[Settings] Failed to queue CEF profile purge:', err); + } + // 1. Logout — clear session in core (auth_clear_session). Best-effort: // if the core process is wedged we still want to wipe local data. try { @@ -55,11 +71,9 @@ const SettingsHome = () => { window.localStorage.clear(); window.sessionStorage.clear(); - // 4. Hard-reload to reset in-memory Redux + React state. Without this - // the live store still holds user/socket/account data even though - // the persisted copy is gone — the UI would look logged-in until - // the user manually refreshes. - window.location.reload(); + // 4. Full app restart so the CEF runtime reboots into the fresh + // pre-login profile instead of keeping the old browser process alive. + await restartApp(); }; const handleLogoutAndClearData = async () => { diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index fd7d00432..1d5ce8ad5 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -27,6 +27,7 @@ import { } from '../services/coreStateApi'; import { openhumanUpdateAnalyticsSettings, + restartApp, setOnboardingCompleted, storeSession, syncMemoryClientToken, @@ -374,6 +375,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const storeSessionToken = useCallback( async (token: string, user?: object) => { + const previousIdentity = snapshotIdentity(getCoreStateSnapshot().snapshot); logoutGuardUntilRef.current = 0; await storeSession(token, user ?? {}); try { @@ -386,6 +388,17 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) await refreshTeams().catch(err => { log('refreshTeams failed after session store: %O', sanitizeError(err)); }); + + const nextIdentity = snapshotIdentity(getCoreStateSnapshot().snapshot); + if (nextIdentity && previousIdentity !== nextIdentity) { + const mask = (s: string | null) => + s == null ? 'none' : s.length > 4 ? `****${s.slice(-4)}` : '****'; + console.debug('[core-state] user changed; restarting app to switch CEF profile', { + previousIdentity: mask(previousIdentity), + nextIdentity: mask(nextIdentity), + }); + await restartApp(); + } }, [refresh, refreshTeams] ); diff --git a/app/src/utils/tauriCommands/core.test.ts b/app/src/utils/tauriCommands/core.test.ts new file mode 100644 index 000000000..30b1c8002 --- /dev/null +++ b/app/src/utils/tauriCommands/core.test.ts @@ -0,0 +1,83 @@ +import { invoke, isTauri } from '@tauri-apps/api/core'; +import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest'; + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() })); +vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +describe('tauriCommands/core', () => { + const mockIsTauri = isTauri as Mock; + const mockInvoke = invoke as Mock; + let restartApp: typeof import('./core').restartApp; + let scheduleCefProfilePurge: typeof import('./core').scheduleCefProfilePurge; + + beforeEach(async () => { + vi.clearAllMocks(); + mockIsTauri.mockReturnValue(true); + const actual = await vi.importActual('./core'); + restartApp = actual.restartApp; + scheduleCefProfilePurge = actual.scheduleCefProfilePurge; + }); + + describe('restartApp', () => { + test('no-ops when not in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + await restartApp(); + + expect(mockInvoke).not.toHaveBeenCalled(); + expect(debug).toHaveBeenCalledWith( + expect.stringContaining('restartApp: skipped — not running in Tauri') + ); + debug.mockRestore(); + }); + + test('invokes restart_app when in Tauri', async () => { + await restartApp(); + expect(mockInvoke).toHaveBeenCalledWith('restart_app'); + }); + }); + + describe('scheduleCefProfilePurge', () => { + test('returns null and does not invoke when not in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const out = await scheduleCefProfilePurge('x'); + + expect(out).toBeNull(); + expect(mockInvoke).not.toHaveBeenCalled(); + expect(debug).toHaveBeenCalledWith( + expect.stringContaining('scheduleCefProfilePurge: skipped — not running in Tauri') + ); + debug.mockRestore(); + }); + + test('invoke with userId null when argument is undefined', async () => { + mockInvoke.mockResolvedValueOnce('/path'); + + const out = await scheduleCefProfilePurge(); + + expect(mockInvoke).toHaveBeenCalledWith('schedule_cef_profile_purge', { userId: null }); + expect(out).toBe('/path'); + }); + + test('invoke with userId null when argument is null', async () => { + mockInvoke.mockResolvedValueOnce('/path'); + + const out = await scheduleCefProfilePurge(null); + + expect(mockInvoke).toHaveBeenCalledWith('schedule_cef_profile_purge', { userId: null }); + expect(out).toBe('/path'); + }); + + test('invoke with userId string when provided', async () => { + mockInvoke.mockResolvedValueOnce('/other'); + + const out = await scheduleCefProfilePurge('user-9'); + + expect(mockInvoke).toHaveBeenCalledWith('schedule_cef_profile_purge', { userId: 'user-9' }); + expect(out).toBe('/other'); + }); + }); +}); diff --git a/app/src/utils/tauriCommands/core.ts b/app/src/utils/tauriCommands/core.ts index faef6d2cd..9f1f33006 100644 --- a/app/src/utils/tauriCommands/core.ts +++ b/app/src/utils/tauriCommands/core.ts @@ -59,6 +59,32 @@ export async function restartCoreProcess(): Promise { console.debug('[core] restartCoreProcess: done'); } +/** + * Restart the desktop shell so CEF relaunches with updated profile paths. + */ +export async function restartApp(): Promise { + if (!isTauri()) { + console.debug('[app] restartApp: skipped — not running in Tauri'); + return; + } + console.debug('[app] restartApp: invoking restart_app'); + await invoke('restart_app'); +} + +/** + * Queue deletion of a user-scoped CEF profile on the next app launch. + */ +export async function scheduleCefProfilePurge(userId?: string | null): Promise { + if (!isTauri()) { + console.debug('[cef-profile] scheduleCefProfilePurge: skipped — not running in Tauri'); + return null; + } + console.debug('[cef-profile] scheduleCefProfilePurge: invoking schedule_cef_profile_purge', { + hasUserId: userId != null, + }); + return invoke('schedule_cef_profile_purge', { userId: userId ?? null }); +} + /** * Check if the running core sidecar is outdated compared to what the app expects. */