diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index a7ec7b12..b33ca125 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -2417,3 +2417,130 @@ test("custom launch arguments reach only their Wayfern profile", async () => { await app.close(); } }); + +test("a password-protected profile keeps what each session saved", async () => { + assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); + const app = appFromEnvironment("browser-sealed", { + seedVersionCache: + cachedFixtureVersion(process.env.DONUT_E2E_PROJECT_ROOT) ?? false, + wayfernTermsAccepted: false, + }); + const password = "sealed-sessions"; + let cdp; + let browserPid; + try { + const prepared = await prepareWayfern( + app, + process.env.DONUT_E2E_PROJECT_ROOT, + ); + if (!app.session) await app.start(); + await app.invoke("accept_wayfern_terms"); + const profile = await createRealProfile( + app, + prepared.version, + "Sealed Sessions", + ); + await app.invoke("set_profile_password", { + profileId: profile.id, + password, + }); + const sealedDir = path.join( + app.dataRoot, + "data", + "profiles", + profile.id, + "profile", + ); + const current = async () => + (await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ); + + const saved = []; + for (let session = 0; session < 3; session++) { + if (await app.invoke("is_profile_locked", { profileId: profile.id })) { + await app.invoke("unlock_profile", { profileId: profile.id, password }); + } + const launched = await app.invoke("launch_browser_profile", { + profile: await current(), + url: `${fixtureUrl}/sealed`, + }); + browserPid = launched.process_id; + const port = Number( + processCommandLine(browserPid).match( + /--remote-debugging-port=(\d+)/, + )[1], + ); + cdp = await CdpClient.connect(port); + await cdp.waitFor(`document.title === "Donut E2E Browser Fixture"`, { + description: "fixture page title", + }); + const cookies = await cdp.evaluate("document.cookie"); + for (const name of saved) { + assert.match( + cookies, + new RegExp(`${name}=kept`), + `session ${session} lost the cookie session ${name} saved`, + ); + } + const name = `sealed_${session}`; + await cdp.evaluate( + `document.cookie = "${name}=kept; Max-Age=86400; Path=/"`, + ); + saved.push(name); + cdp.close(); + cdp = null; + + // The cookie store commits every 30 s or at a clean shutdown, and a + // browser asked to close in its first seconds does not shut down. + await new Promise((resolve) => setTimeout(resolve, 32_000)); + await app.invoke("kill_browser_profile", { profile: await current() }); + await waitForProcessExit(app, browserPid); + browserPid = null; + assert.equal( + await app.invoke("is_profile_locked", { profileId: profile.id }), + true, + "a quit is sealed and its key forgotten before Stop returns", + ); + const names = await readdir(sealedDir); + assert.ok( + names.every((n) => /^[\w-]{32}$/.test(n) || n === ".donut-pw-verify"), + `only sealed files belong in the profile directory: ${names}`, + ); + } + + const logDir = path.join(app.dataRoot, "logs"); + const logText = ( + await Promise.all( + ( + await readdir(logDir) + ) + .filter((n) => n.endsWith(".log")) + .map((n) => readFile(path.join(logDir, n), "utf8")), + ) + ).join("\n"); + assert.doesNotMatch(logText, /Cookie decryption check failed/); + assert.doesNotMatch(logText, /Profile re-encryption failed/); + assert.equal( + logText.match( + new RegExp(`Profile re-encrypted profile=${profile.id} `, "g"), + )?.length, + 3, + "each quit is sealed exactly once", + ); + await app.invoke("delete_profile", { profileId: profile.id }); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + cdp?.close(); + if (app.session && browserPid && processExists(browserPid)) { + const profile = ( + await app.invoke("list_browser_profiles").catch(() => []) + ).find((item) => item.process_id === browserPid); + if (profile) + await app.invoke("kill_browser_profile", { profile }).catch(() => {}); + } + await app.close(); + } +}); diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 438e1d31..62b22c53 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -667,18 +667,29 @@ impl BrowserRunner { // `match_profile_fingerprint_to_exit`. // Create ephemeral dir for ephemeral or password-protected profiles - if profile.password_protected { - crate::profile::password::prepare_for_launch(profile) - .map_err(|e| -> Box { e.into() })?; - } else if profile.ephemeral { - crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string()) - .map_err(|e| -> Box { e.into() })?; - } + let decrypted_copy = if profile.password_protected { + let protected = profile.clone(); + let prepared = tokio::task::spawn_blocking(move || { + crate::profile::password::prepare_for_launch(&protected) + }) + .await + .map_err(|e| -> Box { e.to_string().into() })? + .map_err(|e| -> Box { e.into() })?; + Some(prepared) + } else { + if profile.ephemeral { + crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string()) + .map_err(|e| -> Box { e.into() })?; + } + None + }; - // Get profile path for Wayfern + // Get profile path for Wayfern. A protected profile runs only on its + // decrypted copy, never on the sealed directory. let profiles_dir = self.profile_manager.get_profiles_dir(); - let profile_data_path = - crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir); + let profile_data_path = decrypted_copy.unwrap_or_else(|| { + crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir) + }); // Profiles imported by builds before the layout fix have their content at // the user-data-dir root instead of under `Default/`, so the browser has diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc9c0a53..9150bb5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3194,6 +3194,25 @@ pub fn run_with_builder( last_state != is_running || (!is_running && had_pid); if should_emit { + // A Stop seals its own quit and a launch makes the profile + // run again; this exit is handled once neither is under way. + let _launch_guard = if is_running { + None + } else { + let Some(guard) = + crate::browser_runner::try_lock_profile_launch(&profile_id).await + else { + continue; + }; + if runner + .check_browser_status(app_handle_status.clone(), &profile) + .await + .unwrap_or(false) + { + continue; + } + Some(guard) + }; log::debug!( "Browser running state changed profile={profile_id} running={is_running} was={last_state}" ); diff --git a/src-tauri/src/profile/encryption.rs b/src-tauri/src/profile/encryption.rs index 1cff966e..89ceaa5c 100644 --- a/src-tauri/src/profile/encryption.rs +++ b/src-tauri/src/profile/encryption.rs @@ -8,7 +8,7 @@ //! The plaintext relpath is encoded inside the ciphertext so a launch can //! reconstruct the directory tree without a separate manifest. //! -//! Wrong password fails the AES-GCM auth tag on the first decrypt, which +//! Wrong password fails the AES-GCM auth tag on the verifier file, which //! doubles as password verification. use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -30,6 +30,132 @@ const HMAC_FILENAME_LEN: usize = 32; const VERIFY_FILE_NAME: &str = ".donut-pw-verify"; const VERIFY_FILE_PATH: &str = "__donut_pw_verify__"; +/// Extension of a file `atomic_write` has not renamed into place yet. One left +/// by a crash is never a sealed file. +const TMP_EXTENSION: &str = "donut-tmp"; + +/// What a sealed copy leaves out: caches the browser rebuilds, lock and temp +/// files, crash and metrics dumps, and the files Donut writes again before +/// every launch. Everything else is kept, the SQLite journals and write-ahead +/// logs included: without them a database the browser did not close cleanly +/// opens torn instead of rolled back. +pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[ + "**/Cache/**", + "**/Code Cache/**", + "**/GPUCache/**", + "**/GrShaderCache/**", + "**/ShaderCache/**", + "**/DawnCache/**", + "**/DawnGraphiteCache/**", + "**/DawnWebGPUCache/**", + "**/GraphiteDawnCache/**", + "**/Service Worker/CacheStorage/**", + "**/Service Worker/ScriptCache/**", + "**/blob_storage/**", + "**/Crashpad/**", + "**/Crash Reports/**", + "**/BrowserMetrics*", + "**/BrowserMetrics/**", + "**/optimization_guide_model_store/**", + "**/Safe Browsing/**", + "**/component_crx_cache/**", + "**/cache2/**", + "**/startupCache/**", + "**/safebrowsing/**", + "**/storage/temporary/**", + "**/storage/default/*/cache/**", + "**/datareporting/**", + "**/saved-telemetry-pings/**", + "**/crashes/**", + "**/minidumps/**", + "**/*-shm", + "**/LOCK", + "**/LOG", + "**/LOG.old", + "**/SingletonLock", + "**/SingletonSocket", + "**/SingletonCookie", + "**/.DS_Store", + "*.tmp", + "**/*.donut-tmp", + "**/wayfern-identity.json", + "**/wayfern-persona.json", + BASELINE_FILE_NAME, +]; + +/// The file in a decrypted copy that records what the sealed copy holds. It is +/// written only once the copy is complete and removed before the copy is +/// erased, so a directory without it is never mistaken for a profile. +pub const BASELINE_FILE_NAME: &str = ".donut-baseline.json"; + +/// A file's size and modification time, enough to tell that it changed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FileStamp { + secs: u64, + nanos: u32, + len: u64, +} + +impl FileStamp { + fn of(metadata: &std::fs::Metadata) -> Option { + let modified = metadata + .modified() + .ok()? + .duration_since(SystemTime::UNIX_EPOCH) + .ok()?; + Some(Self { + secs: modified.as_secs(), + nanos: modified.subsec_nanos(), + len: metadata.len(), + }) + } +} + +/// The stamp of every file of a decrypted copy, by plaintext relpath, as of the +/// moment the sealed copy last held the same bytes. +pub type Baseline = HashMap; + +/// The result of decrypting a sealed copy. +pub struct Decrypted { + pub baseline: Baseline, + /// Sealed files that did not open with the right key: damaged, and skipped. + pub unreadable: usize, +} + +/// The stamp of the sealed copy as a whole. Every seal and every password +/// change rewrites the verifier, and a sync download replaces it, so a new +/// stamp means the sealed copy changed. +pub fn sealed_stamp(encrypted_dir: &Path) -> Option { + FileStamp::of(&std::fs::metadata(encrypted_dir.join(VERIFY_FILE_NAME)).ok()?) +} + +/// Whether `plaintext_dir` differs from `baseline`: a file added, changed or +/// removed. +pub fn has_changes( + plaintext_dir: &Path, + exclude_patterns: &[&str], + baseline: &Baseline, +) -> PasswordResult { + let excludes = build_excludes(exclude_patterns); + let mut files = Vec::new(); + walk_files(plaintext_dir, plaintext_dir, &excludes, &mut files)?; + if files.len() != baseline.len() { + return Ok(true); + } + for (relpath, abs) in files { + if FileStamp::of(&std::fs::metadata(&abs)?).as_ref() != baseline.get(&relpath) { + return Ok(true); + } + } + Ok(false) +} + +fn is_tmp_name(name: &str) -> bool { + Path::new(name) + .extension() + .is_some_and(|ext| ext == TMP_EXTENSION) +} + /// In-memory cache of derived per-profile encryption keys, keyed by profile UUID. /// Only populated while a profile is unlocked / running. Never persisted. static KEY_CACHE: std::sync::LazyLock>> = @@ -128,10 +254,9 @@ fn walk_files( continue; } - let metadata = match entry.metadata() { - Ok(m) => m, - Err(_) => continue, - }; + // An error, never a skip: a file left out here is a file the sealed copy + // deletes. + let metadata = entry.metadata()?; if metadata.is_dir() { walk_files(base, &path, excludes, out)?; @@ -142,11 +267,11 @@ fn walk_files( Ok(()) } -fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { +pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let tmp = path.with_extension("donut-tmp"); + let tmp = path.with_extension(TMP_EXTENSION); std::fs::write(&tmp, data)?; std::fs::rename(&tmp, path) } @@ -205,23 +330,25 @@ pub fn encrypt_profile_dir( } /// Decrypt every file in `encrypted_dir` back into `plaintext_dir` (which is -/// created if missing). Returns the per-file mtimes captured after writing, -/// keyed by plaintext relpath. Caller can use them as the "before-launch" -/// snapshot to skip unchanged files on re-encrypt. +/// created if missing), and return the baseline of what it wrote. +/// +/// The key is checked against the verifier first, so a file that does not +/// open after that is damaged, not a wrong password: it is skipped and counted +/// rather than failing the whole profile. pub fn decrypt_profile_dir( key: &[u8; 32], encrypted_dir: &Path, plaintext_dir: &Path, -) -> PasswordResult> { +) -> PasswordResult { + verify_key_against_dir(key, encrypted_dir)?; std::fs::create_dir_all(plaintext_dir)?; - let mut mtimes = HashMap::new(); + let mut decrypted = Decrypted { + baseline: Baseline::new(), + unreadable: 0, + }; - let entries: Vec<_> = std::fs::read_dir(encrypted_dir)? - .filter_map(|r| r.ok()) - .collect(); - - for entry in entries { - let path = entry.path(); + for entry in std::fs::read_dir(encrypted_dir)? { + let path = entry?.path(); if !path.is_file() { continue; } @@ -229,37 +356,40 @@ pub fn decrypt_profile_dir( Some(n) => n, None => continue, }; - if name == VERIFY_FILE_NAME { + if name == VERIFY_FILE_NAME || is_tmp_name(name) { continue; } let bytes = std::fs::read(&path)?; - let (relpath, content) = decrypt_profile_file(key, &bytes)?; + let Ok((relpath, content)) = decrypt_profile_file(key, &bytes) else { + decrypted.unreadable += 1; + continue; + }; let dest = plaintext_dir.join(&relpath); if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&dest, &content)?; - if let Ok(m) = dest.metadata().and_then(|m| m.modified()) { - mtimes.insert(relpath, m); + if let Some(stamp) = FileStamp::of(&std::fs::metadata(&dest)?) { + decrypted.baseline.insert(relpath, stamp); } } - Ok(mtimes) + Ok(decrypted) } /// Re-encrypt the contents of `plaintext_dir` back into `encrypted_dir`, /// preserving on-disk filenames for files whose plaintext content didn't -/// change. Returns the number of files re-encrypted. +/// change. Returns the number of files re-encrypted and the new baseline. /// -/// `before_launch_mtimes` is the snapshot captured by `decrypt_profile_dir`. -/// Files whose mtime hasn't moved are left untouched on disk. +/// A file whose stamp matches `baseline` is already sealed and is left alone. +/// A sealed file whose plaintext is gone is deleted. pub fn reencrypt_changed_files( key: &[u8; 32], plaintext_dir: &Path, encrypted_dir: &Path, exclude_patterns: &[&str], - before_launch_mtimes: &HashMap, -) -> PasswordResult { + baseline: &Baseline, +) -> PasswordResult<(usize, Baseline)> { std::fs::create_dir_all(encrypted_dir)?; let excludes = build_excludes(exclude_patterns); @@ -269,17 +399,20 @@ pub fn reencrypt_changed_files( } let mut current_paths: HashSet = HashSet::new(); + let mut sealed = Baseline::new(); let mut rewrote = 0usize; for (relpath, abs) in current_files { current_paths.insert(relpath.clone()); - let cur_mtime = abs.metadata().and_then(|m| m.modified()).ok(); - let unchanged = match (cur_mtime, before_launch_mtimes.get(&relpath)) { - (Some(now), Some(before)) => now == *before, - _ => false, - }; - if unchanged { - continue; + // Taken before the read, so a write that lands during it shows up as a + // change next time rather than being missed. A file with no usable time + // gets no entry and is sealed again every time. + let stamp = FileStamp::of(&std::fs::metadata(&abs)?); + if let Some(stamp) = stamp { + sealed.insert(relpath.clone(), stamp); + if baseline.get(&relpath) == Some(&stamp) { + continue; + } } let bytes = std::fs::read(&abs)?; @@ -313,21 +446,24 @@ pub fn reencrypt_changed_files( } write_verifier(key, encrypted_dir)?; - Ok(rewrote) + Ok((rewrote, sealed)) } /// Re-encrypt every file under `encrypted_dir` from `old_key` to `new_key` in /// place. Used when changing a profile password without launching it. +/// Returns how many damaged files were dropped: they open with neither key. pub fn rekey_profile_dir( old_key: &[u8; 32], new_key: &[u8; 32], encrypted_dir: &Path, -) -> PasswordResult<()> { +) -> PasswordResult { + verify_key_against_dir(old_key, encrypted_dir)?; let entries: Vec<_> = std::fs::read_dir(encrypted_dir)? .filter_map(|r| r.ok()) .collect(); let mut decrypted: Vec<(String, Vec)> = Vec::new(); + let mut unreadable = 0usize; for entry in &entries { let path = entry.path(); if !path.is_file() { @@ -337,15 +473,17 @@ pub fn rekey_profile_dir( Some(n) => n, None => continue, }; - if name == VERIFY_FILE_NAME { + if name == VERIFY_FILE_NAME || is_tmp_name(name) { continue; } let bytes = std::fs::read(&path)?; - let (relpath, content) = decrypt_profile_file(old_key, &bytes)?; - decrypted.push((relpath, content)); + match decrypt_profile_file(old_key, &bytes) { + Ok(file) => decrypted.push(file), + Err(_) => unreadable += 1, + } } - // Decryption succeeded for every file; safe to rewrite the directory. + // Every readable file is in memory; safe to rewrite the directory. for entry in entries { let path = entry.path(); if path.is_file() { @@ -360,7 +498,7 @@ pub fn rekey_profile_dir( } write_verifier(new_key, encrypted_dir)?; - Ok(()) + Ok(unreadable) } // ---------- key cache ---------- diff --git a/src-tauri/src/profile/encryption_tests.rs b/src-tauri/src/profile/encryption_tests.rs index 20b5c9b8..ececbcbe 100644 --- a/src-tauri/src/profile/encryption_tests.rs +++ b/src-tauri/src/profile/encryption_tests.rs @@ -88,8 +88,9 @@ fn test_dir_roundtrip() { assert!(enc.join(VERIFY_FILE_NAME).exists()); let restored = work.path().join("restored"); - let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap(); - assert_eq!(mtimes.len(), 3); + let decrypted = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + assert_eq!(decrypted.baseline.len(), 3); + assert_eq!(decrypted.unreadable, 0); assert_eq!( std::fs::read(restored.join("Default/Cookies")).unwrap(), @@ -118,11 +119,11 @@ fn test_dir_excludes() { encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap(); let restored = work.path().join("restored"); - let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + let baseline = decrypt_profile_dir(&key, &enc, &restored).unwrap().baseline; // Only Cookies (1 file) should be present, not Cache contents - assert_eq!(mtimes.len(), 1); - assert!(mtimes.contains_key("Default/Cookies")); + assert_eq!(baseline.len(), 1); + assert!(baseline.contains_key("Default/Cookies")); assert!(restored.join("Default/Cookies").exists()); assert!(!restored.join("Default/Cache/data").exists()); } @@ -156,7 +157,7 @@ fn test_reencrypt_skips_unchanged() { encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); let restored = work.path().join("restored"); - let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap().baseline; // Capture pre-rewrite ciphertext bytes let name_a = hmac_filename(&key, "a"); @@ -168,8 +169,14 @@ fn test_reencrypt_skips_unchanged() { std::thread::sleep(std::time::Duration::from_millis(1100)); std::fs::write(restored.join("a"), b"AAA-CHANGED").unwrap(); - let rewrote = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap(); + let (rewrote, sealed) = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap(); assert_eq!(rewrote, 1); + assert_eq!(sealed["b"], snapshot["b"]); + assert_ne!(sealed["a"], snapshot["a"]); + + // Sealed again from the new baseline, nothing has changed. + let (rewrote, _) = reencrypt_changed_files(&key, &restored, &enc, &[], &sealed).unwrap(); + assert_eq!(rewrote, 0); let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap(); let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap(); @@ -195,7 +202,7 @@ fn test_reencrypt_handles_added_and_removed() { encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); let restored = work.path().join("restored"); - let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap().baseline; std::fs::remove_file(restored.join("delete")).unwrap(); std::fs::write(restored.join("new"), b"n").unwrap(); @@ -289,3 +296,63 @@ fn test_unlock_helper() { assert!(has_cached_key(&id)); drop_cached_key(&id); } + +#[test] +fn decrypt_checks_the_key_and_skips_damaged_and_unfinished_files() { + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("good"), b"good").unwrap(); + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + std::fs::write(enc.join("damaged"), b"not ciphertext at all").unwrap(); + // What a crash in the middle of atomic_write leaves. + std::fs::write( + enc + .join(hmac_filename(&key, "good")) + .with_extension("donut-tmp"), + b"partial", + ) + .unwrap(); + + let out = work.path().join("out"); + assert!(matches!( + decrypt_profile_dir(&make_key(), &enc, &out), + Err(PasswordError::WrongPassword) + )); + assert!(!out.exists(), "a wrong key must write nothing"); + + let decrypted = decrypt_profile_dir(&key, &enc, &out).unwrap(); + assert_eq!(decrypted.unreadable, 1); + assert_eq!(decrypted.baseline.len(), 1); + assert_eq!(std::fs::read(out.join("good")).unwrap(), b"good"); +} + +#[test] +fn the_sealed_exclusions_keep_what_a_database_needs() { + let excludes = build_excludes(SEALED_EXCLUDE_PATTERNS); + for kept in [ + "Default/Network/Cookies", + "Default/Network/Cookies-journal", + "Default/History-wal", + "Default/Secure Preferences", + "Default/Session Storage/000003.log", + "Local State", + "os_crypt_key", + ] { + assert!(!excludes.is_match(kept), "{kept} must be sealed"); + } + for dropped in [ + "Default/Cache/Cache_Data/index", + "Default/Code Cache/js/index", + "Default/History-shm", + "Default/Local Storage/leveldb/LOCK", + "SingletonLock", + "wayfern-identity.json", + BASELINE_FILE_NAME, + "Default/x.donut-tmp", + ] { + assert!(excludes.is_match(dropped), "{dropped} must not be sealed"); + } +} diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 7ade2fdb..8d20163d 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -857,10 +857,7 @@ impl ProfileManager { // to. The running-browser guard above only rejects a live process, and // the keep-decrypted path deliberately clears process_id while leaving // the plaintext tree populated. No-ops when there is no ephemeral dir. - crate::ephemeral_dirs::remove_ephemeral_dir(profile_id); - if profile.password_protected { - crate::profile::encryption::drop_cached_key(&profile.id); - } + crate::profile::password::discard_copy(&profile.id); let was_sync_enabled = profile.is_sync_enabled(); let profiles_dir = self.get_profiles_dir(); @@ -1067,9 +1064,11 @@ impl ProfileManager { &self, profile_id: &str, ) -> Result<(), Box> { - crate::ephemeral_dirs::remove_ephemeral_dir(profile_id); - if let Ok(uuid) = uuid::Uuid::parse_str(profile_id) { - crate::profile::encryption::drop_cached_key(&uuid); + match uuid::Uuid::parse_str(profile_id) { + Ok(uuid) => crate::profile::password::discard_copy(&uuid), + Err(_) => { + crate::ephemeral_dirs::remove_ephemeral_dir(profile_id); + } } self.forget_profile_side_state(profile_id); let profiles_dir = self.get_profiles_dir(); diff --git a/src-tauri/src/profile/password.rs b/src-tauri/src/profile/password.rs index 9952a2da..7d0e466f 100644 --- a/src-tauri/src/profile/password.rs +++ b/src-tauri/src/profile/password.rs @@ -9,16 +9,17 @@ use crate::events; use crate::profile::encryption::{ cache_key, decrypt_profile_dir, drop_cached_key, encrypt_profile_dir, fresh_salt, get_cached_key, - has_cached_key, rekey_profile_dir, unlock as unlock_dir, verify_key_against_dir, + has_cached_key, has_changes, reencrypt_changed_files, rekey_profile_dir, sealed_stamp, + unlock as unlock_dir, verify_key_against_dir, Baseline, FileStamp, PasswordError, + BASELINE_FILE_NAME, SEALED_EXCLUDE_PATTERNS, }; use crate::profile::ProfileManager; use crate::sync::encryption::derive_profile_key; -use crate::sync::manifest::DEFAULT_EXCLUDE_PATTERNS; use serde_json::json; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::SystemTime; +use std::time::{Duration, Instant, SystemTime}; /// Build a JSON error payload with just a code. fn err_code(code: &'static str) -> String { @@ -40,16 +41,23 @@ fn err_internal(detail: impl std::fmt::Display) -> String { err_with("INTERNAL_ERROR", &[("detail", detail.to_string())]) } -/// Per-profile snapshot of plaintext file mtimes captured at launch time. -/// Used by `complete_after_quit` to skip re-encrypting unchanged files. -static LAUNCH_SNAPSHOTS: std::sync::LazyLock< - Mutex>>, -> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); -/// Profile IDs whose ephemeral dir is currently populated and matches the -/// on-disk encrypted state, so we can skip re-decrypting on the next launch -/// when `keep_decrypted_profiles_in_ram` is enabled. -static POPULATED_EPHEMERAL: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(HashSet::new())); +/// The error a password command returns when the decrypted copy it must seal +/// first cannot be sealed. The key is the one the command just verified, so +/// `NoKey` cannot happen here. +fn seal_error(error: SealError) -> String { + match error { + SealError::InUse(_) => err_code("PROFILE_RUNNING"), + SealError::NoKey => err_code("PROFILE_LOCKED"), + SealError::Failed(e) => err_internal(e), + } +} + +/// Per-profile lock around everything that touches a profile's decrypted copy +/// or rewrites its sealed one. A quit is sealed by both the Stop button and the +/// exit watcher; unserialized, one of them read files the other was already +/// zeroing, and the sealed copy kept the zeros. +static COPY_LOCKS: std::sync::LazyLock>>>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); /// Per-profile failed unlock attempt tracking for rate-limiting. static FAILED_ATTEMPTS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); @@ -80,6 +88,16 @@ fn attempt_lock(profile_id: &uuid::Uuid) -> Arc> { .clone() } +/// The copy lock for one profile. Held only on blocking threads: preparing a +/// launch, sealing after a quit, and the password commands. +fn copy_lock(profile_id: &uuid::Uuid) -> Arc> { + let mut guard = COPY_LOCKS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard.retain(|_, lock| Arc::strong_count(lock) > 1); + guard.entry(*profile_id).or_default().clone() +} + #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] struct FailureRecord { count: u32, @@ -290,7 +308,7 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul if staging.exists() { let _ = std::fs::remove_dir_all(&staging); } - encrypt_profile_dir(&key, &plaintext_dir, &staging, DEFAULT_EXCLUDE_PATTERNS) + encrypt_profile_dir(&key, &plaintext_dir, &staging, SEALED_EXCLUDE_PATTERNS) .map_err(err_internal)?; // Move plaintext aside, swap in encrypted, then delete plaintext. @@ -405,9 +423,24 @@ pub async fn lock_profile(profile_id: String) -> Result<(), String> { { return Err(err_code("PROFILE_RUNNING")); } + { + let lock = copy_lock(&id); + let _copy = lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let key = get_cached_key(&id); + match seal_copy(&id, &profile_data_dir(&profile), key.as_ref(), false) { + // Sealed and erased, or changes that wait for the next unlock. + Ok(Some(_)) | Err(SealError::NoKey) => {} + // Debris or nothing: either way there is nothing to keep. + Ok(None) => { + crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string()); + } + Err(SealError::InUse(_)) => return Err(err_code("PROFILE_RUNNING")), + Err(SealError::Failed(e)) => return Err(err_internal(e)), + } + } drop_cached_key(&id); - // Purge any leftover ephemeral dir in case keep_decrypted_profiles_in_ram was on. - crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string()); emit_profiles_changed(); Ok(()) } @@ -459,7 +492,19 @@ pub async fn change_profile_password( let new_salt = fresh_salt(); let new_key = derive_profile_key(&new_password, &new_salt).map_err(err_internal)?; - rekey_profile_dir(&old_key, &new_key, &dir).map_err(err_internal)?; + { + let lock = copy_lock(&id); + let _copy = lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // A copy with changes the old key has not sealed yet would otherwise keep + // them out of the sealed profile for good. + seal_copy(&id, &dir, Some(&old_key), true).map_err(seal_error)?; + let damaged = rekey_profile_dir(&old_key, &new_key, &dir).map_err(err_internal)?; + if damaged > 0 { + log::error!("Damaged sealed files dropped on password change profile={id} count={damaged}"); + } + } profile.encryption_salt = Some(new_salt); ProfileManager::instance() @@ -512,11 +557,26 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re } clear_failed_attempts(&id); + let lock = copy_lock(&id); + let _copy = lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + seal_copy(&id, &encrypted_dir, Some(&key), false).map_err(seal_error)?; + // Debris an earlier erase left; the profile is about to be plaintext where + // it lives, and no copy of it may linger elsewhere. + crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string()); + let staging = encrypted_dir.with_extension("decrypting"); if staging.exists() { let _ = std::fs::remove_dir_all(&staging); } - decrypt_profile_dir(&key, &encrypted_dir, &staging).map_err(err_internal)?; + let decrypted = decrypt_profile_dir(&key, &encrypted_dir, &staging).map_err(err_internal)?; + if decrypted.unreadable > 0 { + log::error!( + "Damaged sealed files dropped on password removal profile={id} count={}", + decrypted.unreadable + ); + } let backup = encrypted_dir.with_extension("encrypted-backup"); if backup.exists() { @@ -546,105 +606,272 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re Ok(()) } -// ---------- helpers used by browser_runner ---------- +// ---------- the decrypted copy a launch runs on ---------- +// +// A launch decrypts the sealed profile into a copy the browser runs on, and the +// quit seals the copy's changes back. The copy holds `BASELINE_FILE_NAME`, the +// stamps of its files as of the last time the sealed copy matched it, from the +// moment it is complete until just before it is erased. So a copy with a +// baseline is a whole profile whose newer files still need sealing, and a copy +// without one is debris from an interrupted decrypt or erase. -/// Capture a per-file mtime snapshot of the given decrypted dir. -fn snapshot_mtimes(plaintext_dir: &Path) -> HashMap { - let mut out: HashMap = HashMap::new(); - fn walk( - base: &Path, - current: &Path, - out: &mut HashMap, - ) -> std::io::Result<()> { - for entry in std::fs::read_dir(current)? { - let entry = entry?; - let path = entry.path(); - let meta = entry.metadata()?; - if meta.is_dir() { - walk(base, &path, out)?; - } else if meta.is_file() { - let rel = path - .strip_prefix(base) - .map(|p| p.to_string_lossy().replace('\\', "/")) - .unwrap_or_default(); - if let Ok(m) = meta.modified() { - out.insert(rel, m); - } - } - } - Ok(()) - } - let _ = walk(plaintext_dir, plaintext_dir, &mut out); - out +/// What `BASELINE_FILE_NAME` holds. +#[derive(serde::Serialize, serde::Deserialize)] +struct CopyRecord { + files: Baseline, + /// The sealed copy's stamp when it last matched this copy. + sealed: Option, } -/// Decrypt a password-protected profile's encrypted dir into an ephemeral -/// dir, take a mtime snapshot for diff-on-quit, and return the ephemeral -/// path the browser should launch from. +/// How long a seal waits for the browser's processes to let go of the copy. +const RELEASE_WAIT: Duration = Duration::from_secs(10); +/// How many times a failed seal is retried, a little later each time. +const SEAL_ATTEMPTS: u32 = 3; + +fn read_record(dir: &Path) -> Option { + let bytes = std::fs::read(dir.join(BASELINE_FILE_NAME)).ok()?; + serde_json::from_slice(&bytes).ok() +} + +/// Record that the copy in `dir` matches the sealed copy in `store` as of now. +fn write_record(dir: &Path, files: Baseline, store: &Path) -> std::io::Result<()> { + let record = CopyRecord { + files, + sealed: sealed_stamp(store), + }; + let json = serde_json::to_vec(&record).map_err(std::io::Error::other)?; + crate::profile::encryption::atomic_write(&dir.join(BASELINE_FILE_NAME), &json) +} + +/// Processes started on `dir` as their user data dir, child processes +/// included: on Windows the network service still holds the cookie database +/// for a moment after the browser process has exited. +fn processes_using(dir: &Path) -> Vec { + use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind}; + + let Ok(dir) = dir.canonicalize() else { + return Vec::new(); + }; + let system = System::new_with_specifics( + RefreshKind::nothing() + .with_processes(ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always)), + ); + system + .processes() + .iter() + .filter(|(_, process)| { + process.cmd().iter().any(|arg| { + arg + .to_str() + .and_then(|arg| arg.strip_prefix("--user-data-dir=")) + .and_then(|value| Path::new(value.trim_matches('"')).canonicalize().ok()) + .is_some_and(|value| value == dir) + }) + }) + .map(|(pid, _)| pid.as_u32()) + .collect() +} + +/// Wait until no process runs on `dir`. Returns the ones still there at the +/// limit. +fn wait_until_released(dir: &Path) -> Result<(), Vec> { + let started = Instant::now(); + loop { + let pids = processes_using(dir); + if pids.is_empty() { + return Ok(()); + } + if started.elapsed() >= RELEASE_WAIT { + return Err(pids); + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// Seal the copy's changes into `store` and record the new baseline. +fn seal(key: &[u8; 32], dir: &Path, store: &Path, baseline: &Baseline) -> Result { + let mut attempt = 1; + loop { + let sealed = reencrypt_changed_files(key, dir, store, SEALED_EXCLUDE_PATTERNS, baseline) + .map_err(|e| e.to_string()) + .and_then(|(rewrote, sealed)| { + write_record(dir, sealed, store) + .map(|()| rewrote) + .map_err(|e| e.to_string()) + }); + match sealed { + Err(_) if attempt < SEAL_ATTEMPTS => { + std::thread::sleep(Duration::from_millis(500) * attempt); + attempt += 1; + } + result => return result, + } + } +} + +/// Mark the copy as no longer a profile. Done before every erase, so an erase +/// that stops halfway leaves debris rather than something taken for one. +fn forget_record(dir: &Path) -> std::io::Result<()> { + match std::fs::remove_file(dir.join(BASELINE_FILE_NAME)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + } +} + +/// Erase the copy and forget the key. +fn erase_copy(profile_id: &uuid::Uuid, dir: &Path) { + if let Err(e) = forget_record(dir) { + log::error!( + "Decrypted copy kept, its baseline could not be removed profile={profile_id} err=\"{e}\"" + ); + return; + } + drop_cached_key(profile_id); + crate::ephemeral_dirs::remove_ephemeral_dir(&profile_id.to_string()); +} + +/// Erase a profile's decrypted copy, sealed or not, and forget its key: the +/// profile is being deleted. +pub fn discard_copy(profile_id: &uuid::Uuid) { + let lock = copy_lock(profile_id); + let _copy = lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let id = profile_id.to_string(); + if let Some(dir) = crate::ephemeral_dirs::get_ephemeral_dir(&id) { + if let Err(e) = forget_record(&dir) { + log::error!( + "Decrypted copy baseline not removed before erase profile={profile_id} err=\"{e}\"" + ); + } + } + drop_cached_key(profile_id); + crate::ephemeral_dirs::remove_ephemeral_dir(&id); +} + +/// Why a copy was not sealed. +enum SealError { + /// The profile is locked; the copy waits for the next unlock. + NoKey, + /// A browser still runs on the copy. + InUse(Vec), + Failed(String), +} + +/// Seal the profile's decrypted copy, if it has one with changes to seal, then +/// erase it unless `keep`. `Ok(None)` means there was no copy to seal: none was +/// made, or another caller already sealed and erased it. Hold the copy lock. +fn seal_copy( + profile_id: &uuid::Uuid, + store: &Path, + key: Option<&[u8; 32]>, + keep: bool, +) -> Result, SealError> { + let Some(dir) = crate::ephemeral_dirs::get_ephemeral_dir(&profile_id.to_string()) else { + return Ok(None); + }; + let Some(record) = read_record(&dir) else { + return Ok(None); + }; + let key = key.ok_or(SealError::NoKey)?; + wait_until_released(&dir).map_err(SealError::InUse)?; + let started = Instant::now(); + let rewrote = seal(key, &dir, store, &record.files).map_err(SealError::Failed)?; + log::info!( + "Profile re-encrypted profile={profile_id} changed_files={rewrote} keep_decrypted={keep} elapsed_ms={}", + started.elapsed().as_millis() + ); + if !keep { + erase_copy(profile_id, &dir); + } + Ok(Some(rewrote)) +} + +/// Make the decrypted copy a launch runs on, and return its path. +/// +/// A copy that is still there with a baseline is the newest state of the +/// profile, kept in RAM by the setting or left by a quit that could not seal +/// it: it is sealed and used as it is. Anything else is decrypted afresh. /// /// Returns an error if the profile isn't unlocked yet — the frontend should /// prompt for the password and call `unlock_profile` first. pub fn prepare_for_launch(profile: &crate::profile::BrowserProfile) -> Result { let id = profile.id; let key = get_cached_key(&id).ok_or_else(|| err_code("PROFILE_LOCKED"))?; + let store = profile_data_dir(profile); + let lock = copy_lock(&id); + let _copy = lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let id_str = id.to_string(); - let ephemeral = match crate::ephemeral_dirs::get_ephemeral_dir(&id_str) { - Some(p) => p, + let mut dir = match crate::ephemeral_dirs::get_ephemeral_dir(&id_str) { + Some(dir) => dir, None => crate::ephemeral_dirs::create_ephemeral_dir(&id_str).map_err(err_internal)?, }; - - let already_populated = POPULATED_EPHEMERAL - .lock() - .map(|g| g.contains(&id)) - .unwrap_or(false); - - let encrypted_dir = profile_data_dir(profile); - - let snapshot = if already_populated && ephemeral_has_files(&ephemeral) { - // Reusing a kept-in-RAM copy from the previous session; just snapshot. - snapshot_mtimes(&ephemeral) - } else { - // Wipe any stale contents and re-decrypt. - if let Err(e) = clear_dir_contents(&ephemeral) { - log::warn!("Stale decrypted copy clear failed profile={id} err=\"{e}\""); - } - decrypt_profile_dir(&key, &encrypted_dir, &ephemeral).map_err(|e| match e { - crate::profile::encryption::PasswordError::WrongPassword => err_code("INCORRECT_PASSWORD"), - other => err_internal(other), - })? - }; - - if let Ok(mut guard) = LAUNCH_SNAPSHOTS.lock() { - guard.insert(id, snapshot); - } - if let Ok(mut guard) = POPULATED_EPHEMERAL.lock() { - guard.insert(id); + if let Err(pids) = wait_until_released(&dir) { + log::warn!( + "Launch refused, a browser still runs on the decrypted copy profile={id} pids={pids:?}" + ); + return Err(err_code("PROFILE_RUNNING")); } - Ok(ephemeral) -} - -fn ephemeral_has_files(dir: &Path) -> bool { - std::fs::read_dir(dir) - .map(|mut iter| iter.next().is_some()) - .unwrap_or(false) -} - -fn clear_dir_contents(dir: &Path) -> std::io::Result<()> { - if !dir.exists() { - return Ok(()); - } - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - std::fs::remove_dir_all(&path)?; + if let Some(record) = read_record(&dir) { + let replaced = record.sealed != sealed_stamp(&store); + if replaced && !has_changes(&dir, SEALED_EXCLUDE_PATTERNS, &record.files).unwrap_or(true) { + // Sync brought a newer sealed profile while this copy waited, unused. + log::info!("Kept decrypted copy replaced by a newer sealed profile profile={id}"); } else { - std::fs::remove_file(&path)?; + if replaced { + log::warn!( + "Sealed profile changed while its decrypted copy had changes, the copy's files win profile={id}" + ); + } + // The copy is the newest state either way; a failed seal only means + // the quit seals more. + match seal(&key, &dir, &store, &record.files) { + Ok(0) => {} + Ok(rewrote) => { + log::info!("Unsealed profile changes re-encrypted profile={id} changed_files={rewrote}") + } + Err(e) => { + log::error!("Profile re-encryption before launch failed profile={id} err=\"{e}\"") + } + } + return Ok(dir); } } - Ok(()) + + if std::fs::read_dir(&dir).is_ok_and(|mut entries| entries.next().is_some()) { + if forget_record(&dir).is_err() || !crate::ephemeral_dirs::remove_ephemeral_dir(&id_str) { + return Err(err_internal( + "the previous decrypted copy could not be erased", + )); + } + dir = crate::ephemeral_dirs::create_ephemeral_dir(&id_str).map_err(err_internal)?; + } + + let decrypted = decrypt_profile_dir(&key, &store, &dir) + .map_err(|e| match e { + PasswordError::WrongPassword => err_code("INCORRECT_PASSWORD"), + other => err_internal(other), + }) + .and_then(|decrypted| { + write_record(&dir, decrypted.baseline, &store) + .map(|()| decrypted.unreadable) + .map_err(err_internal) + }); + let unreadable = match decrypted { + Ok(unreadable) => unreadable, + Err(e) => { + crate::ephemeral_dirs::remove_ephemeral_dir(&id_str); + return Err(e); + } + }; + if unreadable > 0 { + log::error!("Damaged sealed files skipped profile={id} count={unreadable}"); + } + Ok(dir) } fn read_keep_decrypted_setting() -> bool { @@ -654,72 +881,54 @@ fn read_keep_decrypted_setting() -> bool { .unwrap_or(false) } -/// Synchronous core of `complete_after_quit`: re-encrypts ephemeral → disk -/// and (unless `keep_decrypted` is true) drops cached key + purges ephemeral. -/// Returns the number of files re-encrypted, or `None` if there was nothing -/// to do. Public for testability. +/// Seal the copy the browser just quit, then erase it unless `keep_decrypted`. +/// Returns the number of files re-encrypted, or `None` when nothing was sealed: +/// no copy, already sealed by the other caller of the same quit, or a failure, +/// in which case the copy and the key stay for the next launch to seal. +fn seal_after_quit(profile_id: uuid::Uuid, store: &Path, keep_decrypted: bool) -> Option { + let lock = copy_lock(&profile_id); + let _copy = lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let key = get_cached_key(&profile_id); + match seal_copy(&profile_id, store, key.as_ref(), keep_decrypted) { + Ok(rewrote) => rewrote, + Err(SealError::NoKey) => { + log::warn!("Profile changes kept until the profile is unlocked profile={profile_id}"); + None + } + Err(SealError::InUse(pids)) => { + log::warn!( + "Profile not re-encrypted, a browser still runs on it profile={profile_id} pids={pids:?}" + ); + None + } + Err(SealError::Failed(e)) => { + log::error!( + "Profile re-encryption failed, decrypted copy kept profile={profile_id} err=\"{e}\"" + ); + None + } + } +} + +/// `complete_after_quit_and_wait` on the calling thread, for tests. +#[cfg(test)] pub fn complete_after_quit_blocking( profile: &crate::profile::BrowserProfile, keep_decrypted: bool, ) -> Option { - use crate::profile::encryption::reencrypt_changed_files; - - let id = profile.id; if !profile.password_protected { return None; } - - // Snapshot is an optimization (skip re-encrypting unchanged files). When - // it's missing — e.g. natural-exit detection firing twice, or status - // checker firing for a profile whose snapshot was already consumed — we - // fall back to treating every ephemeral file as new. Empty `before` - // forces all files through encrypt, which is slower but correct. - let snapshot = LAUNCH_SNAPSHOTS - .lock() - .ok() - .and_then(|mut g| g.remove(&id)) - .unwrap_or_default(); - - let id_str = id.to_string(); - let ephemeral = crate::ephemeral_dirs::get_ephemeral_dir(&id_str)?; - let encrypted = profile_data_dir(profile); - let key = get_cached_key(&id)?; - - let result = match reencrypt_changed_files( - &key, - &ephemeral, - &encrypted, - DEFAULT_EXCLUDE_PATTERNS, - &snapshot, - ) { - Ok(n) => { - log::info!( - "Profile re-encrypted profile={id} changed_files={n} keep_decrypted={keep_decrypted}" - ); - Some(n) - } - Err(e) => { - log::error!("Profile re-encryption failed profile={id} err=\"{e}\""); - None - } - }; - - if !keep_decrypted { - drop_cached_key(&id); - if let Ok(mut guard) = POPULATED_EPHEMERAL.lock() { - guard.remove(&id); - } - crate::ephemeral_dirs::remove_ephemeral_dir(&id_str); - } - - result + seal_after_quit(profile.id, &profile_data_dir(profile), keep_decrypted) } -/// Re-encrypt a password-protected profile's ephemeral dir back to the -/// on-disk encrypted dir after the browser process exits. Optionally purges -/// the ephemeral dir + cached key based on the global setting. Returns the -/// number of files re-encrypted (`None` when nothing to do or the profile -/// isn't protected). +/// Re-encrypt a password-protected profile's decrypted copy back to the +/// on-disk encrypted dir after the browser process exits. Purges the copy and +/// the cached key unless the global setting keeps them. Returns the number of +/// files re-encrypted (`None` when nothing was sealed or the profile isn't +/// protected). /// /// Callers that release a queued sync run after a browser quit MUST await /// this future — releasing sync while re-encryption is still in-flight @@ -732,9 +941,10 @@ pub async fn complete_after_quit_and_wait( return None; } let keep_decrypted = read_keep_decrypted_setting(); - let profile = profile.clone(); + let store = profile_data_dir(profile); + let profile_id = profile.id; - tokio::task::spawn_blocking(move || complete_after_quit_blocking(&profile, keep_decrypted)) + tokio::task::spawn_blocking(move || seal_after_quit(profile_id, &store, keep_decrypted)) .await .unwrap_or_else(|e| { log::error!("Profile re-encryption task panicked err=\"{e}\""); @@ -745,3 +955,7 @@ pub async fn complete_after_quit_and_wait( #[cfg(test)] #[path = "password_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "password_browser_tests.rs"] +mod browser_tests; diff --git a/src-tauri/src/profile/password_browser_tests.rs b/src-tauri/src/profile/password_browser_tests.rs new file mode 100644 index 00000000..581e6d2b --- /dev/null +++ b/src-tauri/src/profile/password_browser_tests.rs @@ -0,0 +1,215 @@ +use super::*; +use crate::cookie_manager::{chrome_decrypt, CookieManager}; +use crate::profile::BrowserProfile; +use crate::wayfern_manager::WayfernManager; +use serde_json::{json, Value}; +use std::time::Duration; + +const PASSWORD: &str = "correct horse battery"; + +fn wayfern_executable() -> PathBuf { + let dir = std::env::var("DONUT_TEST_WAYFERN_DIR") + .ok() + .filter(|dir| !dir.trim().is_empty()) + .expect("DONUT_TEST_WAYFERN_DIR is required"); + crate::browser::create_browser(crate::browser::BrowserType::Wayfern) + .get_executable_path(Path::new(&dir)) + .expect("DONUT_TEST_WAYFERN_DIR holds a Wayfern build") +} + +/// Every encrypted cookie row of the profile, and how many of them the +/// profile's `os_crypt_key` opens. +fn decryptable_cookies(user_data_dir: &Path) -> (usize, usize) { + let path = CookieManager::wayfern_cookie_path(user_data_dir); + if !path.exists() { + return (0, 0); + } + let conn = + rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .unwrap(); + let mut stmt = conn + .prepare("SELECT host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 0") + .unwrap(); + let rows: Vec<(String, Vec)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .map(Result::unwrap) + .collect(); + let Some(key) = chrome_decrypt::get_encryption_key(user_data_dir) else { + return (rows.len(), 0); + }; + let opened = rows + .iter() + .filter(|(host, value)| chrome_decrypt::decrypt(value, host, &key).is_some()) + .count(); + (rows.len(), opened) +} + +async fn browser_ws_url(port: u16) -> String { + let client = reqwest::Client::new(); + for _ in 0..150 { + if let Ok(response) = client + .get(format!("http://127.0.0.1:{port}/json/version")) + .send() + .await + { + if let Ok(version) = response.json::().await { + if let Some(url) = version["webSocketDebuggerUrl"].as_str() { + return url.to_string(); + } + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + panic!("the browser never opened its debugging port"); +} + +async fn cdp(ws_url: &str, method: &str, params: Value) -> Value { + WayfernManager::instance() + .send_cdp_command(ws_url, method, params) + .await + .unwrap_or_else(|e| panic!("{method} failed: {e}")) +} + +/// Runs the browser on `user_data_dir` the way a launch does, checks the +/// cookies earlier sessions set, sets one more and stops it the way the Stop +/// button does. Returns the names of the cookies the browser had on start. +async fn browser_session(user_data_dir: &Path, new_cookie: &str) -> Vec { + let port = WayfernManager::find_free_port().await.unwrap(); + let mut child = tokio::process::Command::new(wayfern_executable()) + .arg(format!("--remote-debugging-port={port}")) + .arg("--remote-debugging-address=127.0.0.1") + .arg(format!("--user-data-dir={}", user_data_dir.display())) + .arg("--no-first-run") + .arg("--no-default-browser-check") + .arg("--disable-background-mode") + .arg("--disable-component-update") + .arg("--use-mock-keychain") + .arg("--password-store=basic") + .arg("--headless=new") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("the browser starts"); + let pid = child.id().unwrap(); + // Reaped as soon as it exits, or the stop below sees a zombie as running. + let reaper = tokio::spawn(async move { child.wait().await }); + + let ws = browser_ws_url(port).await; + let cookies = cdp(&ws, "Storage.getCookies", json!({})).await; + let names: Vec = cookies["cookies"] + .as_array() + .unwrap() + .iter() + .filter(|c| { + c["domain"] + .as_str() + .is_some_and(|d| d.trim_start_matches('.') == "example.com") + }) + .map(|c| c["name"].as_str().unwrap().to_string()) + .collect(); + + let expires = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 86_400; + cdp( + &ws, + "Storage.setCookies", + json!({ "cookies": [{ + "name": new_cookie, + "value": format!("{new_cookie}-value"), + "domain": "example.com", + "path": "/", + "secure": true, + "expires": expires, + }]}), + ) + .await; + + // The cookie store commits every 30 s or at a clean shutdown, and a browser + // asked to close in its first seconds does not shut down. + tokio::time::sleep(Duration::from_secs(32)).await; + + WayfernManager::instance() + .stop_browser_process(pid, Some(port)) + .await; + let _ = tokio::time::timeout(Duration::from_secs(5), reaper).await; + names +} + +/// The whole life of a password-protected Wayfern profile, several times over: +/// unlock, decrypt, browse, stop, re-encrypt. Each quit is sealed by two +/// callers at once, as the Stop button and the exit watcher do. Every cookie a +/// session set must be readable by the next one. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "needs a Wayfern build in DONUT_TEST_WAYFERN_DIR"] +async fn cookies_survive_reencryption_through_a_real_browser() { + let data = tempfile::TempDir::new().unwrap(); + let ephemeral = tempfile::TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(data.path().to_path_buf()); + // Process-wide as well: re-encryption runs on a blocking-pool thread, which + // the thread-local override above does not reach. + std::env::set_var("DONUTBROWSER_DATA_DIR", data.path()); + std::env::set_var("DONUTBROWSER_CACHE_DIR", data.path().join("cache")); + std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", ephemeral.path()); + + let profile = BrowserProfile { + id: uuid::Uuid::new_v4(), + name: "reencrypt".to_string(), + browser: "wayfern".to_string(), + release_type: "stable".to_string(), + ..Default::default() + }; + ProfileManager::instance().save_profile(&profile).unwrap(); + set_profile_password(profile.id.to_string(), PASSWORD.into()) + .await + .unwrap(); + let profile = load_profile(&profile.id).unwrap(); + + let mut expected: Vec = Vec::new(); + for session in 0..5 { + if !has_cached_key(&profile.id) { + unlock_profile(profile.id.to_string(), PASSWORD.into()) + .await + .unwrap(); + } + let dir = prepare_for_launch(&profile).unwrap(); + if session == 0 { + // Bulk like a used profile has, so one seal is still running when the + // other starts. + let bulk = dir.join("Default").join("IndexedDB").join("bulk"); + std::fs::create_dir_all(&bulk).unwrap(); + for i in 0..400 { + std::fs::write(bulk.join(format!("{i:04}.ldb")), vec![7u8; 128 * 1024]).unwrap(); + } + } + let (rows, opened) = decryptable_cookies(&dir); + assert_eq!( + rows, opened, + "session {session} found undecryptable cookies" + ); + + let name = format!("c{session}"); + let mut seen = browser_session(&dir, &name).await; + seen.sort(); + assert_eq!(seen, expected, "session {session} lost cookies"); + expected.push(name); + + let (watcher, stop_button) = tokio::join!( + complete_after_quit_and_wait(&profile), + complete_after_quit_and_wait(&profile) + ); + assert!( + watcher.is_some() != stop_button.is_some(), + "session {session} must be sealed exactly once: {watcher:?} {stop_button:?}" + ); + } + + std::env::remove_var("DONUTBROWSER_EPHEMERAL_ROOT"); + std::env::remove_var("DONUTBROWSER_CACHE_DIR"); + std::env::remove_var("DONUTBROWSER_DATA_DIR"); +} diff --git a/src-tauri/src/profile/password_tests.rs b/src-tauri/src/profile/password_tests.rs index bc336a38..28d230e2 100644 --- a/src-tauri/src/profile/password_tests.rs +++ b/src-tauri/src/profile/password_tests.rs @@ -49,8 +49,6 @@ fn parse_err_param(err: &str, key: &str) -> Option { fn fresh_test_state(id: &uuid::Uuid) { drop_cached_key(id); - let _ = LAUNCH_SNAPSHOTS.lock().map(|mut g| g.remove(id)); - let _ = POPULATED_EPHEMERAL.lock().map(|mut g| g.remove(id)); crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string()); } @@ -58,6 +56,81 @@ fn profile_full_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf { profiles_dir.join(profile.id.to_string()).join("profile") } +/// Scratch data and decrypted-copy directories, seen by this thread and by +/// the threads a seal runs on. The thread-local override alone would send a +/// seal on another thread to the real data directory. +struct Scratch { + _data: TempDir, + _ephemeral: TempDir, + _guard: crate::app_dirs::TestDirGuard, +} + +impl Scratch { + fn new() -> Self { + let data = TempDir::new().unwrap(); + let ephemeral = TempDir::new().unwrap(); + std::env::set_var("DONUTBROWSER_DATA_DIR", data.path()); + std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", ephemeral.path()); + let guard = crate::app_dirs::set_test_data_dir(data.path().to_path_buf()); + Self { + _data: data, + _ephemeral: ephemeral, + _guard: guard, + } + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + std::env::remove_var("DONUTBROWSER_EPHEMERAL_ROOT"); + std::env::remove_var("DONUTBROWSER_DATA_DIR"); + } +} + +const PASSWORD: &str = "hunter2!"; + +/// A protected profile with `populate_plaintext_dir` content, unlocked. +fn protected_profile(name: &str) -> (BrowserProfile, PathBuf) { + let profile = make_profile(name); + let store = profile_full_path(&profile, &ProfileManager::instance().get_profiles_dir()); + populate_plaintext_dir(&store); + ProfileManager::instance().save_profile(&profile).unwrap(); + fresh_test_state(&profile.id); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(set_profile_password( + profile.id.to_string(), + PASSWORD.into(), + )) + .unwrap(); + (load_profile(&profile.id).unwrap(), store) +} + +/// Write as a browser does, which is never in the same file-time tick as the +/// decrypt before it (on NTFS a tick is about 15 ms). +fn browser_writes(path: &Path, contents: impl AsRef<[u8]>) { + std::thread::sleep(Duration::from_millis(50)); + std::fs::write(path, contents).unwrap(); +} + +/// What the sealed copy decrypts to, by relpath. +fn sealed_files(profile: &BrowserProfile, store: &Path) -> HashMap> { + let key = derive_profile_key(PASSWORD, profile.encryption_salt.as_deref().unwrap()).unwrap(); + let out = TempDir::new().unwrap(); + let decrypted = crate::profile::encryption::decrypt_profile_dir(&key, store, out.path()).unwrap(); + assert_eq!(decrypted.unreadable, 0, "the sealed copy has damaged files"); + decrypted + .baseline + .keys() + .map(|relpath| { + ( + relpath.clone(), + std::fs::read(out.path().join(relpath)).unwrap(), + ) + }) + .collect() +} + #[test] #[serial_test::serial] fn integration_set_password_encrypts_dir() { @@ -376,9 +449,14 @@ fn integration_empty_profile_session_survives_restart() { // Launch — ephemeral starts empty (only the verifier in encrypted, which is skipped) let ephemeral = prepare_for_launch(&profile).unwrap(); - assert!( - std::fs::read_dir(&ephemeral).unwrap().next().is_none(), - "ephemeral should start empty for a fresh encrypted profile" + let names: Vec<_> = std::fs::read_dir(&ephemeral) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!( + names, + [crate::profile::encryption::BASELINE_FILE_NAME], + "ephemeral should hold only its baseline for a fresh encrypted profile" ); // Simulate the browser writing a session @@ -622,3 +700,297 @@ fn integration_lock_drops_key() { fresh_test_state(&profile.id); } + +#[test] +#[serial_test::serial] +fn a_quit_both_callers_seal_is_sealed_once_and_intact() { + let _scratch = Scratch::new(); + let (profile, store) = protected_profile("test-race"); + + for round in 0..5u8 { + if !has_cached_key(&profile.id) { + tokio::runtime::Runtime::new() + .unwrap() + .block_on(unlock_profile(profile.id.to_string(), PASSWORD.into())) + .unwrap(); + } + let copy = prepare_for_launch(&profile).unwrap(); + // Enough files that one seal is still running when the other starts. + std::fs::create_dir_all(copy.join("Default/IndexedDB")).unwrap(); + browser_writes(©.join("Default/Cookies"), [round + 1; 64]); + for i in 0..300 { + let file = copy.join("Default/IndexedDB").join(format!("{i:04}.ldb")); + std::fs::write(&file, vec![round + 1; 16 * 1024]).unwrap(); + } + + let callers: Vec<_> = (0..2) + .map(|_| { + let store = store.clone(); + std::thread::spawn(move || seal_after_quit(profile.id, &store, false)) + }) + .collect(); + let results: Vec> = callers.into_iter().map(|c| c.join().unwrap()).collect(); + + assert_eq!( + results.iter().filter(|r| r.is_some()).count(), + 1, + "exactly one caller seals, got {results:?}" + ); + assert!(crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none()); + assert!(!has_cached_key(&profile.id)); + let sealed = sealed_files(&profile, &store); + assert_eq!(sealed["Default/Cookies"], vec![round + 1; 64]); + for i in 0..300 { + assert_eq!( + sealed[&format!("Default/IndexedDB/{i:04}.ldb")], + vec![round + 1; 16 * 1024], + "round {round} file {i}" + ); + } + } + fresh_test_state(&profile.id); +} + +#[cfg(unix)] +#[test] +#[serial_test::serial] +fn a_seal_that_fails_keeps_the_copy_for_the_next_launch_to_seal() { + use std::os::unix::fs::PermissionsExt; + let _scratch = Scratch::new(); + let (profile, store) = protected_profile("test-failed-seal"); + + let copy = prepare_for_launch(&profile).unwrap(); + browser_writes(©.join("Default/Cookies"), b"logged-in"); + + std::fs::set_permissions(&store, std::fs::Permissions::from_mode(0o555)).unwrap(); + let sealed = complete_after_quit_blocking(&profile, false); + std::fs::set_permissions(&store, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!(sealed, None); + assert!(has_cached_key(&profile.id), "the key stays to seal later"); + assert_eq!( + std::fs::read(copy.join("Default/Cookies")).unwrap(), + b"logged-in", + "the only copy of the session must survive a failed seal" + ); + assert_eq!( + sealed_files(&profile, &store)["Default/Cookies"], + b"sqlite-data" + ); + + let relaunched = prepare_for_launch(&profile).unwrap(); + assert_eq!(relaunched, copy); + assert_eq!( + std::fs::read(relaunched.join("Default/Cookies")).unwrap(), + b"logged-in" + ); + assert_eq!( + sealed_files(&profile, &store)["Default/Cookies"], + b"logged-in" + ); + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn changes_from_a_quit_without_the_key_are_sealed_after_unlock() { + let _scratch = Scratch::new(); + let (profile, store) = protected_profile("test-no-key"); + + let copy = prepare_for_launch(&profile).unwrap(); + browser_writes(©.join("Default/Cookies"), b"logged-in"); + // The app restarted while the browser ran: the copy is there, the key is not. + drop_cached_key(&profile.id); + + assert_eq!(complete_after_quit_blocking(&profile, false), None); + assert_eq!( + std::fs::read(copy.join("Default/Cookies")).unwrap(), + b"logged-in" + ); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(unlock_profile(profile.id.to_string(), PASSWORD.into())) + .unwrap(); + let relaunched = prepare_for_launch(&profile).unwrap(); + assert_eq!( + std::fs::read(relaunched.join("Default/Cookies")).unwrap(), + b"logged-in" + ); + assert_eq!( + sealed_files(&profile, &store)["Default/Cookies"], + b"logged-in" + ); + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn debris_of_an_interrupted_erase_is_not_launched_on() { + let _scratch = Scratch::new(); + let (profile, _store) = protected_profile("test-debris"); + + let copy = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string()).unwrap(); + std::fs::create_dir_all(copy.join("Default")).unwrap(); + // A hot journal next to a freshly decrypted database would roll it back. + std::fs::write(copy.join("Default/Cookies-journal"), [0u8; 512]).unwrap(); + std::fs::write(copy.join("Default/Cookies"), [0u8; 512]).unwrap(); + + let launched = prepare_for_launch(&profile).unwrap(); + assert!(!launched.join("Default/Cookies-journal").exists()); + assert_eq!( + std::fs::read(launched.join("Default/Cookies")).unwrap(), + b"sqlite-data" + ); + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn a_launch_stopped_before_its_quit_was_sealed_keeps_its_changes() { + let _scratch = Scratch::new(); + let (profile, store) = protected_profile("test-relaunch"); + + // A launch whose browser wrote and was then killed, with no quit sealed. + let copy = prepare_for_launch(&profile).unwrap(); + browser_writes(©.join("os_crypt_key"), [7u8; 32]); + browser_writes(©.join("Default/Cookies"), b"under-key-7"); + + // The retry reuses the copy and must not take those files as sealed. + let retry = prepare_for_launch(&profile).unwrap(); + assert_eq!(retry, copy); + assert_eq!(complete_after_quit_blocking(&profile, false), Some(0)); + + let sealed = sealed_files(&profile, &store); + assert_eq!(sealed["os_crypt_key"], [7u8; 32]); + assert_eq!(sealed["Default/Cookies"], b"under-key-7"); + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn lock_and_password_removal_seal_a_pending_copy_first() { + let _scratch = Scratch::new(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let (profile, store) = protected_profile("test-pending"); + + let copy = prepare_for_launch(&profile).unwrap(); + browser_writes(©.join("Default/Cookies"), b"before-lock"); + rt.block_on(lock_profile(profile.id.to_string())).unwrap(); + assert!(!has_cached_key(&profile.id)); + assert!(crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none()); + assert_eq!( + sealed_files(&profile, &store)["Default/Cookies"], + b"before-lock" + ); + + rt.block_on(unlock_profile(profile.id.to_string(), PASSWORD.into())) + .unwrap(); + let copy = prepare_for_launch(&profile).unwrap(); + browser_writes(©.join("Default/Cookies"), b"before-removal"); + rt.block_on(remove_profile_password( + profile.id.to_string(), + PASSWORD.into(), + )) + .unwrap(); + assert!(crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none()); + assert!(!copy.exists(), "no plaintext copy may outlive the password"); + assert_eq!( + std::fs::read(store.join("Default/Cookies")).unwrap(), + b"before-removal" + ); + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn a_password_change_seals_a_pending_copy_with_the_old_key() { + let _scratch = Scratch::new(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let (profile, store) = protected_profile("test-change-pending"); + + let copy = prepare_for_launch(&profile).unwrap(); + browser_writes(©.join("Default/Cookies"), b"pending"); + rt.block_on(change_profile_password( + profile.id.to_string(), + PASSWORD.into(), + "another password".into(), + )) + .unwrap(); + + let profile = load_profile(&profile.id).unwrap(); + let key = derive_profile_key( + "another password", + profile.encryption_salt.as_deref().unwrap(), + ) + .unwrap(); + let out = TempDir::new().unwrap(); + crate::profile::encryption::decrypt_profile_dir(&key, &store, out.path()).unwrap(); + assert_eq!( + std::fs::read(out.path().join("Default/Cookies")).unwrap(), + b"pending" + ); + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn the_sealed_copy_keeps_database_journals_and_drops_caches() { + let _scratch = Scratch::new(); + let (profile, store) = protected_profile("test-journals"); + + let copy = prepare_for_launch(&profile).unwrap(); + browser_writes(©.join("Default/Cookies-journal"), b"hot-journal"); + browser_writes(©.join("Default/History-wal"), b"wal-frames"); + browser_writes(©.join("Default/Secure Preferences"), b"{}"); + std::fs::create_dir_all(copy.join("Default/Code Cache/js")).unwrap(); + browser_writes(©.join("Default/Code Cache/js/index"), b"cache"); + complete_after_quit_blocking(&profile, false).unwrap(); + + let sealed = sealed_files(&profile, &store); + assert_eq!(sealed["Default/Cookies-journal"], b"hot-journal"); + assert_eq!(sealed["Default/History-wal"], b"wal-frames"); + assert_eq!(sealed["Default/Secure Preferences"], b"{}"); + assert!(!sealed.contains_key("Default/Code Cache/js/index")); + assert!(!sealed.contains_key(crate::profile::encryption::BASELINE_FILE_NAME)); + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn a_kept_copy_gives_way_to_a_newer_sealed_profile_from_sync() { + let _scratch = Scratch::new(); + let (profile, store) = protected_profile("test-kept-sync"); + + let copy = prepare_for_launch(&profile).unwrap(); + complete_after_quit_blocking(&profile, true).unwrap(); + assert!(copy.join("Default/Cookies").exists(), "the copy is kept"); + + // Another device's session arrives through sync. + let key = get_cached_key(&profile.id).unwrap(); + let elsewhere = TempDir::new().unwrap(); + crate::profile::encryption::decrypt_profile_dir(&key, &store, elsewhere.path()).unwrap(); + browser_writes( + &elsewhere.path().join("Default/Cookies"), + b"from-the-other-device", + ); + crate::profile::encryption::encrypt_profile_dir( + &key, + elsewhere.path(), + &store, + crate::profile::encryption::SEALED_EXCLUDE_PATTERNS, + ) + .unwrap(); + + let relaunched = prepare_for_launch(&profile).unwrap(); + assert_eq!( + std::fs::read(relaunched.join("Default/Cookies")).unwrap(), + b"from-the-other-device" + ); + complete_after_quit_blocking(&profile, false).unwrap(); + assert_eq!( + sealed_files(&profile, &store)["Default/Cookies"], + b"from-the-other-device" + ); + fresh_test_state(&profile.id); +}