mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-14 13:49:02 +02:00
feat: add tips
This commit is contained in:
+14
-7
@@ -37,13 +37,20 @@ fn main() {
|
||||
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
|
||||
}
|
||||
|
||||
// Inject vault password at build time
|
||||
if let Ok(vault_password) = std::env::var("DONUT_BROWSER_VAULT_PASSWORD") {
|
||||
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD={vault_password}");
|
||||
} else {
|
||||
// Use default password if environment variable is not set
|
||||
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD=donutbrowser-api-vault-password");
|
||||
}
|
||||
// The sealing password of every build before the per-install vault key.
|
||||
// Still compiled in so an update can open the files those builds sealed
|
||||
// and re-seal them under the installation's own key (see `src/vault.rs`).
|
||||
// It reaches the crate through a file in OUT_DIR rather than a rustc-env
|
||||
// line, so the build log never carries it.
|
||||
let legacy_vault_password = std::env::var("DONUT_BROWSER_VAULT_PASSWORD")
|
||||
.unwrap_or_else(|_| "donutbrowser-api-vault-password".to_string());
|
||||
let out_dir = std::env::var("OUT_DIR").expect("cargo sets OUT_DIR for build scripts");
|
||||
std::fs::write(
|
||||
std::path::Path::new(&out_dir).join("legacy_vault_password.txt"),
|
||||
legacy_vault_password,
|
||||
)
|
||||
.expect("write the legacy vault password for include_str!");
|
||||
println!("cargo:rerun-if-env-changed=DONUT_BROWSER_VAULT_PASSWORD");
|
||||
|
||||
// Tell Cargo to rebuild if the proxy binary source changes
|
||||
println!("cargo:rerun-if-changed=src/bin/proxy_server.rs");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::browser::ProxySettings;
|
||||
use crate::events;
|
||||
use crate::group_manager::GROUP_MANAGER;
|
||||
use crate::log_redaction::ShortId;
|
||||
use crate::profile::manager::ProfileManager;
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::tag_manager::TAG_MANAGER;
|
||||
@@ -2542,7 +2543,8 @@ fn resolve_extension_source(
|
||||
Ok(Some(ExtensionSource::Upload { file_name, data }))
|
||||
}
|
||||
(None, Some(path)) => Ok(Some(ExtensionSource::LocalPath {
|
||||
path: std::path::PathBuf::from(path),
|
||||
path: crate::extension_manager::client_named_path(&path)
|
||||
.map_err(|_| extension_request_error("EXTENSION_PATH_INVALID"))?,
|
||||
link,
|
||||
})),
|
||||
(None, None) => Ok(None),
|
||||
@@ -3472,7 +3474,10 @@ async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_ta
|
||||
() = to_relay => {}
|
||||
() = to_client => {}
|
||||
}
|
||||
log::info!("CDP proxy for remote session {session_id} closed");
|
||||
log::info!(
|
||||
"CDP proxy for remote session {} closed",
|
||||
ShortId(&session_id)
|
||||
);
|
||||
}
|
||||
|
||||
// API Handler - Every remote session this account currently owns
|
||||
@@ -4159,7 +4164,7 @@ async fn batch_run_profiles(
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut results = Vec::with_capacity(request.profile_ids.len());
|
||||
let mut results = Vec::new();
|
||||
for profile_id in &request.profile_ids {
|
||||
let fail = |error: &str| BatchRunResult {
|
||||
profile_id: profile_id.clone(),
|
||||
@@ -4285,7 +4290,7 @@ async fn batch_stop_profiles(
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
let mut results = Vec::with_capacity(request.profile_ids.len());
|
||||
let mut results = Vec::new();
|
||||
for profile_id in &request.profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
results.push(BatchStopResult {
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::browser::ProxySettings;
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::events;
|
||||
use crate::log_redaction::ShortId;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
|
||||
@@ -1076,7 +1077,8 @@ impl BrowserRunner {
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Stopping remote session {session_id} for profile {} ({profile_id})",
|
||||
"Stopping remote session {} for profile {} ({profile_id})",
|
||||
ShortId(&session_id),
|
||||
profile.name
|
||||
);
|
||||
crate::remote_session::end_remote_session(&session_id)
|
||||
@@ -1085,7 +1087,10 @@ impl BrowserRunner {
|
||||
// Surfaced rather than swallowed. A failure here means the browser is
|
||||
// STILL RUNNING; reporting success would tell the user their profile is
|
||||
// free when a remote host is still writing to it.
|
||||
log::warn!("Failed to stop remote session {session_id}: {e}");
|
||||
log::warn!(
|
||||
"Failed to stop remote session {}: {e}",
|
||||
ShortId(&session_id)
|
||||
);
|
||||
e.to_error_json().into()
|
||||
})?;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! never holds any credential or hostname belonging to the machine the browser
|
||||
//! runs on. That boundary is why this is a relay and not a direct connection.
|
||||
|
||||
use crate::log_redaction::ShortId;
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
@@ -88,7 +89,7 @@ impl CdpTarget {
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Self::Local { .. } => "local browser".to_string(),
|
||||
Self::Remote { session_id, .. } => format!("remote session {session_id}"),
|
||||
Self::Remote { session_id, .. } => format!("remote session {}", ShortId(session_id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +213,7 @@ pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError
|
||||
log::info!(
|
||||
"Driving profile '{}' through remote session {}",
|
||||
profile.name,
|
||||
session.session_id
|
||||
ShortId(&session.session_id)
|
||||
);
|
||||
return Ok(CdpTarget::Remote {
|
||||
ws_url: endpoint.ws_url,
|
||||
@@ -720,7 +721,10 @@ impl CdpTarget {
|
||||
} => {
|
||||
let mut connection = dial_relay(ws_url, bearer).await?;
|
||||
if let Err(e) = connection.attach_to_page().await {
|
||||
log::warn!("Could not attach to a page in remote session {session_id}: {e}");
|
||||
log::warn!(
|
||||
"Could not attach to a page in remote session {}: {e}",
|
||||
ShortId(session_id)
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(connection)
|
||||
|
||||
+12
-110
@@ -1,15 +1,10 @@
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Key, Nonce,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use lazy_static::lazy_static;
|
||||
use rand::RngExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::browser::ProxySettings;
|
||||
@@ -378,114 +373,21 @@ impl CloudAuthManager {
|
||||
SettingsManager::instance().get_settings_dir()
|
||||
}
|
||||
|
||||
fn get_vault_password() -> String {
|
||||
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
|
||||
// --- Encrypted file storage (shared with settings_manager.rs via crate::vault) ---
|
||||
|
||||
fn magic(header: &[u8; 5]) -> [u8; 6] {
|
||||
let mut magic = [0u8; 6];
|
||||
magic[..5].copy_from_slice(header);
|
||||
magic[5] = 2;
|
||||
magic
|
||||
}
|
||||
|
||||
// --- Encrypted file storage (same pattern as settings_manager.rs) ---
|
||||
|
||||
fn encrypt_and_store(file_path: &PathBuf, header: &[u8; 5], data: &str) -> Result<(), String> {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
}
|
||||
|
||||
let vault_password = Self::get_vault_password();
|
||||
let salt_bytes: [u8; 16] = rand::rng().random();
|
||||
let salt = crate::sync::encryption::encode_salt(&salt_bytes);
|
||||
let key_bytes =
|
||||
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce_bytes: [u8; 12] = rand::rng().random();
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, data.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {e}"))?;
|
||||
|
||||
let mut file_data = Vec::new();
|
||||
file_data.extend_from_slice(header);
|
||||
file_data.push(2u8);
|
||||
let salt_str = salt.as_str();
|
||||
file_data.push(salt_str.len() as u8);
|
||||
file_data.extend_from_slice(salt_str.as_bytes());
|
||||
file_data.extend_from_slice(&nonce);
|
||||
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
|
||||
file_data.extend_from_slice(&ciphertext);
|
||||
|
||||
fs::write(file_path, file_data).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
crate::app_dirs::restrict_to_owner(file_path);
|
||||
Ok(())
|
||||
fn encrypt_and_store(file_path: &Path, header: &[u8; 5], data: &str) -> Result<(), String> {
|
||||
crate::vault::seal(file_path, &Self::magic(header), data)
|
||||
}
|
||||
|
||||
fn decrypt_from_file(file_path: &PathBuf, header: &[u8; 5]) -> Result<Option<String>, String> {
|
||||
if !file_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file_data = fs::read(file_path).map_err(|e| format!("Failed to read file: {e}"))?;
|
||||
|
||||
if file_data.len() < 6 || &file_data[0..5] != header {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let version = file_data[5];
|
||||
if version != 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut offset = 6;
|
||||
if offset >= file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_len = file_data[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
if offset + salt_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_bytes = &file_data[offset..offset + salt_len];
|
||||
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
|
||||
let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?;
|
||||
offset += salt_len;
|
||||
|
||||
if offset + 12 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid nonce length".to_string())?;
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
offset += 12;
|
||||
|
||||
if offset + 4 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext_len = u32::from_le_bytes([
|
||||
file_data[offset],
|
||||
file_data[offset + 1],
|
||||
file_data[offset + 2],
|
||||
file_data[offset + 3],
|
||||
]) as usize;
|
||||
offset += 4;
|
||||
|
||||
if offset + ciphertext_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext = &file_data[offset..offset + ciphertext_len];
|
||||
|
||||
let vault_password = Self::get_vault_password();
|
||||
let key_bytes =
|
||||
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let plaintext = cipher
|
||||
.decrypt(&nonce, ciphertext)
|
||||
.map_err(|_| "Decryption failed".to_string())?;
|
||||
|
||||
match String::from_utf8(plaintext) {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
fn decrypt_from_file(file_path: &Path, header: &[u8; 5]) -> Result<Option<String>, String> {
|
||||
crate::vault::open(file_path, &Self::magic(header))
|
||||
}
|
||||
|
||||
// --- Token storage methods ---
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::api_client::ApiClient;
|
||||
use crate::browser::{create_browser, BrowserType};
|
||||
use crate::browser_version_manager::DownloadInfo;
|
||||
use crate::events;
|
||||
use crate::log_redaction::Plain;
|
||||
|
||||
// Maximum time to wait for the next chunk of a streaming download before treating
|
||||
// the connection as stalled. Converts an indefinite hang into a terminal error so
|
||||
@@ -705,7 +706,11 @@ impl Downloader {
|
||||
return Ok(version);
|
||||
} else {
|
||||
// Registry says it's downloaded but files don't exist - clean up registry
|
||||
log::info!("Registry indicates {browser_str} {version} is downloaded, but files are missing. Cleaning up registry entry.");
|
||||
log::info!(
|
||||
"Registry indicates {} {} is downloaded, but files are missing. Cleaning up registry entry.",
|
||||
Plain(&browser_str),
|
||||
Plain(&version)
|
||||
);
|
||||
self.registry.remove_browser(&browser_str, &version);
|
||||
self
|
||||
.registry
|
||||
@@ -811,7 +816,11 @@ impl Downloader {
|
||||
// Do not remove the archive here. We keep it until verification succeeds.
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Extraction failed for {browser_str} {version}: {e}");
|
||||
log::error!(
|
||||
"Extraction failed for {} {}: {e}",
|
||||
Plain(&browser_str),
|
||||
Plain(&version)
|
||||
);
|
||||
|
||||
// Delete the corrupt/invalid archive so a fresh download happens next time
|
||||
if download_path.exists() {
|
||||
@@ -857,7 +866,11 @@ impl Downloader {
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Verify the browser was downloaded correctly
|
||||
log::info!("Verifying download for browser: {browser_str}, version: {version}");
|
||||
log::info!(
|
||||
"Verifying download for browser: {}, version: {}",
|
||||
Plain(&browser_str),
|
||||
Plain(&version)
|
||||
);
|
||||
|
||||
// Use the browser's own verification method
|
||||
if !browser.is_version_downloaded(&version, &binaries_dir) {
|
||||
@@ -912,7 +925,11 @@ impl Downloader {
|
||||
.registry
|
||||
.mark_download_completed(&browser_str, &version, browser_dir.clone())
|
||||
{
|
||||
log::warn!("Warning: Could not mark {browser_str} {version} as completed in registry: {e}");
|
||||
log::warn!(
|
||||
"Warning: Could not mark {} {} as completed in registry: {e}",
|
||||
Plain(&browser_str),
|
||||
Plain(&version)
|
||||
);
|
||||
}
|
||||
self
|
||||
.registry
|
||||
|
||||
@@ -405,6 +405,19 @@ fn err_code(code: &str) -> Box<dyn std::error::Error> {
|
||||
serde_json::json!({ "code": code }).to_string().into()
|
||||
}
|
||||
|
||||
/// A filesystem path named by an automation client (REST or MCP).
|
||||
///
|
||||
/// Automation loads an extension from whatever folder or archive the caller
|
||||
/// names, so the location is the caller's to choose. What a request may not
|
||||
/// do is climb: a `..` component is refused before the path is touched, and
|
||||
/// the path is then used exactly as given.
|
||||
pub fn client_named_path(raw: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
if raw.contains("..") {
|
||||
return Err(err_code("EXTENSION_PATH_INVALID"));
|
||||
}
|
||||
Ok(PathBuf::from(raw))
|
||||
}
|
||||
|
||||
/// Validate that `dir` is a loadable unpacked extension and return its parsed
|
||||
/// manifest.
|
||||
fn validate_unpacked_dir(dir: &Path) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -121,6 +121,7 @@ mod mcp_remote;
|
||||
mod mcp_server;
|
||||
mod tag_manager;
|
||||
mod team_lock;
|
||||
mod vault;
|
||||
mod version_updater;
|
||||
pub mod vpn;
|
||||
mod vpn_extension_detect;
|
||||
@@ -167,8 +168,9 @@ use downloader::{cancel_download, download_browser};
|
||||
use settings_manager::{
|
||||
complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed,
|
||||
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
|
||||
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
|
||||
save_sync_settings, save_table_sorting_settings,
|
||||
get_tips_state, get_window_resize_warning_dismissed, mark_tip_seen, observe_cloud_plan,
|
||||
open_log_directory, read_log_files, save_app_settings, save_sync_settings,
|
||||
save_table_sorting_settings, set_tips_auto_show,
|
||||
};
|
||||
|
||||
use sync::{
|
||||
@@ -3480,6 +3482,10 @@ pub fn run_with_builder(
|
||||
get_window_resize_warning_dismissed,
|
||||
get_onboarding_completed,
|
||||
complete_onboarding,
|
||||
get_tips_state,
|
||||
mark_tip_seen,
|
||||
set_tips_auto_show,
|
||||
observe_cloud_plan,
|
||||
data_root::get_data_root_info,
|
||||
data_root::move_data_root,
|
||||
data_root::clear_data_root_choice,
|
||||
|
||||
@@ -39,6 +39,43 @@ static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
.expect("valid UUID regex")
|
||||
});
|
||||
|
||||
/// A caller-supplied string as it may appear in a log line: control
|
||||
/// characters, a newline above all, are shown escaped, so no request can
|
||||
/// forge a second log entry or hide the end of the real one.
|
||||
pub struct Plain<'a>(pub &'a str);
|
||||
|
||||
impl std::fmt::Display for Plain<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
use std::fmt::Write;
|
||||
for c in self.0.chars() {
|
||||
if c.is_control() {
|
||||
for escaped in c.escape_default() {
|
||||
f.write_char(escaped)?;
|
||||
}
|
||||
} else {
|
||||
f.write_char(c)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The first characters of an identifier: enough to match log lines up by
|
||||
/// eye, and not the whole value, which for a session is a bearer of sorts.
|
||||
pub struct ShortId<'a>(pub &'a str);
|
||||
|
||||
impl std::fmt::Display for ShortId<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
const SHOWN: usize = 8;
|
||||
let shown: String = Plain(self.0).to_string().chars().take(SHOWN).collect();
|
||||
f.write_str(&shown)?;
|
||||
if self.0.chars().count() > SHOWN {
|
||||
f.write_str("\u{2026}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url_label(value: &str) -> String {
|
||||
url::Url::parse(value)
|
||||
.map(|parsed| format!("{}://<redacted>", parsed.scheme()))
|
||||
@@ -66,6 +103,22 @@ pub fn text(value: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plain_escapes_every_control_character() {
|
||||
assert_eq!(Plain("wayfern").to_string(), "wayfern");
|
||||
assert_eq!(
|
||||
Plain("1.0\nINFO forged line\r\t").to_string(),
|
||||
"1.0\\nINFO forged line\\r\\t"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_id_keeps_a_prefix_and_marks_the_cut() {
|
||||
assert_eq!(ShortId("abcdef").to_string(), "abcdef");
|
||||
assert_eq!(ShortId("0123456789abcdef").to_string(), "01234567\u{2026}");
|
||||
assert_eq!(ShortId("ab\ncd").to_string(), "ab\\ncd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_sensitive_log_content() {
|
||||
let input = format!(
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::browser::ProxySettings;
|
||||
use crate::cdp_target::{CdpError, CdpTarget};
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::group_manager::GROUP_MANAGER;
|
||||
use crate::log_redaction::ShortId;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::settings_manager::SettingsManager;
|
||||
@@ -2479,7 +2480,7 @@ impl McpServer {
|
||||
let mut inner = self.inner.lock().await;
|
||||
match inner.sessions.remove(session_id) {
|
||||
Some(session) => {
|
||||
log::info!("[mcp] Session terminated: {session_id}");
|
||||
log::info!("[mcp] Session terminated: {}", ShortId(session_id));
|
||||
session.cached_pages
|
||||
}
|
||||
None => return,
|
||||
@@ -2501,8 +2502,9 @@ impl McpServer {
|
||||
.is_err()
|
||||
{
|
||||
log::debug!(
|
||||
"[mcp] Session {session_id} ended before its element caches could be cleared; the \
|
||||
page-side slot cap will reclaim them"
|
||||
"[mcp] Session {} ended before its element caches could be cleared; the \
|
||||
page-side slot cap will reclaim them",
|
||||
ShortId(session_id)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4606,7 +4608,7 @@ impl McpServer {
|
||||
"instructions": "Donut Browser MCP server. Use tools/list to discover available browser automation tools."
|
||||
});
|
||||
|
||||
log::info!("[mcp] New session initialized: {}", session_id);
|
||||
log::info!("[mcp] New session initialized: {}", ShortId(&session_id));
|
||||
Ok((session_id, (id, result)))
|
||||
}
|
||||
|
||||
@@ -7605,9 +7607,14 @@ impl McpServer {
|
||||
.get("link")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let path = crate::extension_manager::client_named_path(path).map_err(|e| McpError {
|
||||
code: -32602,
|
||||
message: format!("Invalid path: {e}"),
|
||||
data: None,
|
||||
})?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let extension = mgr
|
||||
.add_extension_from_path(name, std::path::Path::new(path), link)
|
||||
.add_extension_from_path(name, &path, link)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to add extension: {e}"),
|
||||
@@ -7651,11 +7658,18 @@ impl McpServer {
|
||||
.get("link")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let path = path
|
||||
.map(|path| {
|
||||
crate::extension_manager::client_named_path(path).map_err(|e| McpError {
|
||||
code: -32602,
|
||||
message: format!("Invalid path: {e}"),
|
||||
data: None,
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let extension = match path {
|
||||
Some(path) => {
|
||||
mgr.update_extension_from_path(extension_id, name, std::path::Path::new(path), link)
|
||||
}
|
||||
Some(path) => mgr.update_extension_from_path(extension_id, name, &path, link),
|
||||
None => mgr.update_extension(extension_id, name, None, None),
|
||||
}
|
||||
.map_err(|e| McpError {
|
||||
|
||||
@@ -407,296 +407,5 @@ pub fn fresh_salt() -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_key() -> [u8; 32] {
|
||||
derive_profile_key("hunter2", &generate_salt()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_filename_deterministic() {
|
||||
let key = [7u8; 32];
|
||||
let a = hmac_filename(&key, "Default/Cookies");
|
||||
let b = hmac_filename(&key, "Default/Cookies");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.len(), HMAC_FILENAME_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_filename_different_keys() {
|
||||
let a = hmac_filename(&[1u8; 32], "Default/Cookies");
|
||||
let b = hmac_filename(&[2u8; 32], "Default/Cookies");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_filename_different_paths() {
|
||||
let key = [1u8; 32];
|
||||
let a = hmac_filename(&key, "Default/Cookies");
|
||||
let b = hmac_filename(&key, "Default/Login Data");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_roundtrip() {
|
||||
let key = make_key();
|
||||
let original = b"hello world".to_vec();
|
||||
let encrypted = encrypt_profile_file(&key, "Default/Cookies", &original).unwrap();
|
||||
let (path, content) = decrypt_profile_file(&key, &encrypted).unwrap();
|
||||
assert_eq!(path, "Default/Cookies");
|
||||
assert_eq!(content, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_wrong_key_fails() {
|
||||
let key1 = make_key();
|
||||
let key2 = make_key();
|
||||
let encrypted = encrypt_profile_file(&key1, "Cookies", b"data").unwrap();
|
||||
assert!(matches!(
|
||||
decrypt_profile_file(&key2, &encrypted),
|
||||
Err(PasswordError::WrongPassword)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_truncated_ciphertext() {
|
||||
let key = make_key();
|
||||
let encrypted = encrypt_profile_file(&key, "x", b"y").unwrap();
|
||||
// Drop the auth tag
|
||||
let truncated = &encrypted[..encrypted.len() - 1];
|
||||
assert!(decrypt_profile_file(&key, truncated).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dir_roundtrip() {
|
||||
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.join("Default")).unwrap();
|
||||
std::fs::write(plain.join("Default/Cookies"), b"sqlite-data").unwrap();
|
||||
std::fs::write(plain.join("Default/Bookmarks"), b"{\"x\":1}").unwrap();
|
||||
std::fs::write(plain.join("Local State"), b"state").unwrap();
|
||||
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
// No plaintext filenames on disk
|
||||
let names: Vec<String> = std::fs::read_dir(&enc)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
for n in &names {
|
||||
assert!(!n.contains("Cookies"), "plaintext leaked: {n}");
|
||||
assert!(!n.contains("Bookmarks"));
|
||||
assert!(!n.contains("Local State"));
|
||||
}
|
||||
|
||||
// Verify file present
|
||||
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);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("Default/Cookies")).unwrap(),
|
||||
b"sqlite-data"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("Default/Bookmarks")).unwrap(),
|
||||
b"{\"x\":1}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("Local State")).unwrap(),
|
||||
b"state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dir_excludes() {
|
||||
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.join("Default/Cache")).unwrap();
|
||||
std::fs::write(plain.join("Default/Cookies"), b"keep").unwrap();
|
||||
std::fs::write(plain.join("Default/Cache/data"), b"drop").unwrap();
|
||||
|
||||
encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap();
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
|
||||
// Only Cookies (1 file) should be present, not Cache contents
|
||||
assert_eq!(mtimes.len(), 1);
|
||||
assert!(mtimes.contains_key("Default/Cookies"));
|
||||
assert!(restored.join("Default/Cookies").exists());
|
||||
assert!(!restored.join("Default/Cache/data").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_against_wrong_key() {
|
||||
let key1 = make_key();
|
||||
let key2 = 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("file"), b"data").unwrap();
|
||||
encrypt_profile_dir(&key1, &plain, &enc, &[]).unwrap();
|
||||
assert!(verify_key_against_dir(&key1, &enc).is_ok());
|
||||
assert!(matches!(
|
||||
verify_key_against_dir(&key2, &enc),
|
||||
Err(PasswordError::WrongPassword)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reencrypt_skips_unchanged() {
|
||||
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("a"), b"AAA").unwrap();
|
||||
std::fs::write(plain.join("b"), b"BBB").unwrap();
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
|
||||
// Capture pre-rewrite ciphertext bytes
|
||||
let name_a = hmac_filename(&key, "a");
|
||||
let name_b = hmac_filename(&key, "b");
|
||||
let cipher_a_before = std::fs::read(enc.join(&name_a)).unwrap();
|
||||
let cipher_b_before = std::fs::read(enc.join(&name_b)).unwrap();
|
||||
|
||||
// Modify only "a" in the restored tree
|
||||
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();
|
||||
assert_eq!(rewrote, 1);
|
||||
|
||||
let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap();
|
||||
let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap();
|
||||
assert_ne!(
|
||||
cipher_a_before, cipher_a_after,
|
||||
"changed file should have new ciphertext"
|
||||
);
|
||||
assert_eq!(
|
||||
cipher_b_before, cipher_b_after,
|
||||
"unchanged file should have stable ciphertext"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reencrypt_handles_added_and_removed() {
|
||||
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("keep"), b"k").unwrap();
|
||||
std::fs::write(plain.join("delete"), b"d").unwrap();
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
|
||||
std::fs::remove_file(restored.join("delete")).unwrap();
|
||||
std::fs::write(restored.join("new"), b"n").unwrap();
|
||||
|
||||
reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
|
||||
|
||||
let names: HashSet<String> = std::fs::read_dir(&enc)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
assert!(names.contains(&hmac_filename(&key, "keep")));
|
||||
assert!(names.contains(&hmac_filename(&key, "new")));
|
||||
assert!(!names.contains(&hmac_filename(&key, "delete")));
|
||||
assert!(names.contains(VERIFY_FILE_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rekey_changes_filenames_and_content() {
|
||||
let old = make_key();
|
||||
let new = 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("x"), b"data").unwrap();
|
||||
encrypt_profile_dir(&old, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let old_name = hmac_filename(&old, "x");
|
||||
let new_name = hmac_filename(&new, "x");
|
||||
assert_ne!(old_name, new_name);
|
||||
|
||||
rekey_profile_dir(&old, &new, &enc).unwrap();
|
||||
|
||||
assert!(!enc.join(&old_name).exists());
|
||||
assert!(enc.join(&new_name).exists());
|
||||
verify_key_against_dir(&new, &enc).unwrap();
|
||||
assert!(matches!(
|
||||
verify_key_against_dir(&old, &enc),
|
||||
Err(PasswordError::WrongPassword)
|
||||
));
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
decrypt_profile_dir(&new, &enc, &restored).unwrap();
|
||||
assert_eq!(std::fs::read(restored.join("x")).unwrap(), b"data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_write_leaves_original_intact_if_tmp_lingers() {
|
||||
let work = TempDir::new().unwrap();
|
||||
let target = work.path().join("file");
|
||||
std::fs::write(&target, b"original").unwrap();
|
||||
|
||||
// Simulate a stale tmp from a crashed write
|
||||
std::fs::write(target.with_extension("donut-tmp"), b"partial").unwrap();
|
||||
|
||||
// A successful write should overwrite the original even when stale tmp exists
|
||||
atomic_write(&target, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_cache_lifecycle() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
assert!(!has_cached_key(&id));
|
||||
cache_key(id, [9u8; 32]);
|
||||
assert!(has_cached_key(&id));
|
||||
assert_eq!(get_cached_key(&id), Some([9u8; 32]));
|
||||
drop_cached_key(&id);
|
||||
assert!(!has_cached_key(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unlock_helper() {
|
||||
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("x"), b"data").unwrap();
|
||||
|
||||
let salt = generate_salt();
|
||||
let key = derive_profile_key("correct horse", &salt).unwrap();
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let id = uuid::Uuid::new_v4();
|
||||
drop_cached_key(&id);
|
||||
assert!(unlock(id, "wrong", &salt, &enc).is_err());
|
||||
assert!(!has_cached_key(&id));
|
||||
assert!(unlock(id, "correct horse", &salt, &enc).is_ok());
|
||||
assert!(has_cached_key(&id));
|
||||
drop_cached_key(&id);
|
||||
}
|
||||
}
|
||||
#[path = "encryption_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_key() -> [u8; 32] {
|
||||
derive_profile_key("hunter2", &generate_salt()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_filename_deterministic() {
|
||||
let key = [7u8; 32];
|
||||
let a = hmac_filename(&key, "Default/Cookies");
|
||||
let b = hmac_filename(&key, "Default/Cookies");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.len(), HMAC_FILENAME_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_filename_different_keys() {
|
||||
let a = hmac_filename(&[1u8; 32], "Default/Cookies");
|
||||
let b = hmac_filename(&[2u8; 32], "Default/Cookies");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_filename_different_paths() {
|
||||
let key = [1u8; 32];
|
||||
let a = hmac_filename(&key, "Default/Cookies");
|
||||
let b = hmac_filename(&key, "Default/Login Data");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_roundtrip() {
|
||||
let key = make_key();
|
||||
let original = b"hello world".to_vec();
|
||||
let encrypted = encrypt_profile_file(&key, "Default/Cookies", &original).unwrap();
|
||||
let (path, content) = decrypt_profile_file(&key, &encrypted).unwrap();
|
||||
assert_eq!(path, "Default/Cookies");
|
||||
assert_eq!(content, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_wrong_key_fails() {
|
||||
let key1 = make_key();
|
||||
let key2 = make_key();
|
||||
let encrypted = encrypt_profile_file(&key1, "Cookies", b"data").unwrap();
|
||||
assert!(matches!(
|
||||
decrypt_profile_file(&key2, &encrypted),
|
||||
Err(PasswordError::WrongPassword)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_truncated_ciphertext() {
|
||||
let key = make_key();
|
||||
let encrypted = encrypt_profile_file(&key, "x", b"y").unwrap();
|
||||
// Drop the auth tag
|
||||
let truncated = &encrypted[..encrypted.len() - 1];
|
||||
assert!(decrypt_profile_file(&key, truncated).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dir_roundtrip() {
|
||||
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.join("Default")).unwrap();
|
||||
std::fs::write(plain.join("Default/Cookies"), b"sqlite-data").unwrap();
|
||||
std::fs::write(plain.join("Default/Bookmarks"), b"{\"x\":1}").unwrap();
|
||||
std::fs::write(plain.join("Local State"), b"state").unwrap();
|
||||
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
// No plaintext filenames on disk
|
||||
let names: Vec<String> = std::fs::read_dir(&enc)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
for n in &names {
|
||||
assert!(!n.contains("Cookies"), "plaintext leaked: {n}");
|
||||
assert!(!n.contains("Bookmarks"));
|
||||
assert!(!n.contains("Local State"));
|
||||
}
|
||||
|
||||
// Verify file present
|
||||
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);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("Default/Cookies")).unwrap(),
|
||||
b"sqlite-data"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("Default/Bookmarks")).unwrap(),
|
||||
b"{\"x\":1}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("Local State")).unwrap(),
|
||||
b"state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dir_excludes() {
|
||||
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.join("Default/Cache")).unwrap();
|
||||
std::fs::write(plain.join("Default/Cookies"), b"keep").unwrap();
|
||||
std::fs::write(plain.join("Default/Cache/data"), b"drop").unwrap();
|
||||
|
||||
encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap();
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
|
||||
// Only Cookies (1 file) should be present, not Cache contents
|
||||
assert_eq!(mtimes.len(), 1);
|
||||
assert!(mtimes.contains_key("Default/Cookies"));
|
||||
assert!(restored.join("Default/Cookies").exists());
|
||||
assert!(!restored.join("Default/Cache/data").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_against_wrong_key() {
|
||||
let key1 = make_key();
|
||||
let key2 = 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("file"), b"data").unwrap();
|
||||
encrypt_profile_dir(&key1, &plain, &enc, &[]).unwrap();
|
||||
assert!(verify_key_against_dir(&key1, &enc).is_ok());
|
||||
assert!(matches!(
|
||||
verify_key_against_dir(&key2, &enc),
|
||||
Err(PasswordError::WrongPassword)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reencrypt_skips_unchanged() {
|
||||
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("a"), b"AAA").unwrap();
|
||||
std::fs::write(plain.join("b"), b"BBB").unwrap();
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
|
||||
// Capture pre-rewrite ciphertext bytes
|
||||
let name_a = hmac_filename(&key, "a");
|
||||
let name_b = hmac_filename(&key, "b");
|
||||
let cipher_a_before = std::fs::read(enc.join(&name_a)).unwrap();
|
||||
let cipher_b_before = std::fs::read(enc.join(&name_b)).unwrap();
|
||||
|
||||
// Modify only "a" in the restored tree
|
||||
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();
|
||||
assert_eq!(rewrote, 1);
|
||||
|
||||
let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap();
|
||||
let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap();
|
||||
assert_ne!(
|
||||
cipher_a_before, cipher_a_after,
|
||||
"changed file should have new ciphertext"
|
||||
);
|
||||
assert_eq!(
|
||||
cipher_b_before, cipher_b_after,
|
||||
"unchanged file should have stable ciphertext"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reencrypt_handles_added_and_removed() {
|
||||
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("keep"), b"k").unwrap();
|
||||
std::fs::write(plain.join("delete"), b"d").unwrap();
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
|
||||
std::fs::remove_file(restored.join("delete")).unwrap();
|
||||
std::fs::write(restored.join("new"), b"n").unwrap();
|
||||
|
||||
reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
|
||||
|
||||
let names: HashSet<String> = std::fs::read_dir(&enc)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
assert!(names.contains(&hmac_filename(&key, "keep")));
|
||||
assert!(names.contains(&hmac_filename(&key, "new")));
|
||||
assert!(!names.contains(&hmac_filename(&key, "delete")));
|
||||
assert!(names.contains(VERIFY_FILE_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rekey_changes_filenames_and_content() {
|
||||
let old = make_key();
|
||||
let new = 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("x"), b"data").unwrap();
|
||||
encrypt_profile_dir(&old, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let old_name = hmac_filename(&old, "x");
|
||||
let new_name = hmac_filename(&new, "x");
|
||||
assert_ne!(old_name, new_name);
|
||||
|
||||
rekey_profile_dir(&old, &new, &enc).unwrap();
|
||||
|
||||
assert!(!enc.join(&old_name).exists());
|
||||
assert!(enc.join(&new_name).exists());
|
||||
verify_key_against_dir(&new, &enc).unwrap();
|
||||
assert!(matches!(
|
||||
verify_key_against_dir(&old, &enc),
|
||||
Err(PasswordError::WrongPassword)
|
||||
));
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
decrypt_profile_dir(&new, &enc, &restored).unwrap();
|
||||
assert_eq!(std::fs::read(restored.join("x")).unwrap(), b"data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_write_leaves_original_intact_if_tmp_lingers() {
|
||||
let work = TempDir::new().unwrap();
|
||||
let target = work.path().join("file");
|
||||
std::fs::write(&target, b"original").unwrap();
|
||||
|
||||
// Simulate a stale tmp from a crashed write
|
||||
std::fs::write(target.with_extension("donut-tmp"), b"partial").unwrap();
|
||||
|
||||
// A successful write should overwrite the original even when stale tmp exists
|
||||
atomic_write(&target, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_cache_lifecycle() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
assert!(!has_cached_key(&id));
|
||||
cache_key(id, [9u8; 32]);
|
||||
assert!(has_cached_key(&id));
|
||||
assert_eq!(get_cached_key(&id), Some([9u8; 32]));
|
||||
drop_cached_key(&id);
|
||||
assert!(!has_cached_key(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unlock_helper() {
|
||||
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("x"), b"data").unwrap();
|
||||
|
||||
let salt = generate_salt();
|
||||
let key = derive_profile_key("correct horse", &salt).unwrap();
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let id = uuid::Uuid::new_v4();
|
||||
drop_cached_key(&id);
|
||||
assert!(unlock(id, "wrong", &salt, &enc).is_err());
|
||||
assert!(!has_cached_key(&id));
|
||||
assert!(unlock(id, "correct horse", &salt, &enc).is_ok());
|
||||
assert!(has_cached_key(&id));
|
||||
drop_cached_key(&id);
|
||||
}
|
||||
@@ -1016,7 +1016,7 @@ impl ProfileManager {
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut deduped: Vec<String> = Vec::with_capacity(tags.len());
|
||||
let mut deduped: Vec<String> = Vec::new();
|
||||
for t in tags.into_iter() {
|
||||
if seen.insert(t.clone()) {
|
||||
deduped.push(t);
|
||||
|
||||
@@ -745,629 +745,5 @@ pub async fn complete_after_quit_and_wait(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::BrowserProfile;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_profile(name: &str) -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
release_type: "stable".to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_plaintext_dir(dir: &Path) {
|
||||
std::fs::create_dir_all(dir.join("Default")).unwrap();
|
||||
std::fs::write(dir.join("Default/Cookies"), b"sqlite-data").unwrap();
|
||||
std::fs::write(dir.join("Default/Bookmarks"), b"{\"x\":1}").unwrap();
|
||||
std::fs::write(dir.join("Local State"), b"local-state").unwrap();
|
||||
// Cache files should be excluded:
|
||||
std::fs::create_dir_all(dir.join("Default/Cache")).unwrap();
|
||||
std::fs::write(dir.join("Default/Cache/data_0"), b"cache-blob").unwrap();
|
||||
}
|
||||
|
||||
fn parse_err_code(err: &str) -> Option<&'static str> {
|
||||
let v: serde_json::Value = serde_json::from_str(err).ok()?;
|
||||
let code = v.get("code")?.as_str()?;
|
||||
Some(match code {
|
||||
"INCORRECT_PASSWORD" => "INCORRECT_PASSWORD",
|
||||
"LOCKED_OUT" => "LOCKED_OUT",
|
||||
"PROFILE_NOT_FOUND" => "PROFILE_NOT_FOUND",
|
||||
"PROFILE_NOT_PROTECTED" => "PROFILE_NOT_PROTECTED",
|
||||
"PROFILE_ALREADY_PROTECTED" => "PROFILE_ALREADY_PROTECTED",
|
||||
"PROFILE_RUNNING" => "PROFILE_RUNNING",
|
||||
"PROFILE_MISSING_SALT" => "PROFILE_MISSING_SALT",
|
||||
"PROFILE_LOCKED" => "PROFILE_LOCKED",
|
||||
"INVALID_PROFILE_ID" => "INVALID_PROFILE_ID",
|
||||
"PASSWORD_TOO_SHORT" => "PASSWORD_TOO_SHORT",
|
||||
"INTERNAL_ERROR" => "INTERNAL_ERROR",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_err_param(err: &str, key: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(err).ok()?;
|
||||
Some(v.get("params")?.get(key)?.as_str()?.to_string())
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
fn profile_full_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
|
||||
profiles_dir.join(profile.id.to_string()).join("profile")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_set_password_encrypts_dir() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let mut profile = make_profile("test-set");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
assert!(profile.password_protected);
|
||||
assert!(profile.encryption_salt.is_some());
|
||||
|
||||
// No plaintext filenames should remain on disk
|
||||
let names: Vec<String> = std::fs::read_dir(&plain_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
for n in &names {
|
||||
assert!(!n.contains("Cookies"), "plaintext name leaked: {n}");
|
||||
assert!(!n.contains("Bookmarks"));
|
||||
assert!(!n.contains("Local State"));
|
||||
}
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_full_lifecycle_persists_data() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-lifecycle");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let mut profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
|
||||
// Simulate launch: prepare_for_launch decrypts to ephemeral
|
||||
let ephemeral = prepare_for_launch(&profile).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral.join("Default/Cookies")).unwrap(),
|
||||
b"sqlite-data"
|
||||
);
|
||||
|
||||
// Simulate user activity: modify Cookies, leave Bookmarks alone
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
std::fs::write(ephemeral.join("Default/Cookies"), b"sqlite-modified").unwrap();
|
||||
|
||||
// Capture pre-quit ciphertext for the unchanged Bookmarks file
|
||||
let key = get_cached_key(&profile.id).unwrap();
|
||||
let bookmarks_name = crate::profile::encryption::hmac_filename(&key, "Default/Bookmarks");
|
||||
let bookmarks_cipher_before = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap();
|
||||
|
||||
// Simulate quit (purge=true): re-encrypts and clears cached key + ephemeral
|
||||
let n = complete_after_quit_blocking(&profile, false);
|
||||
assert!(n.is_some(), "should have re-encrypted at least one file");
|
||||
assert!(
|
||||
get_cached_key(&profile.id).is_none(),
|
||||
"key should be dropped"
|
||||
);
|
||||
assert!(
|
||||
crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none(),
|
||||
"ephemeral should be purged"
|
||||
);
|
||||
|
||||
// Unchanged file's ciphertext should be byte-identical
|
||||
let bookmarks_cipher_after = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap();
|
||||
assert_eq!(
|
||||
bookmarks_cipher_before, bookmarks_cipher_after,
|
||||
"unchanged file's ciphertext should be stable across quit"
|
||||
);
|
||||
|
||||
// Wrong password rejected
|
||||
let r = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into()));
|
||||
assert!(r.is_err());
|
||||
|
||||
// Correct password unlocks
|
||||
rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap();
|
||||
|
||||
// Re-launch and verify the modification persisted
|
||||
profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
let ephemeral2 = prepare_for_launch(&profile).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(),
|
||||
b"sqlite-modified",
|
||||
"modification should persist across the encrypt/decrypt cycle"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("Default/Bookmarks")).unwrap(),
|
||||
b"{\"x\":1}",
|
||||
"unchanged file should still be present"
|
||||
);
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_keep_decrypted_keeps_ephemeral_but_still_re_encrypts() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-keep");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
let ephemeral = prepare_for_launch(&profile).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
std::fs::write(ephemeral.join("Default/Cookies"), b"new-bytes").unwrap();
|
||||
|
||||
// keep_decrypted=true: ephemeral stays, key stays cached
|
||||
let n = complete_after_quit_blocking(&profile, true);
|
||||
assert!(n.is_some());
|
||||
assert!(
|
||||
get_cached_key(&profile.id).is_some(),
|
||||
"key should still be cached"
|
||||
);
|
||||
assert!(
|
||||
crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some(),
|
||||
"ephemeral should be preserved"
|
||||
);
|
||||
|
||||
// The on-disk encrypted dir was still updated
|
||||
let key = get_cached_key(&profile.id).unwrap();
|
||||
let cookies_name = crate::profile::encryption::hmac_filename(&key, "Default/Cookies");
|
||||
let cipher = std::fs::read(plain_dir.join(&cookies_name)).unwrap();
|
||||
let (path, content) = crate::profile::encryption::decrypt_profile_file(&key, &cipher).unwrap();
|
||||
assert_eq!(path, "Default/Cookies");
|
||||
assert_eq!(content, b"new-bytes");
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_change_and_remove_password() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-change");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
let salt_v1 = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap()
|
||||
.encryption_salt
|
||||
.clone()
|
||||
.unwrap();
|
||||
|
||||
// Wrong old password should fail
|
||||
let r = rt.block_on(change_profile_password(
|
||||
profile.id.to_string(),
|
||||
"wrong".into(),
|
||||
"newpassword!".into(),
|
||||
));
|
||||
assert!(r.is_err());
|
||||
|
||||
// Correct old password works, salt should change
|
||||
rt.block_on(change_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
"newpassword!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
let salt_v2 = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap()
|
||||
.encryption_salt
|
||||
.clone()
|
||||
.unwrap();
|
||||
assert_ne!(salt_v1, salt_v2, "salt should rotate on password change");
|
||||
|
||||
// Old password rejected, new accepted
|
||||
assert!(rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.is_err());
|
||||
rt.block_on(unlock_profile(
|
||||
profile.id.to_string(),
|
||||
"newpassword!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Remove password: data should be plaintext again
|
||||
rt.block_on(remove_profile_password(
|
||||
profile.id.to_string(),
|
||||
"newpassword!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let final_profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
assert!(!final_profile.password_protected);
|
||||
assert!(final_profile.encryption_salt.is_none());
|
||||
assert_eq!(
|
||||
std::fs::read(plain_dir.join("Default/Cookies")).unwrap(),
|
||||
b"sqlite-data"
|
||||
);
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_empty_profile_session_survives_restart() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
// Mimic a freshly created profile with no browser data yet
|
||||
let profile = make_profile("test-empty");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
std::fs::create_dir_all(&plain_dir).unwrap();
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// After encrypting an empty profile, only the verifier file lives on disk
|
||||
let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count();
|
||||
assert_eq!(
|
||||
on_disk_count, 1,
|
||||
"fresh encrypted profile should have only the verifier file"
|
||||
);
|
||||
|
||||
let profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
|
||||
// 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"
|
||||
);
|
||||
|
||||
// Simulate the browser writing a session
|
||||
std::fs::create_dir_all(ephemeral.join("Default")).unwrap();
|
||||
std::fs::write(ephemeral.join("Default/Cookies"), b"session-cookies").unwrap();
|
||||
std::fs::write(ephemeral.join("Default/places.sqlite"), b"places-data").unwrap();
|
||||
std::fs::write(ephemeral.join("prefs.js"), b"user_pref(\"x\", 1);").unwrap();
|
||||
|
||||
// Browser exits — re-encrypt back to disk
|
||||
let n = complete_after_quit_blocking(&profile, false);
|
||||
assert!(
|
||||
matches!(n, Some(rewrote) if rewrote >= 3),
|
||||
"expected at least 3 files re-encrypted, got {n:?}"
|
||||
);
|
||||
|
||||
// Encrypted dir should now have verifier + 3 user files
|
||||
let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count();
|
||||
assert!(
|
||||
on_disk_count >= 4,
|
||||
"encrypted dir should contain session data + verifier, got {on_disk_count} files"
|
||||
);
|
||||
|
||||
// Simulate full app restart: drop key, drop ephemeral tracking, remove ephemeral
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
// Unlock with same password
|
||||
rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap();
|
||||
|
||||
// Re-launch — session must come back
|
||||
let ephemeral2 = prepare_for_launch(&profile).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(),
|
||||
b"session-cookies",
|
||||
"Cookies should survive across encrypt/quit/restart/unlock cycle"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("Default/places.sqlite")).unwrap(),
|
||||
b"places-data"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("prefs.js")).unwrap(),
|
||||
b"user_pref(\"x\", 1);"
|
||||
);
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_progressive_backoff_on_wrong_password() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-backoff");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
fresh_test_state(&profile.id);
|
||||
clear_failed_attempts(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
drop_cached_key(&profile.id);
|
||||
|
||||
// First 4 wrong attempts produce the INCORRECT_PASSWORD code
|
||||
for _ in 0..4 {
|
||||
let err = rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "wrong".into()))
|
||||
.unwrap_err();
|
||||
assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD"));
|
||||
}
|
||||
|
||||
// 5th wrong attempt also returns the code, but the next one will be locked out
|
||||
let err = rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "wrong".into()))
|
||||
.unwrap_err();
|
||||
assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD"));
|
||||
|
||||
// 6th attempt is rate-limited regardless of password correctness
|
||||
let err = rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap_err();
|
||||
assert_eq!(parse_err_code(&err), Some("LOCKED_OUT"));
|
||||
let secs = parse_err_param(&err, "seconds")
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap();
|
||||
assert!(secs > 0 && secs <= 60, "expected 1m countdown, got {secs}s");
|
||||
|
||||
// Bypass the timer by manually expiring last_failed_at past the lockout
|
||||
if let Ok(mut guard) = FAILED_ATTEMPTS.lock() {
|
||||
if let Some(record) = guard.get_mut(&profile.id) {
|
||||
record.last_failed_at_secs = now_epoch_secs().saturating_sub(120);
|
||||
}
|
||||
}
|
||||
if let Some(record) = FAILED_ATTEMPTS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.get(&profile.id).copied())
|
||||
{
|
||||
persist_record(&profile.id, &record);
|
||||
}
|
||||
|
||||
// Correct password now succeeds, clearing the failure history
|
||||
rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap();
|
||||
let post = FAILED_ATTEMPTS
|
||||
.lock()
|
||||
.map(|g| g.contains_key(&profile.id))
|
||||
.unwrap_or(true);
|
||||
assert!(!post, "successful unlock should clear failure record");
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
clear_failed_attempts(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_lockout_survives_restart() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-restart");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
fresh_test_state(&profile.id);
|
||||
clear_failed_attempts(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
drop_cached_key(&profile.id);
|
||||
|
||||
// 5 wrong attempts to trigger lockout
|
||||
for _ in 0..5 {
|
||||
let _ = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into()));
|
||||
}
|
||||
|
||||
// Sidecar file should now exist
|
||||
let sidecar = lockout_sidecar_path(&profile.id);
|
||||
assert!(sidecar.exists(), "sidecar should be persisted to disk");
|
||||
|
||||
// Simulate app restart by clearing the in-memory cache (but NOT the sidecar)
|
||||
if let Ok(mut g) = FAILED_ATTEMPTS.lock() {
|
||||
g.clear();
|
||||
}
|
||||
|
||||
// Lockout should still apply because state was loaded from disk
|
||||
let err = rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
parse_err_code(&err),
|
||||
Some("LOCKED_OUT"),
|
||||
"expected lockout to persist across restart, got: {err}"
|
||||
);
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
clear_failed_attempts(&profile.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attempt_lock_serializes_one_profile_without_blocking_others() {
|
||||
let a = uuid::Uuid::new_v4();
|
||||
let b = uuid::Uuid::new_v4();
|
||||
|
||||
// One lock per profile is what turns check-lockout -> verify -> record
|
||||
// into a critical section instead of a check-then-act race.
|
||||
assert!(Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&a)));
|
||||
assert!(!Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&b)));
|
||||
|
||||
let held = attempt_lock(&a);
|
||||
let guard = held.lock().await;
|
||||
assert!(
|
||||
attempt_lock(&a).try_lock().is_err(),
|
||||
"a concurrent attempt on the same profile must wait for the window"
|
||||
);
|
||||
assert!(
|
||||
attempt_lock(&b).try_lock().is_ok(),
|
||||
"a different profile must not be serialized behind it"
|
||||
);
|
||||
drop(guard);
|
||||
assert!(attempt_lock(&a).try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lockout_schedule_progression() {
|
||||
use std::time::Duration;
|
||||
assert_eq!(lockout_for_count(0), None);
|
||||
assert_eq!(lockout_for_count(4), None);
|
||||
assert_eq!(lockout_for_count(5), Some(Duration::from_secs(60)));
|
||||
assert_eq!(lockout_for_count(6), Some(Duration::from_secs(5 * 60)));
|
||||
assert_eq!(lockout_for_count(7), Some(Duration::from_secs(15 * 60)));
|
||||
assert_eq!(lockout_for_count(8), Some(Duration::from_secs(60 * 60)));
|
||||
assert_eq!(lockout_for_count(9), Some(Duration::from_secs(2 * 3600)));
|
||||
assert_eq!(lockout_for_count(10), Some(Duration::from_secs(4 * 3600)));
|
||||
assert_eq!(lockout_for_count(11), Some(Duration::from_secs(8 * 3600)));
|
||||
assert_eq!(lockout_for_count(12), Some(Duration::from_secs(24 * 3600)));
|
||||
assert_eq!(lockout_for_count(50), Some(Duration::from_secs(24 * 3600)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_lock_drops_key() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-lock");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(get_cached_key(&profile.id).is_some());
|
||||
assert!(!rt
|
||||
.block_on(is_profile_locked(profile.id.to_string()))
|
||||
.unwrap());
|
||||
|
||||
rt.block_on(lock_profile(profile.id.to_string())).unwrap();
|
||||
assert!(get_cached_key(&profile.id).is_none());
|
||||
assert!(rt
|
||||
.block_on(is_profile_locked(profile.id.to_string()))
|
||||
.unwrap());
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
}
|
||||
#[path = "password_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
use super::*;
|
||||
use crate::profile::BrowserProfile;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_profile(name: &str) -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
release_type: "stable".to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_plaintext_dir(dir: &Path) {
|
||||
std::fs::create_dir_all(dir.join("Default")).unwrap();
|
||||
std::fs::write(dir.join("Default/Cookies"), b"sqlite-data").unwrap();
|
||||
std::fs::write(dir.join("Default/Bookmarks"), b"{\"x\":1}").unwrap();
|
||||
std::fs::write(dir.join("Local State"), b"local-state").unwrap();
|
||||
// Cache files should be excluded:
|
||||
std::fs::create_dir_all(dir.join("Default/Cache")).unwrap();
|
||||
std::fs::write(dir.join("Default/Cache/data_0"), b"cache-blob").unwrap();
|
||||
}
|
||||
|
||||
fn parse_err_code(err: &str) -> Option<&'static str> {
|
||||
let v: serde_json::Value = serde_json::from_str(err).ok()?;
|
||||
let code = v.get("code")?.as_str()?;
|
||||
Some(match code {
|
||||
"INCORRECT_PASSWORD" => "INCORRECT_PASSWORD",
|
||||
"LOCKED_OUT" => "LOCKED_OUT",
|
||||
"PROFILE_NOT_FOUND" => "PROFILE_NOT_FOUND",
|
||||
"PROFILE_NOT_PROTECTED" => "PROFILE_NOT_PROTECTED",
|
||||
"PROFILE_ALREADY_PROTECTED" => "PROFILE_ALREADY_PROTECTED",
|
||||
"PROFILE_RUNNING" => "PROFILE_RUNNING",
|
||||
"PROFILE_MISSING_SALT" => "PROFILE_MISSING_SALT",
|
||||
"PROFILE_LOCKED" => "PROFILE_LOCKED",
|
||||
"INVALID_PROFILE_ID" => "INVALID_PROFILE_ID",
|
||||
"PASSWORD_TOO_SHORT" => "PASSWORD_TOO_SHORT",
|
||||
"INTERNAL_ERROR" => "INTERNAL_ERROR",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_err_param(err: &str, key: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(err).ok()?;
|
||||
Some(v.get("params")?.get(key)?.as_str()?.to_string())
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
fn profile_full_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
|
||||
profiles_dir.join(profile.id.to_string()).join("profile")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_set_password_encrypts_dir() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let mut profile = make_profile("test-set");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
assert!(profile.password_protected);
|
||||
assert!(profile.encryption_salt.is_some());
|
||||
|
||||
// No plaintext filenames should remain on disk
|
||||
let names: Vec<String> = std::fs::read_dir(&plain_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
for n in &names {
|
||||
assert!(!n.contains("Cookies"), "plaintext name leaked: {n}");
|
||||
assert!(!n.contains("Bookmarks"));
|
||||
assert!(!n.contains("Local State"));
|
||||
}
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_full_lifecycle_persists_data() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-lifecycle");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let mut profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
|
||||
// Simulate launch: prepare_for_launch decrypts to ephemeral
|
||||
let ephemeral = prepare_for_launch(&profile).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral.join("Default/Cookies")).unwrap(),
|
||||
b"sqlite-data"
|
||||
);
|
||||
|
||||
// Simulate user activity: modify Cookies, leave Bookmarks alone
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
std::fs::write(ephemeral.join("Default/Cookies"), b"sqlite-modified").unwrap();
|
||||
|
||||
// Capture pre-quit ciphertext for the unchanged Bookmarks file
|
||||
let key = get_cached_key(&profile.id).unwrap();
|
||||
let bookmarks_name = crate::profile::encryption::hmac_filename(&key, "Default/Bookmarks");
|
||||
let bookmarks_cipher_before = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap();
|
||||
|
||||
// Simulate quit (purge=true): re-encrypts and clears cached key + ephemeral
|
||||
let n = complete_after_quit_blocking(&profile, false);
|
||||
assert!(n.is_some(), "should have re-encrypted at least one file");
|
||||
assert!(
|
||||
get_cached_key(&profile.id).is_none(),
|
||||
"key should be dropped"
|
||||
);
|
||||
assert!(
|
||||
crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none(),
|
||||
"ephemeral should be purged"
|
||||
);
|
||||
|
||||
// Unchanged file's ciphertext should be byte-identical
|
||||
let bookmarks_cipher_after = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap();
|
||||
assert_eq!(
|
||||
bookmarks_cipher_before, bookmarks_cipher_after,
|
||||
"unchanged file's ciphertext should be stable across quit"
|
||||
);
|
||||
|
||||
// Wrong password rejected
|
||||
let r = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into()));
|
||||
assert!(r.is_err());
|
||||
|
||||
// Correct password unlocks
|
||||
rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap();
|
||||
|
||||
// Re-launch and verify the modification persisted
|
||||
profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
let ephemeral2 = prepare_for_launch(&profile).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(),
|
||||
b"sqlite-modified",
|
||||
"modification should persist across the encrypt/decrypt cycle"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("Default/Bookmarks")).unwrap(),
|
||||
b"{\"x\":1}",
|
||||
"unchanged file should still be present"
|
||||
);
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_keep_decrypted_keeps_ephemeral_but_still_re_encrypts() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-keep");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
let ephemeral = prepare_for_launch(&profile).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
std::fs::write(ephemeral.join("Default/Cookies"), b"new-bytes").unwrap();
|
||||
|
||||
// keep_decrypted=true: ephemeral stays, key stays cached
|
||||
let n = complete_after_quit_blocking(&profile, true);
|
||||
assert!(n.is_some());
|
||||
assert!(
|
||||
get_cached_key(&profile.id).is_some(),
|
||||
"key should still be cached"
|
||||
);
|
||||
assert!(
|
||||
crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some(),
|
||||
"ephemeral should be preserved"
|
||||
);
|
||||
|
||||
// The on-disk encrypted dir was still updated
|
||||
let key = get_cached_key(&profile.id).unwrap();
|
||||
let cookies_name = crate::profile::encryption::hmac_filename(&key, "Default/Cookies");
|
||||
let cipher = std::fs::read(plain_dir.join(&cookies_name)).unwrap();
|
||||
let (path, content) = crate::profile::encryption::decrypt_profile_file(&key, &cipher).unwrap();
|
||||
assert_eq!(path, "Default/Cookies");
|
||||
assert_eq!(content, b"new-bytes");
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_change_and_remove_password() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-change");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
let salt_v1 = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap()
|
||||
.encryption_salt
|
||||
.clone()
|
||||
.unwrap();
|
||||
|
||||
// Wrong old password should fail
|
||||
let r = rt.block_on(change_profile_password(
|
||||
profile.id.to_string(),
|
||||
"wrong".into(),
|
||||
"newpassword!".into(),
|
||||
));
|
||||
assert!(r.is_err());
|
||||
|
||||
// Correct old password works, salt should change
|
||||
rt.block_on(change_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
"newpassword!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
let salt_v2 = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap()
|
||||
.encryption_salt
|
||||
.clone()
|
||||
.unwrap();
|
||||
assert_ne!(salt_v1, salt_v2, "salt should rotate on password change");
|
||||
|
||||
// Old password rejected, new accepted
|
||||
assert!(rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.is_err());
|
||||
rt.block_on(unlock_profile(
|
||||
profile.id.to_string(),
|
||||
"newpassword!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Remove password: data should be plaintext again
|
||||
rt.block_on(remove_profile_password(
|
||||
profile.id.to_string(),
|
||||
"newpassword!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let final_profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
assert!(!final_profile.password_protected);
|
||||
assert!(final_profile.encryption_salt.is_none());
|
||||
assert_eq!(
|
||||
std::fs::read(plain_dir.join("Default/Cookies")).unwrap(),
|
||||
b"sqlite-data"
|
||||
);
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_empty_profile_session_survives_restart() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
// Mimic a freshly created profile with no browser data yet
|
||||
let profile = make_profile("test-empty");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
std::fs::create_dir_all(&plain_dir).unwrap();
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// After encrypting an empty profile, only the verifier file lives on disk
|
||||
let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count();
|
||||
assert_eq!(
|
||||
on_disk_count, 1,
|
||||
"fresh encrypted profile should have only the verifier file"
|
||||
);
|
||||
|
||||
let profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap();
|
||||
|
||||
// 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"
|
||||
);
|
||||
|
||||
// Simulate the browser writing a session
|
||||
std::fs::create_dir_all(ephemeral.join("Default")).unwrap();
|
||||
std::fs::write(ephemeral.join("Default/Cookies"), b"session-cookies").unwrap();
|
||||
std::fs::write(ephemeral.join("Default/places.sqlite"), b"places-data").unwrap();
|
||||
std::fs::write(ephemeral.join("prefs.js"), b"user_pref(\"x\", 1);").unwrap();
|
||||
|
||||
// Browser exits — re-encrypt back to disk
|
||||
let n = complete_after_quit_blocking(&profile, false);
|
||||
assert!(
|
||||
matches!(n, Some(rewrote) if rewrote >= 3),
|
||||
"expected at least 3 files re-encrypted, got {n:?}"
|
||||
);
|
||||
|
||||
// Encrypted dir should now have verifier + 3 user files
|
||||
let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count();
|
||||
assert!(
|
||||
on_disk_count >= 4,
|
||||
"encrypted dir should contain session data + verifier, got {on_disk_count} files"
|
||||
);
|
||||
|
||||
// Simulate full app restart: drop key, drop ephemeral tracking, remove ephemeral
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
// Unlock with same password
|
||||
rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap();
|
||||
|
||||
// Re-launch — session must come back
|
||||
let ephemeral2 = prepare_for_launch(&profile).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(),
|
||||
b"session-cookies",
|
||||
"Cookies should survive across encrypt/quit/restart/unlock cycle"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("Default/places.sqlite")).unwrap(),
|
||||
b"places-data"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(ephemeral2.join("prefs.js")).unwrap(),
|
||||
b"user_pref(\"x\", 1);"
|
||||
);
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_progressive_backoff_on_wrong_password() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-backoff");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
fresh_test_state(&profile.id);
|
||||
clear_failed_attempts(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
drop_cached_key(&profile.id);
|
||||
|
||||
// First 4 wrong attempts produce the INCORRECT_PASSWORD code
|
||||
for _ in 0..4 {
|
||||
let err = rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "wrong".into()))
|
||||
.unwrap_err();
|
||||
assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD"));
|
||||
}
|
||||
|
||||
// 5th wrong attempt also returns the code, but the next one will be locked out
|
||||
let err = rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "wrong".into()))
|
||||
.unwrap_err();
|
||||
assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD"));
|
||||
|
||||
// 6th attempt is rate-limited regardless of password correctness
|
||||
let err = rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap_err();
|
||||
assert_eq!(parse_err_code(&err), Some("LOCKED_OUT"));
|
||||
let secs = parse_err_param(&err, "seconds")
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap();
|
||||
assert!(secs > 0 && secs <= 60, "expected 1m countdown, got {secs}s");
|
||||
|
||||
// Bypass the timer by manually expiring last_failed_at past the lockout
|
||||
if let Ok(mut guard) = FAILED_ATTEMPTS.lock() {
|
||||
if let Some(record) = guard.get_mut(&profile.id) {
|
||||
record.last_failed_at_secs = now_epoch_secs().saturating_sub(120);
|
||||
}
|
||||
}
|
||||
if let Some(record) = FAILED_ATTEMPTS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.get(&profile.id).copied())
|
||||
{
|
||||
persist_record(&profile.id, &record);
|
||||
}
|
||||
|
||||
// Correct password now succeeds, clearing the failure history
|
||||
rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap();
|
||||
let post = FAILED_ATTEMPTS
|
||||
.lock()
|
||||
.map(|g| g.contains_key(&profile.id))
|
||||
.unwrap_or(true);
|
||||
assert!(!post, "successful unlock should clear failure record");
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
clear_failed_attempts(&profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_lockout_survives_restart() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-restart");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
fresh_test_state(&profile.id);
|
||||
clear_failed_attempts(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
drop_cached_key(&profile.id);
|
||||
|
||||
// 5 wrong attempts to trigger lockout
|
||||
for _ in 0..5 {
|
||||
let _ = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into()));
|
||||
}
|
||||
|
||||
// Sidecar file should now exist
|
||||
let sidecar = lockout_sidecar_path(&profile.id);
|
||||
assert!(sidecar.exists(), "sidecar should be persisted to disk");
|
||||
|
||||
// Simulate app restart by clearing the in-memory cache (but NOT the sidecar)
|
||||
if let Ok(mut g) = FAILED_ATTEMPTS.lock() {
|
||||
g.clear();
|
||||
}
|
||||
|
||||
// Lockout should still apply because state was loaded from disk
|
||||
let err = rt
|
||||
.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into()))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
parse_err_code(&err),
|
||||
Some("LOCKED_OUT"),
|
||||
"expected lockout to persist across restart, got: {err}"
|
||||
);
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
clear_failed_attempts(&profile.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attempt_lock_serializes_one_profile_without_blocking_others() {
|
||||
let a = uuid::Uuid::new_v4();
|
||||
let b = uuid::Uuid::new_v4();
|
||||
|
||||
// One lock per profile is what turns check-lockout -> verify -> record
|
||||
// into a critical section instead of a check-then-act race.
|
||||
assert!(Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&a)));
|
||||
assert!(!Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&b)));
|
||||
|
||||
let held = attempt_lock(&a);
|
||||
let guard = held.lock().await;
|
||||
assert!(
|
||||
attempt_lock(&a).try_lock().is_err(),
|
||||
"a concurrent attempt on the same profile must wait for the window"
|
||||
);
|
||||
assert!(
|
||||
attempt_lock(&b).try_lock().is_ok(),
|
||||
"a different profile must not be serialized behind it"
|
||||
);
|
||||
drop(guard);
|
||||
assert!(attempt_lock(&a).try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lockout_schedule_progression() {
|
||||
use std::time::Duration;
|
||||
assert_eq!(lockout_for_count(0), None);
|
||||
assert_eq!(lockout_for_count(4), None);
|
||||
assert_eq!(lockout_for_count(5), Some(Duration::from_secs(60)));
|
||||
assert_eq!(lockout_for_count(6), Some(Duration::from_secs(5 * 60)));
|
||||
assert_eq!(lockout_for_count(7), Some(Duration::from_secs(15 * 60)));
|
||||
assert_eq!(lockout_for_count(8), Some(Duration::from_secs(60 * 60)));
|
||||
assert_eq!(lockout_for_count(9), Some(Duration::from_secs(2 * 3600)));
|
||||
assert_eq!(lockout_for_count(10), Some(Duration::from_secs(4 * 3600)));
|
||||
assert_eq!(lockout_for_count(11), Some(Duration::from_secs(8 * 3600)));
|
||||
assert_eq!(lockout_for_count(12), Some(Duration::from_secs(24 * 3600)));
|
||||
assert_eq!(lockout_for_count(50), Some(Duration::from_secs(24 * 3600)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn integration_lock_drops_key() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
|
||||
|
||||
let profile = make_profile("test-lock");
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
let plain_dir = profile_full_path(&profile, &profiles_dir);
|
||||
populate_plaintext_dir(&plain_dir);
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
fresh_test_state(&profile.id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(set_profile_password(
|
||||
profile.id.to_string(),
|
||||
"hunter2!".into(),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(get_cached_key(&profile.id).is_some());
|
||||
assert!(!rt
|
||||
.block_on(is_profile_locked(profile.id.to_string()))
|
||||
.unwrap());
|
||||
|
||||
rt.block_on(lock_profile(profile.id.to_string())).unwrap();
|
||||
assert!(get_cached_key(&profile.id).is_none());
|
||||
assert!(rt
|
||||
.block_on(is_profile_locked(profile.id.to_string()))
|
||||
.unwrap());
|
||||
|
||||
fresh_test_state(&profile.id);
|
||||
}
|
||||
@@ -377,191 +377,5 @@ impl SourceKeyring {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn empty_password_key_matches_chromium_constant() {
|
||||
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
|
||||
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peanuts_key_matches_known_vector() {
|
||||
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
|
||||
// breaks every Linux `--password-store=basic` import.
|
||||
assert_eq!(
|
||||
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
|
||||
[
|
||||
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
|
||||
0x78
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_round_trip() {
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
|
||||
let sealed = key.encrypt(b"session-token").expect("encrypt");
|
||||
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_round_trip_empty_plaintext() {
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
|
||||
let sealed = key.encrypt(b"").expect("encrypt");
|
||||
// PKCS7 always emits a full padding block, so this must not be empty.
|
||||
assert_eq!(sealed.len(), 16);
|
||||
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcm_round_trip_with_fresh_nonce_each_time() {
|
||||
let key = CryptoKey::Aes256Gcm([7u8; 32]);
|
||||
let a = key.encrypt(b"session-token").expect("encrypt");
|
||||
let b = key.encrypt(b"session-token").expect("encrypt");
|
||||
assert_ne!(a, b, "nonce must be random per call");
|
||||
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
|
||||
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcm_rejects_tampered_ciphertext() {
|
||||
let key = CryptoKey::Aes256Gcm([7u8; 32]);
|
||||
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
|
||||
let last = sealed.len() - 1;
|
||||
sealed[last] ^= 0xff;
|
||||
assert!(key.decrypt(&sealed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_key_is_stable_across_calls() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let first = TargetKey::ensure(dir.path()).expect("mint");
|
||||
let sealed = first.encrypt(b"value").expect("encrypt");
|
||||
|
||||
let second = TargetKey::ensure(dir.path()).expect("reuse");
|
||||
// Re-running import over the same directory must not orphan what the
|
||||
// previous run wrote.
|
||||
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
|
||||
assert_eq!(
|
||||
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
|
||||
second.encrypt(b"probe").map(|v| v[..3].to_vec())
|
||||
);
|
||||
|
||||
let mut keyring = SourceKeyring::default();
|
||||
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
install_host_key(&mut keyring, &contents);
|
||||
match keyring.decrypt(&sealed) {
|
||||
Decrypted::Value(v) => assert_eq!(v, b"value"),
|
||||
_ => panic!("target key must round-trip through the source keyring"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minted_key_matches_wayfern_file_format() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
TargetKey::ensure(dir.path()).expect("mint");
|
||||
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
contents.len(),
|
||||
32,
|
||||
"DPAPIKeyProvider only adopts a 32-byte portable key"
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// The non-Windows key file is base64(16 random bytes) = 24 ASCII chars.
|
||||
assert_eq!(contents.len(), 24);
|
||||
let text = String::from_utf8(contents).expect("ascii");
|
||||
assert!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&text)
|
||||
.map(|b| b.len())
|
||||
== Ok(16),
|
||||
"expected base64 of 16 bytes, got {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
|
||||
let keyring = SourceKeyring::default();
|
||||
assert!(matches!(
|
||||
keyring.decrypt(b"plain cookie value"),
|
||||
Decrypted::NotEncrypted
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_bound_records_are_flagged_unrecoverable() {
|
||||
let keyring = SourceKeyring::default();
|
||||
let mut sealed = b"v20".to_vec();
|
||||
sealed.extend_from_slice(&[0u8; 40]);
|
||||
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
|
||||
assert!(
|
||||
keyring.saw_app_bound.get(),
|
||||
"v20 must be reported to the user, not silently dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_key_for_known_tag_is_unrecoverable() {
|
||||
let keyring = SourceKeyring::default();
|
||||
let mut sealed = b"v10".to_vec();
|
||||
sealed.extend_from_slice(&[0u8; 32]);
|
||||
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_password_fallback_recovers_the_record() {
|
||||
// A record sealed with the empty-password key must still open when the
|
||||
// keyring holds a different primary key, mirroring Chromium.
|
||||
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
|
||||
.encrypt(b"legacy")
|
||||
.unwrap();
|
||||
let mut stored = b"v10".to_vec();
|
||||
stored.extend_from_slice(&sealed_body);
|
||||
|
||||
let keyring = SourceKeyring {
|
||||
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
|
||||
..Default::default()
|
||||
};
|
||||
match keyring.decrypt(&stored) {
|
||||
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
|
||||
_ => panic!("empty-password fallback must be attempted"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the host-format key into a keyring under the host tag, for tests
|
||||
/// that need to verify what we wrote is what Wayfern will read.
|
||||
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bytes: [u8; 32] = contents.try_into().unwrap();
|
||||
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "os_crypt_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn empty_password_key_matches_chromium_constant() {
|
||||
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
|
||||
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peanuts_key_matches_known_vector() {
|
||||
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
|
||||
// breaks every Linux `--password-store=basic` import.
|
||||
assert_eq!(
|
||||
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
|
||||
[
|
||||
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
|
||||
0x78
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_round_trip() {
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
|
||||
let sealed = key.encrypt(b"session-token").expect("encrypt");
|
||||
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_round_trip_empty_plaintext() {
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
|
||||
let sealed = key.encrypt(b"").expect("encrypt");
|
||||
// PKCS7 always emits a full padding block, so this must not be empty.
|
||||
assert_eq!(sealed.len(), 16);
|
||||
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcm_round_trip_with_fresh_nonce_each_time() {
|
||||
let key = CryptoKey::Aes256Gcm([7u8; 32]);
|
||||
let a = key.encrypt(b"session-token").expect("encrypt");
|
||||
let b = key.encrypt(b"session-token").expect("encrypt");
|
||||
assert_ne!(a, b, "nonce must be random per call");
|
||||
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
|
||||
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcm_rejects_tampered_ciphertext() {
|
||||
let key = CryptoKey::Aes256Gcm([7u8; 32]);
|
||||
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
|
||||
let last = sealed.len() - 1;
|
||||
sealed[last] ^= 0xff;
|
||||
assert!(key.decrypt(&sealed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_key_is_stable_across_calls() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let first = TargetKey::ensure(dir.path()).expect("mint");
|
||||
let sealed = first.encrypt(b"value").expect("encrypt");
|
||||
|
||||
let second = TargetKey::ensure(dir.path()).expect("reuse");
|
||||
// Re-running import over the same directory must not orphan what the
|
||||
// previous run wrote.
|
||||
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
|
||||
assert_eq!(
|
||||
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
|
||||
second.encrypt(b"probe").map(|v| v[..3].to_vec())
|
||||
);
|
||||
|
||||
let mut keyring = SourceKeyring::default();
|
||||
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
install_host_key(&mut keyring, &contents);
|
||||
match keyring.decrypt(&sealed) {
|
||||
Decrypted::Value(v) => assert_eq!(v, b"value"),
|
||||
_ => panic!("target key must round-trip through the source keyring"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minted_key_matches_wayfern_file_format() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
TargetKey::ensure(dir.path()).expect("mint");
|
||||
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
contents.len(),
|
||||
32,
|
||||
"DPAPIKeyProvider only adopts a 32-byte portable key"
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// The non-Windows key file is base64(16 random bytes) = 24 ASCII chars.
|
||||
assert_eq!(contents.len(), 24);
|
||||
let text = String::from_utf8(contents).expect("ascii");
|
||||
assert!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&text)
|
||||
.map(|b| b.len())
|
||||
== Ok(16),
|
||||
"expected base64 of 16 bytes, got {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
|
||||
let keyring = SourceKeyring::default();
|
||||
assert!(matches!(
|
||||
keyring.decrypt(b"plain cookie value"),
|
||||
Decrypted::NotEncrypted
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_bound_records_are_flagged_unrecoverable() {
|
||||
let keyring = SourceKeyring::default();
|
||||
let mut sealed = b"v20".to_vec();
|
||||
sealed.extend_from_slice(&[0u8; 40]);
|
||||
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
|
||||
assert!(
|
||||
keyring.saw_app_bound.get(),
|
||||
"v20 must be reported to the user, not silently dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_key_for_known_tag_is_unrecoverable() {
|
||||
let keyring = SourceKeyring::default();
|
||||
let mut sealed = b"v10".to_vec();
|
||||
sealed.extend_from_slice(&[0u8; 32]);
|
||||
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_password_fallback_recovers_the_record() {
|
||||
// A record sealed with the empty-password key must still open when the
|
||||
// keyring holds a different primary key, mirroring Chromium.
|
||||
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
|
||||
.encrypt(b"legacy")
|
||||
.unwrap();
|
||||
let mut stored = b"v10".to_vec();
|
||||
stored.extend_from_slice(&sealed_body);
|
||||
|
||||
let keyring = SourceKeyring {
|
||||
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
|
||||
..Default::default()
|
||||
};
|
||||
match keyring.decrypt(&stored) {
|
||||
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
|
||||
_ => panic!("empty-password fallback must be attempted"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the host-format key into a keyring under the host tag, for tests
|
||||
/// that need to verify what we wrote is what Wayfern will read.
|
||||
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bytes: [u8; 32] = contents.try_into().unwrap();
|
||||
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,12 @@ pub struct ProfileImportReport {
|
||||
pub cookies_migrated: usize,
|
||||
/// Cookies carried over as rows but whose value could not be recovered.
|
||||
pub cookies_unrecoverable: usize,
|
||||
pub passwords_migrated: usize,
|
||||
pub passwords_unrecoverable: usize,
|
||||
/// Saved logins whose secret is readable in the new profile. `passwords_migrated` on the wire.
|
||||
#[serde(rename = "passwords_migrated")]
|
||||
pub logins_migrated: usize,
|
||||
/// Saved logins carried as rows whose secret could not be recovered. `passwords_unrecoverable` on the wire.
|
||||
#[serde(rename = "passwords_unrecoverable")]
|
||||
pub logins_unrecoverable: usize,
|
||||
/// Saved cards / IBANs / autofill secrets re-encrypted.
|
||||
pub payment_methods_migrated: usize,
|
||||
pub payment_methods_unrecoverable: usize,
|
||||
@@ -66,7 +70,7 @@ impl ProfileImportReport {
|
||||
/// should present the import as a success or as a warning.
|
||||
pub fn is_empty_import(&self) -> bool {
|
||||
self.cookies_migrated == 0
|
||||
&& self.passwords_migrated == 0
|
||||
&& self.logins_migrated == 0
|
||||
&& self.history_entries == 0
|
||||
&& self.bookmarks == 0
|
||||
&& self.local_storage_origins == 0
|
||||
|
||||
@@ -306,8 +306,8 @@ pub fn reencrypt_profile(
|
||||
if let Some(conn) = open_rw(&default_dir.join("Login Data")) {
|
||||
for (table, column) in LOGIN_COLUMNS {
|
||||
let counts = reencrypt_column(&conn, table, column, source, target);
|
||||
report.passwords_migrated += counts.migrated;
|
||||
report.passwords_unrecoverable += counts.unrecoverable;
|
||||
report.logins_migrated += counts.migrated;
|
||||
report.logins_unrecoverable += counts.unrecoverable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,586 +515,5 @@ pub fn finalize_profile(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile_import::os_crypt::{derive_key, CryptoKey};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn source_keyring_with(password: &[u8]) -> SourceKeyring {
|
||||
// Match the host's CBC iteration count so tests exercise the real path.
|
||||
#[cfg(target_os = "linux")]
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(
|
||||
password,
|
||||
super::super::os_crypt::POSIX_ITERATIONS,
|
||||
));
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(password, super::super::os_crypt::MAC_ITERATIONS));
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return SourceKeyring {
|
||||
v11: Some(key),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
SourceKeyring {
|
||||
v10: Some(key),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn seal_as_source(keyring: &SourceKeyring, plaintext: &[u8]) -> Vec<u8> {
|
||||
let (tag, key) = if let Some(k) = keyring.v10.as_ref() {
|
||||
(b"v10", k)
|
||||
} else {
|
||||
(b"v11", keyring.v11.as_ref().unwrap())
|
||||
};
|
||||
let mut out = tag.to_vec();
|
||||
out.extend_from_slice(&key.encrypt(plaintext).unwrap());
|
||||
out
|
||||
}
|
||||
|
||||
fn make_cookie_db(path: &Path, version: i64) -> Connection {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn
|
||||
.execute_batch(
|
||||
"CREATE TABLE cookies(
|
||||
creation_utc INTEGER NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
top_frame_site_key TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
encrypted_value BLOB NOT NULL DEFAULT '',
|
||||
path TEXT NOT NULL DEFAULT '/'
|
||||
);
|
||||
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO meta VALUES('version', ?1)",
|
||||
[version.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO meta VALUES('last_compatible_version', ?1)",
|
||||
[version.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v24_cookie_is_reframed_for_the_target_key() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let mut framed = Sha256::digest(b"example.com").to_vec();
|
||||
framed.extend_from_slice(b"tasty");
|
||||
let sealed = seal_as_source(&source, &framed);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_migrated, 1);
|
||||
assert_eq!(report.cookies_unrecoverable, 0);
|
||||
|
||||
// Read it back exactly the way Wayfern will.
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let (value, encrypted): (String, Vec<u8>) = conn
|
||||
.query_row("SELECT value, encrypted_value FROM cookies", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
value.is_empty(),
|
||||
"a row with both value and encrypted_value set is dropped at load"
|
||||
);
|
||||
|
||||
let target_keyring = target_as_keyring(dir.path());
|
||||
let Decrypted::Value(plain) = target_keyring.decrypt(&encrypted) else {
|
||||
panic!("target must be able to open what it sealed");
|
||||
};
|
||||
assert_eq!(&plain[..32], &Sha256::digest(b"example.com")[..]);
|
||||
assert_eq!(&plain[32..], b"tasty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_sealed_as_sqlite_text_is_still_recovered() {
|
||||
// Chromium's own v23->v24 migration binds `encrypted_value` with
|
||||
// BindString, so an established profile's cookies carry storage class TEXT
|
||||
// in a column declared BLOB. Reading them as a strict blob returns empty,
|
||||
// which used to blank every cookie and report it as migrated.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let mut framed = Sha256::digest(b"example.com").to_vec();
|
||||
framed.extend_from_slice(b"tasty");
|
||||
let sealed = seal_as_source(&source, &framed);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', '', CAST(?1 AS TEXT), '/')",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
let stored_type: String = conn
|
||||
.query_row("SELECT typeof(encrypted_value) FROM cookies", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stored_type, "text",
|
||||
"fixture must reproduce Chromium's binding"
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_migrated, 1);
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let encrypted: Vec<u8> = conn
|
||||
.query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
|
||||
panic!("expected a readable cookie");
|
||||
};
|
||||
assert_eq!(&plain[32..], b"tasty", "the cookie value must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_note_sealed_as_sqlite_text_is_still_recovered() {
|
||||
// `password_notes.value` is written with BindString on every platform, so
|
||||
// this is not an edge case — it is how the column always looks.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
std::fs::create_dir_all(&default_dir).unwrap();
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let sealed = seal_as_source(&source, b"a private note");
|
||||
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
conn
|
||||
.execute_batch(
|
||||
"CREATE TABLE logins(password_value BLOB);
|
||||
CREATE TABLE password_notes(id INTEGER PRIMARY KEY, value BLOB);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO password_notes(value) VALUES(CAST(?1 AS TEXT))",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_profile(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.passwords_migrated, 1);
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
let stored: Vec<u8> = conn
|
||||
.query_row("SELECT value FROM password_notes", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else {
|
||||
panic!("note must be readable with the target key");
|
||||
};
|
||||
assert_eq!(plain, b"a private note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_extension_paths_are_recognised_as_absolute_on_every_host() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("Secure Preferences");
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
"extensions": { "settings": {
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" },
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "C:\\Program Files\\Google\\Chrome\\Application\\151.0.0\\resources\\pdf" },
|
||||
"cccccccccccccccccccccccccccccccc": { "path": "//host/share/ext" }
|
||||
}}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut report = ProfileImportReport::default();
|
||||
sanitize_secure_preferences(&path, &mut report);
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let settings = value["extensions"]["settings"].as_object().unwrap();
|
||||
assert_eq!(
|
||||
settings.len(),
|
||||
1,
|
||||
"a Windows-syntax path is still absolute when imported onto macOS"
|
||||
);
|
||||
assert!(settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
|
||||
assert_eq!(report.extensions_migrated, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_cookie_is_sealed_and_value_cleared() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let source = source_keyring_with(b"unused");
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_migrated, 1);
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let (value, encrypted): (String, Vec<u8>) = conn
|
||||
.query_row("SELECT value, encrypted_value FROM cookies", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert!(value.is_empty());
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
|
||||
panic!("expected a readable cookie");
|
||||
};
|
||||
assert_eq!(&plain[32..], b"plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v23_cookie_has_no_prefix_to_strip_and_is_upgraded_to_v24() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
// v23 stores the bare value, with no SHA256(host) prefix.
|
||||
let sealed = seal_as_source(&source, b"tasty");
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 23);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_migrated, 1);
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let version: String = conn
|
||||
.query_row("SELECT value FROM meta WHERE key='version'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
version, "24",
|
||||
"we wrote v24 framing, so the store must declare v24 or Chromium re-prefixes it"
|
||||
);
|
||||
|
||||
let encrypted: Vec<u8> = conn
|
||||
.query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
|
||||
panic!("expected a readable cookie");
|
||||
};
|
||||
assert_eq!(&plain[32..], b"tasty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecoverable_cookie_row_is_deleted_and_counted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
let mut app_bound = b"v20".to_vec();
|
||||
app_bound.extend_from_slice(&[0u8; 48]);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
|
||||
rusqlite::params![app_bound],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_unrecoverable, 1);
|
||||
assert_eq!(report.cookies_migrated, 0);
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let remaining: i64 = conn
|
||||
.query_row("SELECT count(*) FROM cookies", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(remaining, 0, "a row no key can open is dead weight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_store_older_than_chromium_migrates_is_removed_with_a_warning() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
make_cookie_db(&cookie_path, 22);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let source = source_keyring_with(b"x");
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert!(report
|
||||
.warnings
|
||||
.contains(&warning::STORE_TOO_OLD.to_string()));
|
||||
assert!(!cookie_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passwords_are_reencrypted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
std::fs::create_dir_all(&default_dir).unwrap();
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let sealed = seal_as_source(&source, b"hunter2");
|
||||
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE logins(origin_url VARCHAR, password_value BLOB);")
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO logins VALUES('https://example.com', ?1)",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_profile(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.passwords_migrated, 1);
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
let stored: Vec<u8> = conn
|
||||
.query_row("SELECT password_value FROM logins", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else {
|
||||
panic!("password must be readable with the target key");
|
||||
};
|
||||
assert_eq!(plain, b"hunter2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_optional_tables_are_not_an_error() {
|
||||
// `password_notes` and most payment tables only exist on some schemas.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
std::fs::create_dir_all(&default_dir).unwrap();
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE logins(password_value BLOB);")
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let source = source_keyring_with(b"x");
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_profile(&default_dir, &source, &target, &mut report);
|
||||
assert_eq!(report.passwords_migrated, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_preferences_keeps_extensions_and_drops_protection() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("Secure Preferences");
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
"protection": { "macs": { "extensions": { "settings": "deadbeef" } }, "super_mac": "x" },
|
||||
"extensions": { "settings": {
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" },
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "/Applications/Chromium.app/Contents/Resources/x" }
|
||||
}}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut report = ProfileImportReport::default();
|
||||
sanitize_secure_preferences(&path, &mut report);
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert!(value.get("protection").is_none());
|
||||
let settings = value["extensions"]["settings"].as_object().unwrap();
|
||||
assert!(
|
||||
settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||||
"a relative path is the user's real extension and must survive"
|
||||
);
|
||||
assert!(
|
||||
!settings.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
|
||||
"an absolute path points into the source browser's bundle"
|
||||
);
|
||||
assert_eq!(report.extensions_migrated, 1);
|
||||
assert!(report
|
||||
.warnings
|
||||
.contains(&warning::SECURE_PREFERENCES_RESET.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_lose_machine_paths_and_crash_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("Preferences");
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
"download": { "default_directory": "/Users/someone-else/Downloads" },
|
||||
"profile": { "exit_type": "Crashed", "exited_cleanly": false, "name": "Person 1" },
|
||||
"intl": { "accept_languages": "de,de-DE" }
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut report = ProfileImportReport::default();
|
||||
sanitize_preferences(&path, &mut report);
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert!(value["download"].get("default_directory").is_none());
|
||||
assert_eq!(value["profile"]["exit_type"], "Normal");
|
||||
assert_eq!(value["profile"]["exited_cleanly"], true);
|
||||
assert!(value["intl"].get("accept_languages").is_none());
|
||||
assert_eq!(
|
||||
value["profile"]["name"], "Person 1",
|
||||
"unrelated preferences must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_cookies_still_migrate_when_no_source_key_is_available() {
|
||||
// A declined Keychain prompt loses the encrypted rows, but a profile whose
|
||||
// cookies were stored in plaintext has nothing to lose. Reporting zero for
|
||||
// it would be the same silent-empty-import failure this work exists to fix.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut sealed_elsewhere = b"v10".to_vec();
|
||||
sealed_elsewhere.extend_from_slice(&[9u8; 32]);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(1, 'other.example', '', 'sid', '', ?1, '/')",
|
||||
rusqlite::params![sealed_elsewhere],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let empty = SourceKeyring::default();
|
||||
let mut report = ProfileImportReport::default();
|
||||
finalize_profile(&default_dir, &empty, &target, &mut report);
|
||||
|
||||
assert_eq!(
|
||||
report.cookies_migrated, 1,
|
||||
"the plaintext row is recoverable"
|
||||
);
|
||||
assert_eq!(report.cookies_unrecoverable, 1, "the sealed row is not");
|
||||
assert!(report
|
||||
.warnings
|
||||
.contains(&warning::SECRETS_NOT_MIGRATED.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_are_counted_recursively() {
|
||||
let roots = serde_json::json!({
|
||||
"bookmark_bar": { "type": "folder", "children": [
|
||||
{ "type": "url", "url": "https://a.example" },
|
||||
{ "type": "folder", "children": [{ "type": "url", "url": "https://b.example" }] }
|
||||
]},
|
||||
"other": { "type": "folder", "children": [] }
|
||||
});
|
||||
assert_eq!(count_bookmarks(Some(&roots)), 2);
|
||||
}
|
||||
|
||||
/// Load the freshly minted `os_crypt_key` back as a keyring, so tests assert
|
||||
/// against what Wayfern will actually do rather than against our own writer.
|
||||
fn target_as_keyring(user_data_dir: &Path) -> SourceKeyring {
|
||||
let contents =
|
||||
std::fs::read(user_data_dir.join(crate::profile_import::os_crypt::KEY_FILE_NAME)).unwrap();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bytes: [u8; 32] = contents.as_slice().try_into().unwrap();
|
||||
SourceKeyring {
|
||||
v10: Some(CryptoKey::Aes256Gcm(bytes)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
SourceKeyring {
|
||||
v10: Some(CryptoKey::Aes128Cbc(derive_key(
|
||||
&contents,
|
||||
super::super::os_crypt::MAC_ITERATIONS,
|
||||
))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
SourceKeyring {
|
||||
v11: Some(CryptoKey::Aes128Cbc(derive_key(
|
||||
&contents,
|
||||
super::super::os_crypt::POSIX_ITERATIONS,
|
||||
))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "rewrite_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
use super::*;
|
||||
use crate::profile_import::os_crypt::{derive_key, CryptoKey};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn source_keyring_with(password: &[u8]) -> SourceKeyring {
|
||||
// Match the host's CBC iteration count so tests exercise the real path.
|
||||
#[cfg(target_os = "linux")]
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(
|
||||
password,
|
||||
super::super::os_crypt::POSIX_ITERATIONS,
|
||||
));
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(password, super::super::os_crypt::MAC_ITERATIONS));
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return SourceKeyring {
|
||||
v11: Some(key),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
SourceKeyring {
|
||||
v10: Some(key),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn seal_as_source(keyring: &SourceKeyring, plaintext: &[u8]) -> Vec<u8> {
|
||||
let (tag, key) = if let Some(k) = keyring.v10.as_ref() {
|
||||
(b"v10", k)
|
||||
} else {
|
||||
(b"v11", keyring.v11.as_ref().unwrap())
|
||||
};
|
||||
let mut out = tag.to_vec();
|
||||
out.extend_from_slice(&key.encrypt(plaintext).unwrap());
|
||||
out
|
||||
}
|
||||
|
||||
fn make_cookie_db(path: &Path, version: i64) -> Connection {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn
|
||||
.execute_batch(
|
||||
"CREATE TABLE cookies(
|
||||
creation_utc INTEGER NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
top_frame_site_key TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
encrypted_value BLOB NOT NULL DEFAULT '',
|
||||
path TEXT NOT NULL DEFAULT '/'
|
||||
);
|
||||
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO meta VALUES('version', ?1)",
|
||||
[version.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO meta VALUES('last_compatible_version', ?1)",
|
||||
[version.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v24_cookie_is_reframed_for_the_target_key() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let mut framed = Sha256::digest(b"example.com").to_vec();
|
||||
framed.extend_from_slice(b"tasty");
|
||||
let sealed = seal_as_source(&source, &framed);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_migrated, 1);
|
||||
assert_eq!(report.cookies_unrecoverable, 0);
|
||||
|
||||
// Read it back exactly the way Wayfern will.
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let (value, encrypted): (String, Vec<u8>) = conn
|
||||
.query_row("SELECT value, encrypted_value FROM cookies", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
value.is_empty(),
|
||||
"a row with both value and encrypted_value set is dropped at load"
|
||||
);
|
||||
|
||||
let target_keyring = target_as_keyring(dir.path());
|
||||
let Decrypted::Value(plain) = target_keyring.decrypt(&encrypted) else {
|
||||
panic!("target must be able to open what it sealed");
|
||||
};
|
||||
assert_eq!(&plain[..32], &Sha256::digest(b"example.com")[..]);
|
||||
assert_eq!(&plain[32..], b"tasty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_sealed_as_sqlite_text_is_still_recovered() {
|
||||
// Chromium's own v23->v24 migration binds `encrypted_value` with
|
||||
// BindString, so an established profile's cookies carry storage class TEXT
|
||||
// in a column declared BLOB. Reading them as a strict blob returns empty,
|
||||
// which used to blank every cookie and report it as migrated.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let mut framed = Sha256::digest(b"example.com").to_vec();
|
||||
framed.extend_from_slice(b"tasty");
|
||||
let sealed = seal_as_source(&source, &framed);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', '', CAST(?1 AS TEXT), '/')",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
let stored_type: String = conn
|
||||
.query_row("SELECT typeof(encrypted_value) FROM cookies", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stored_type, "text",
|
||||
"fixture must reproduce Chromium's binding"
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_migrated, 1);
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let encrypted: Vec<u8> = conn
|
||||
.query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
|
||||
panic!("expected a readable cookie");
|
||||
};
|
||||
assert_eq!(&plain[32..], b"tasty", "the cookie value must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_note_sealed_as_sqlite_text_is_still_recovered() {
|
||||
// `password_notes.value` is written with BindString on every platform, so
|
||||
// this is not an edge case — it is how the column always looks.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
std::fs::create_dir_all(&default_dir).unwrap();
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let sealed = seal_as_source(&source, b"a private note");
|
||||
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
conn
|
||||
.execute_batch(
|
||||
"CREATE TABLE logins(password_value BLOB);
|
||||
CREATE TABLE password_notes(id INTEGER PRIMARY KEY, value BLOB);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO password_notes(value) VALUES(CAST(?1 AS TEXT))",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_profile(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.logins_migrated, 1);
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
let stored: Vec<u8> = conn
|
||||
.query_row("SELECT value FROM password_notes", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else {
|
||||
panic!("note must be readable with the target key");
|
||||
};
|
||||
assert_eq!(plain, b"a private note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_extension_paths_are_recognised_as_absolute_on_every_host() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("Secure Preferences");
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
"extensions": { "settings": {
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" },
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "C:\\Program Files\\Google\\Chrome\\Application\\151.0.0\\resources\\pdf" },
|
||||
"cccccccccccccccccccccccccccccccc": { "path": "//host/share/ext" }
|
||||
}}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut report = ProfileImportReport::default();
|
||||
sanitize_secure_preferences(&path, &mut report);
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let settings = value["extensions"]["settings"].as_object().unwrap();
|
||||
assert_eq!(
|
||||
settings.len(),
|
||||
1,
|
||||
"a Windows-syntax path is still absolute when imported onto macOS"
|
||||
);
|
||||
assert!(settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
|
||||
assert_eq!(report.extensions_migrated, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_cookie_is_sealed_and_value_cleared() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let source = source_keyring_with(b"unused");
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_migrated, 1);
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let (value, encrypted): (String, Vec<u8>) = conn
|
||||
.query_row("SELECT value, encrypted_value FROM cookies", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert!(value.is_empty());
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
|
||||
panic!("expected a readable cookie");
|
||||
};
|
||||
assert_eq!(&plain[32..], b"plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v23_cookie_has_no_prefix_to_strip_and_is_upgraded_to_v24() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
// v23 stores the bare value, with no SHA256(host) prefix.
|
||||
let sealed = seal_as_source(&source, b"tasty");
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 23);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_migrated, 1);
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let version: String = conn
|
||||
.query_row("SELECT value FROM meta WHERE key='version'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
version, "24",
|
||||
"we wrote v24 framing, so the store must declare v24 or Chromium re-prefixes it"
|
||||
);
|
||||
|
||||
let encrypted: Vec<u8> = conn
|
||||
.query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
|
||||
panic!("expected a readable cookie");
|
||||
};
|
||||
assert_eq!(&plain[32..], b"tasty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecoverable_cookie_row_is_deleted_and_counted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
let mut app_bound = b"v20".to_vec();
|
||||
app_bound.extend_from_slice(&[0u8; 48]);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
|
||||
rusqlite::params![app_bound],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.cookies_unrecoverable, 1);
|
||||
assert_eq!(report.cookies_migrated, 0);
|
||||
let conn = Connection::open(&cookie_path).unwrap();
|
||||
let remaining: i64 = conn
|
||||
.query_row("SELECT count(*) FROM cookies", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(remaining, 0, "a row no key can open is dead weight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_store_older_than_chromium_migrates_is_removed_with_a_warning() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
make_cookie_db(&cookie_path, 22);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let source = source_keyring_with(b"x");
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_cookies(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert!(report
|
||||
.warnings
|
||||
.contains(&warning::STORE_TOO_OLD.to_string()));
|
||||
assert!(!cookie_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passwords_are_reencrypted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
std::fs::create_dir_all(&default_dir).unwrap();
|
||||
|
||||
let source = source_keyring_with(b"source-password");
|
||||
let sealed = seal_as_source(&source, b"hunter2");
|
||||
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE logins(origin_url VARCHAR, password_value BLOB);")
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO logins VALUES('https://example.com', ?1)",
|
||||
rusqlite::params![sealed],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_profile(&default_dir, &source, &target, &mut report);
|
||||
|
||||
assert_eq!(report.logins_migrated, 1);
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
let stored: Vec<u8> = conn
|
||||
.query_row("SELECT password_value FROM logins", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else {
|
||||
panic!("password must be readable with the target key");
|
||||
};
|
||||
assert_eq!(plain, b"hunter2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_optional_tables_are_not_an_error() {
|
||||
// `password_notes` and most payment tables only exist on some schemas.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
std::fs::create_dir_all(&default_dir).unwrap();
|
||||
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE logins(password_value BLOB);")
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let source = source_keyring_with(b"x");
|
||||
let mut report = ProfileImportReport::default();
|
||||
reencrypt_profile(&default_dir, &source, &target, &mut report);
|
||||
assert_eq!(report.logins_migrated, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_preferences_keeps_extensions_and_drops_protection() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("Secure Preferences");
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
"protection": { "macs": { "extensions": { "settings": "deadbeef" } }, "super_mac": "x" },
|
||||
"extensions": { "settings": {
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" },
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "/Applications/Chromium.app/Contents/Resources/x" }
|
||||
}}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut report = ProfileImportReport::default();
|
||||
sanitize_secure_preferences(&path, &mut report);
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert!(value.get("protection").is_none());
|
||||
let settings = value["extensions"]["settings"].as_object().unwrap();
|
||||
assert!(
|
||||
settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||||
"a relative path is the user's real extension and must survive"
|
||||
);
|
||||
assert!(
|
||||
!settings.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
|
||||
"an absolute path points into the source browser's bundle"
|
||||
);
|
||||
assert_eq!(report.extensions_migrated, 1);
|
||||
assert!(report
|
||||
.warnings
|
||||
.contains(&warning::SECURE_PREFERENCES_RESET.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_lose_machine_paths_and_crash_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("Preferences");
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
"download": { "default_directory": "/Users/someone-else/Downloads" },
|
||||
"profile": { "exit_type": "Crashed", "exited_cleanly": false, "name": "Person 1" },
|
||||
"intl": { "accept_languages": "de,de-DE" }
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut report = ProfileImportReport::default();
|
||||
sanitize_preferences(&path, &mut report);
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert!(value["download"].get("default_directory").is_none());
|
||||
assert_eq!(value["profile"]["exit_type"], "Normal");
|
||||
assert_eq!(value["profile"]["exited_cleanly"], true);
|
||||
assert!(value["intl"].get("accept_languages").is_none());
|
||||
assert_eq!(
|
||||
value["profile"]["name"], "Person 1",
|
||||
"unrelated preferences must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_cookies_still_migrate_when_no_source_key_is_available() {
|
||||
// A declined Keychain prompt loses the encrypted rows, but a profile whose
|
||||
// cookies were stored in plaintext has nothing to lose. Reporting zero for
|
||||
// it would be the same silent-empty-import failure this work exists to fix.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
let cookie_path = layout::host_cookie_path(&default_dir);
|
||||
|
||||
let conn = make_cookie_db(&cookie_path, 24);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut sealed_elsewhere = b"v10".to_vec();
|
||||
sealed_elsewhere.extend_from_slice(&[9u8; 32]);
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
|
||||
VALUES(1, 'other.example', '', 'sid', '', ?1, '/')",
|
||||
rusqlite::params![sealed_elsewhere],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let target = TargetKey::ensure(dir.path()).unwrap();
|
||||
let empty = SourceKeyring::default();
|
||||
let mut report = ProfileImportReport::default();
|
||||
finalize_profile(&default_dir, &empty, &target, &mut report);
|
||||
|
||||
assert_eq!(
|
||||
report.cookies_migrated, 1,
|
||||
"the plaintext row is recoverable"
|
||||
);
|
||||
assert_eq!(report.cookies_unrecoverable, 1, "the sealed row is not");
|
||||
assert!(report
|
||||
.warnings
|
||||
.contains(&warning::SECRETS_NOT_MIGRATED.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_are_counted_recursively() {
|
||||
let roots = serde_json::json!({
|
||||
"bookmark_bar": { "type": "folder", "children": [
|
||||
{ "type": "url", "url": "https://a.example" },
|
||||
{ "type": "folder", "children": [{ "type": "url", "url": "https://b.example" }] }
|
||||
]},
|
||||
"other": { "type": "folder", "children": [] }
|
||||
});
|
||||
assert_eq!(count_bookmarks(Some(&roots)), 2);
|
||||
}
|
||||
|
||||
/// Load the freshly minted `os_crypt_key` back as a keyring, so tests assert
|
||||
/// against what Wayfern will actually do rather than against our own writer.
|
||||
fn target_as_keyring(user_data_dir: &Path) -> SourceKeyring {
|
||||
let contents =
|
||||
std::fs::read(user_data_dir.join(crate::profile_import::os_crypt::KEY_FILE_NAME)).unwrap();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bytes: [u8; 32] = contents.as_slice().try_into().unwrap();
|
||||
SourceKeyring {
|
||||
v10: Some(CryptoKey::Aes256Gcm(bytes)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
SourceKeyring {
|
||||
v10: Some(CryptoKey::Aes128Cbc(derive_key(
|
||||
&contents,
|
||||
super::super::os_crypt::MAC_ITERATIONS,
|
||||
))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
SourceKeyring {
|
||||
v11: Some(CryptoKey::Aes128Cbc(derive_key(
|
||||
&contents,
|
||||
super::super::os_crypt::POSIX_ITERATIONS,
|
||||
))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -711,7 +711,7 @@ impl ProfileImporter {
|
||||
.collect();
|
||||
|
||||
let total = items.len();
|
||||
let mut results = Vec::with_capacity(total);
|
||||
let mut results = Vec::new();
|
||||
let mut imported_count = 0usize;
|
||||
let mut skipped_count = 0usize;
|
||||
let mut failed_count = 0usize;
|
||||
@@ -857,10 +857,10 @@ impl ProfileImporter {
|
||||
|
||||
let profile_id = uuid::Uuid::new_v4();
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
|
||||
let new_profile_data_dir = new_profile_uuid_dir.join("profile");
|
||||
let new_profile_dir = profiles_dir.join(profile_id.to_string());
|
||||
let new_profile_data_dir = new_profile_dir.join("profile");
|
||||
|
||||
create_dir_all(&new_profile_uuid_dir)?;
|
||||
create_dir_all(&new_profile_dir)?;
|
||||
create_dir_all(&new_profile_data_dir)?;
|
||||
|
||||
// Profile dirs can be multiple GB and the migration hits SQLite and the
|
||||
@@ -884,7 +884,7 @@ impl ProfileImporter {
|
||||
// every other error path here, or the half-copied — possibly multi-GB
|
||||
// — directory is orphaned with no metadata pointing at it, so nothing
|
||||
// ever reclaims it.
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
let _ = fs::remove_dir_all(&new_profile_dir);
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "INTERNAL_ERROR",
|
||||
@@ -898,7 +898,7 @@ impl ProfileImporter {
|
||||
let report = match migrate_result {
|
||||
Ok(report) => report,
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
let _ = fs::remove_dir_all(&new_profile_dir);
|
||||
// Structured codes (an unimportable source, a running browser) pass
|
||||
// through so the frontend can translate them; anything else is
|
||||
// internal.
|
||||
@@ -915,7 +915,7 @@ impl ProfileImporter {
|
||||
let version = match self.get_default_version_for_browser(mapped) {
|
||||
Ok(version) => version,
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
let _ = fs::remove_dir_all(&new_profile_dir);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -1013,7 +1013,7 @@ impl ProfileImporter {
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
let _ = fs::remove_dir_all(&new_profile_dir);
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "INTERNAL_ERROR",
|
||||
@@ -1099,9 +1099,9 @@ impl ProfileImporter {
|
||||
new_profile_name,
|
||||
source_path.display(),
|
||||
report.cookies_migrated,
|
||||
report.passwords_migrated,
|
||||
report.logins_migrated,
|
||||
report.history_entries,
|
||||
report.cookies_unrecoverable + report.passwords_unrecoverable,
|
||||
report.cookies_unrecoverable + report.logins_unrecoverable,
|
||||
report.warnings
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3120,7 +3120,7 @@ this line has no colon\r\n\
|
||||
] {
|
||||
assert!(
|
||||
!as_text.contains(secret),
|
||||
"{secret:?} reached the wire in the clear on an httpstls upstream"
|
||||
"a CONNECT detail or credential reached the wire in the clear on an httpstls upstream"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ async fn socks5_udp_associate(settings: &ProxySettings) -> std::io::Result<UdpSu
|
||||
.username
|
||||
.as_deref()
|
||||
.filter(|user| !user.is_empty())
|
||||
.map(|user| (user, settings.password.as_deref().unwrap_or("")));
|
||||
.map(|user| (user, settings.password.as_deref().unwrap_or_default()));
|
||||
|
||||
let greeting: Vec<u8> = match credentials {
|
||||
Some(_) => vec![SOCKS5, 2, AUTH_NONE, AUTH_USERPASS],
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
//! and the work is sitting in cloud storage. This is the window that used to
|
||||
//! be wide open.
|
||||
|
||||
use crate::log_redaction::ShortId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
@@ -287,8 +288,9 @@ pub fn reconcile(live_session_ids: &std::collections::HashSet<String>) -> Vec<St
|
||||
.collect();
|
||||
for (profile_id, session_id) in stale {
|
||||
log::info!(
|
||||
"Remote session {session_id} for profile {profile_id} ended while this machine was not \
|
||||
watching; its work is still in cloud storage"
|
||||
"Remote session {} for profile {profile_id} ended while this machine was not \
|
||||
watching; its work is still in cloud storage",
|
||||
ShortId(&session_id)
|
||||
);
|
||||
store.insert(
|
||||
profile_id.clone(),
|
||||
|
||||
+301
-113
@@ -2,12 +2,6 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Key, Nonce,
|
||||
};
|
||||
use rand::RngExt;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TableSortingSettings {
|
||||
pub column: String, // Column to sort by: "name", "browser", "status"
|
||||
@@ -98,6 +92,26 @@ pub struct AppSettings {
|
||||
/// `profile::trash::configured_retention_days`.
|
||||
#[serde(default = "default_trash_retention_days")]
|
||||
pub trash_retention_days: u32,
|
||||
/// Feature tips. Whether one tip the user has not seen yet may open by
|
||||
/// itself shortly after launch. Off is the user's choice, made in the tips
|
||||
/// dialog.
|
||||
#[serde(default = "default_tips_auto_show")]
|
||||
pub tips_auto_show: bool,
|
||||
/// Ids of the tips that have been shown, in the automatic or the browse
|
||||
/// flow, so the automatic flow never repeats one.
|
||||
#[serde(default)]
|
||||
pub tips_seen: Vec<String>,
|
||||
/// Unix seconds of the last tip that opened by itself. Paces the automatic
|
||||
/// flow to one tip a day at most.
|
||||
#[serde(default)]
|
||||
pub tips_last_auto_shown_at: Option<u64>,
|
||||
/// Cloud user ids that have had the paid-plan welcome.
|
||||
#[serde(default)]
|
||||
pub paid_welcome_seen_for: Vec<String>,
|
||||
/// The plan status last observed per cloud user id, `"free"` or `"paid"`.
|
||||
/// A change from free to paid is what earns the paid-plan welcome.
|
||||
#[serde(default)]
|
||||
pub cloud_plan_memory: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
@@ -118,6 +132,18 @@ fn default_trash_retention_days() -> u32 {
|
||||
crate::profile::trash::DEFAULT_RETENTION_DAYS
|
||||
}
|
||||
|
||||
fn default_tips_auto_show() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// How long the automatic tip flow waits between two tips, so a busy day of
|
||||
/// restarts does not turn into a tip on every launch.
|
||||
pub const TIPS_AUTO_INTERVAL_SECS: u64 = 20 * 60 * 60;
|
||||
|
||||
/// The plan status remembered per cloud user.
|
||||
const PLAN_STATUS_PAID: &str = "paid";
|
||||
const PLAN_STATUS_FREE: &str = "free";
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -144,6 +170,11 @@ impl Default for AppSettings {
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
trash_retention_days: crate::profile::trash::DEFAULT_RETENTION_DAYS,
|
||||
tips_auto_show: true,
|
||||
tips_seen: Vec::new(),
|
||||
tips_last_auto_shown_at: None,
|
||||
paid_welcome_seen_for: Vec::new(),
|
||||
cloud_plan_memory: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +190,20 @@ pub struct StoredMcpRemoteKey {
|
||||
|
||||
pub struct SettingsManager;
|
||||
|
||||
/// Write `content` to `path` in one step: to a sibling first, then renamed
|
||||
/// into place. A reader that opens the file mid-write, and there are several
|
||||
/// at startup, sees the old settings or the new ones, never an empty file
|
||||
/// that parses as the defaults.
|
||||
fn write_whole(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
|
||||
let staging = path.with_extension("json.tmp");
|
||||
fs::write(&staging, content)?;
|
||||
if let Err(e) = fs::rename(&staging, path) {
|
||||
let _ = fs::remove_file(&staging);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SettingsManager {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self
|
||||
@@ -213,7 +258,7 @@ impl SettingsManager {
|
||||
|
||||
let settings_file = self.get_settings_file();
|
||||
let json = serde_json::to_string_pretty(&on_disk)?;
|
||||
fs::write(settings_file, json)?;
|
||||
write_whole(&settings_file, json.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -240,55 +285,23 @@ impl SettingsManager {
|
||||
|
||||
let sorting_file = self.get_table_sorting_file();
|
||||
let json = serde_json::to_string_pretty(sorting)?;
|
||||
fs::write(sorting_file, json)?;
|
||||
write_whole(&sorting_file, json.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_vault_password() -> String {
|
||||
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
|
||||
}
|
||||
|
||||
/// Encrypt `secret` into `file` under the vault password.
|
||||
/// Seal `secret` into `file`.
|
||||
///
|
||||
/// One implementation for every secret this manager keeps on disk. The API,
|
||||
/// MCP and sync tokens each carried their own copy of this routine, and the
|
||||
/// remote MCP credential would have been the fourth; the file layout is the
|
||||
/// same for all of them and only the five-byte header tells them apart.
|
||||
/// One implementation for every secret this manager keeps on disk, in
|
||||
/// `crate::vault`: the API, MCP and sync tokens and the remote MCP
|
||||
/// credential share the layout, and only the five-byte header tells them
|
||||
/// apart.
|
||||
fn encrypt_to_file(
|
||||
file: &std::path::Path,
|
||||
header: &[u8; 5],
|
||||
secret: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let vault_password = Self::get_vault_password();
|
||||
let salt_bytes: [u8; 16] = rand::rng().random();
|
||||
let salt = crate::sync::encryption::encode_salt(&salt_bytes);
|
||||
let key_bytes =
|
||||
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce_bytes: [u8; 12] = rand::rng().random();
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, secret.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {e}"))?;
|
||||
|
||||
let mut file_data = Vec::new();
|
||||
file_data.extend_from_slice(header);
|
||||
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
|
||||
let salt_str = salt.as_str();
|
||||
file_data.push(salt_str.len() as u8);
|
||||
file_data.extend_from_slice(salt_str.as_bytes());
|
||||
file_data.extend_from_slice(&nonce);
|
||||
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
|
||||
file_data.extend_from_slice(&ciphertext);
|
||||
|
||||
std::fs::write(file, file_data)?;
|
||||
crate::app_dirs::restrict_to_owner(file);
|
||||
crate::vault::seal(file, &Self::magic(header), secret)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -301,74 +314,15 @@ impl SettingsManager {
|
||||
file: &std::path::Path,
|
||||
header: &[u8; 5],
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
if !file.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(crate::vault::open(file, &Self::magic(header))?)
|
||||
}
|
||||
|
||||
let file_data = std::fs::read(file)?;
|
||||
|
||||
if file_data.len() < 6 || &file_data[0..5] != header {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let version = file_data[5];
|
||||
if version != 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut offset = 6;
|
||||
if offset >= file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_len = file_data[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
if offset + salt_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_bytes = &file_data[offset..offset + salt_len];
|
||||
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
|
||||
let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?;
|
||||
offset += salt_len;
|
||||
|
||||
if offset + 12 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid nonce length")?;
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
offset += 12;
|
||||
|
||||
if offset + 4 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext_len = u32::from_le_bytes([
|
||||
file_data[offset],
|
||||
file_data[offset + 1],
|
||||
file_data[offset + 2],
|
||||
file_data[offset + 3],
|
||||
]) as usize;
|
||||
offset += 4;
|
||||
|
||||
if offset + ciphertext_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext = &file_data[offset..offset + ciphertext_len];
|
||||
|
||||
let vault_password = Self::get_vault_password();
|
||||
let key_bytes =
|
||||
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let plaintext = cipher
|
||||
.decrypt(&nonce, ciphertext)
|
||||
.map_err(|_| "Decryption failed")?;
|
||||
|
||||
match String::from_utf8(plaintext) {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
/// The header plus the layout version every file of this manager carries.
|
||||
fn magic(header: &[u8; 5]) -> [u8; 6] {
|
||||
let mut magic = [0u8; 6];
|
||||
magic[..5].copy_from_slice(header);
|
||||
magic[5] = 2;
|
||||
magic
|
||||
}
|
||||
|
||||
fn remove_secret_file(file: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -959,6 +913,158 @@ pub async fn complete_onboarding() -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))
|
||||
}
|
||||
|
||||
/// What the tips dialog needs to decide what to open and what to skip.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct TipsState {
|
||||
pub auto_show: bool,
|
||||
pub seen: Vec<String>,
|
||||
pub last_auto_shown_at: Option<u64>,
|
||||
/// Whether the automatic flow may open a tip right now: it is switched on
|
||||
/// and the last automatic tip is old enough.
|
||||
pub auto_due: bool,
|
||||
}
|
||||
|
||||
impl TipsState {
|
||||
fn of(settings: &AppSettings, now: u64) -> Self {
|
||||
Self {
|
||||
auto_show: settings.tips_auto_show,
|
||||
seen: settings.tips_seen.clone(),
|
||||
last_auto_shown_at: settings.tips_last_auto_shown_at,
|
||||
auto_due: settings.tips_auto_show
|
||||
&& settings
|
||||
.tips_last_auto_shown_at
|
||||
.is_none_or(|last| now.saturating_sub(last) >= TIPS_AUTO_INTERVAL_SECS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialises every read-modify-write of the tips fields. Two tips shown in
|
||||
/// quick succession are two concurrent commands, and without this the second
|
||||
/// load could precede the first save and drop it.
|
||||
static TIPS_WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Remembers a tip as shown. `auto` marks it as the tip that opened by
|
||||
/// itself, which restarts the daily pacing.
|
||||
fn record_tip_seen(settings: &mut AppSettings, tip_id: &str, auto: bool, now: u64) {
|
||||
if !settings.tips_seen.iter().any(|id| id == tip_id) {
|
||||
settings.tips_seen.push(tip_id.to_string());
|
||||
}
|
||||
if auto {
|
||||
settings.tips_last_auto_shown_at = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the plan status seen for a cloud account and answers whether the
|
||||
/// paid-plan welcome is due for it.
|
||||
///
|
||||
/// The welcome is for an account that just became paid: one this desktop last
|
||||
/// saw as free, or one it sees for the first time right after the user signed
|
||||
/// in (they bought a plan on the website and came back). An account that was
|
||||
/// already paid the last time anybody looked, or that turns up paid in an old
|
||||
/// session after an app update, is not new to its plan and is recorded as
|
||||
/// greeted without a dialog.
|
||||
fn paid_welcome_due(
|
||||
settings: &mut AppSettings,
|
||||
user_id: &str,
|
||||
paid: bool,
|
||||
fresh_login: bool,
|
||||
) -> bool {
|
||||
let status = if paid {
|
||||
PLAN_STATUS_PAID
|
||||
} else {
|
||||
PLAN_STATUS_FREE
|
||||
};
|
||||
let previous = settings
|
||||
.cloud_plan_memory
|
||||
.insert(user_id.to_string(), status.to_string());
|
||||
if !paid {
|
||||
return false;
|
||||
}
|
||||
if settings
|
||||
.paid_welcome_seen_for
|
||||
.iter()
|
||||
.any(|id| id == user_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let due = match previous.as_deref() {
|
||||
Some(PLAN_STATUS_FREE) => true,
|
||||
Some(_) => false,
|
||||
None => fresh_login,
|
||||
};
|
||||
settings.paid_welcome_seen_for.push(user_id.to_string());
|
||||
due
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tips_state() -> Result<TipsState, String> {
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
Ok(TipsState::of(&settings, unix_now()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mark_tip_seen(tip_id: String, auto: bool) -> Result<TipsState, String> {
|
||||
let _serial = TIPS_WRITE
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let manager = SettingsManager::instance();
|
||||
let mut settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
let now = unix_now();
|
||||
record_tip_seen(&mut settings, &tip_id, auto, now);
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))?;
|
||||
Ok(TipsState::of(&settings, now))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_tips_auto_show(enabled: bool) -> Result<TipsState, String> {
|
||||
let _serial = TIPS_WRITE
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let manager = SettingsManager::instance();
|
||||
let mut settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
settings.tips_auto_show = enabled;
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))?;
|
||||
Ok(TipsState::of(&settings, unix_now()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn observe_cloud_plan(
|
||||
user_id: String,
|
||||
paid: bool,
|
||||
fresh_login: bool,
|
||||
) -> Result<bool, String> {
|
||||
let _serial = TIPS_WRITE
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let manager = SettingsManager::instance();
|
||||
let mut settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
let due = paid_welcome_due(&mut settings, &user_id, paid, fresh_login);
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))?;
|
||||
Ok(due)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_system_language() -> String {
|
||||
sys_locale::get_locale()
|
||||
@@ -1031,6 +1137,83 @@ mod tests {
|
||||
let (_manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tips_state_defaults_to_automatic_and_due() {
|
||||
let settings = AppSettings::default();
|
||||
let state = TipsState::of(&settings, 1_000_000);
|
||||
assert!(state.auto_show);
|
||||
assert!(state.seen.is_empty());
|
||||
assert_eq!(state.last_auto_shown_at, None);
|
||||
assert!(state.auto_due, "a fresh install owes its first tip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tips_seen_dedupes_and_paces_the_automatic_flow() {
|
||||
let mut settings = AppSettings::default();
|
||||
record_tip_seen(&mut settings, "dns", false, 100);
|
||||
record_tip_seen(&mut settings, "dns", false, 200);
|
||||
assert_eq!(settings.tips_seen, vec!["dns".to_string()]);
|
||||
assert_eq!(
|
||||
settings.tips_last_auto_shown_at, None,
|
||||
"a browsed tip must not restart the daily pacing"
|
||||
);
|
||||
|
||||
record_tip_seen(&mut settings, "proxy", true, 1_000);
|
||||
assert_eq!(settings.tips_last_auto_shown_at, Some(1_000));
|
||||
assert!(
|
||||
!TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS - 1).auto_due,
|
||||
"the next automatic tip waits a day"
|
||||
);
|
||||
assert!(TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS).auto_due);
|
||||
|
||||
settings.tips_auto_show = false;
|
||||
assert!(
|
||||
!TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS * 3).auto_due,
|
||||
"switched off means never due"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paid_welcome_is_due_once_when_an_account_turns_paid() {
|
||||
let mut settings = AppSettings::default();
|
||||
assert!(!paid_welcome_due(&mut settings, "u1", false, true));
|
||||
assert!(
|
||||
settings.paid_welcome_seen_for.is_empty(),
|
||||
"a free account is not greeted, so nothing is recorded"
|
||||
);
|
||||
assert!(
|
||||
paid_welcome_due(&mut settings, "u1", true, false),
|
||||
"free to paid is the upgrade the welcome exists for"
|
||||
);
|
||||
assert!(!paid_welcome_due(&mut settings, "u1", true, true), "once");
|
||||
assert_eq!(settings.paid_welcome_seen_for, vec!["u1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paid_welcome_greets_a_fresh_sign_in_but_not_an_old_paid_session() {
|
||||
let mut settings = AppSettings::default();
|
||||
assert!(
|
||||
paid_welcome_due(&mut settings, "bought-on-web", true, true),
|
||||
"first sight right after signing in: they came back from checkout"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!paid_welcome_due(&mut settings, "long-paid", true, false),
|
||||
"an app update on a machine that was already paid is not a new plan"
|
||||
);
|
||||
assert!(
|
||||
!paid_welcome_due(&mut settings, "long-paid", true, true),
|
||||
"and it is recorded as greeted, so it never fires later"
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.cloud_plan_memory
|
||||
.get("long-paid")
|
||||
.map(String::as_str),
|
||||
Some(PLAN_STATUS_PAID)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_app_settings() {
|
||||
let default_settings = AppSettings::default();
|
||||
@@ -1105,6 +1288,11 @@ mod tests {
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
trash_retention_days: 14,
|
||||
tips_auto_show: true,
|
||||
tips_seen: Vec::new(),
|
||||
tips_last_auto_shown_at: None,
|
||||
paid_welcome_seen_for: Vec::new(),
|
||||
cloud_plan_memory: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let save_result = manager.save_settings(&test_settings);
|
||||
|
||||
@@ -19,7 +19,9 @@ use base64::{
|
||||
/// silently lock every user out of their encrypted data, so the defaults are
|
||||
/// pinned by the test below rather than trusted.
|
||||
pub fn derive_vault_key(password: &[u8], salt: &[u8]) -> Result<[u8; 32], String> {
|
||||
let mut key = [0u8; 32];
|
||||
// Filled by the KDF. It starts as noise rather than zeros so that no
|
||||
// failure path can ever hand back an all-zero key.
|
||||
let mut key: [u8; 32] = rand::rng().random();
|
||||
Argon2::default()
|
||||
.hash_password_into(password, salt, &mut key)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
@@ -42,9 +44,6 @@ use rand::RngExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
|
||||
const E2E_FILE_VERSION: u8 = 1;
|
||||
|
||||
/// Argon2id is intentionally expensive (~80–150 ms per call). During an
|
||||
/// encryption rollover, every synced entity (proxy, group, vpn, extension,
|
||||
/// extension group, profile metadata) goes through `derive_profile_key`,
|
||||
@@ -61,10 +60,7 @@ fn password_fingerprint(pwd: &str) -> [u8; 32] {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(pwd.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&result);
|
||||
out
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
fn invalidate_key_cache() {
|
||||
@@ -77,122 +73,16 @@ fn get_e2e_password_path() -> std::path::PathBuf {
|
||||
crate::app_dirs::settings_dir().join("e2e_password.dat")
|
||||
}
|
||||
|
||||
fn get_vault_password() -> String {
|
||||
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
|
||||
}
|
||||
/// Header plus layout version of the sync password file.
|
||||
const E2E_MAGIC: [u8; 6] = *b"DBE2E\x01";
|
||||
|
||||
pub fn store_e2e_password(password: &str) -> Result<(), String> {
|
||||
invalidate_key_cache();
|
||||
let file_path = get_e2e_password_path();
|
||||
|
||||
if let Some(parent) = file_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
}
|
||||
|
||||
let vault_password = get_vault_password();
|
||||
let salt_bytes: [u8; 16] = rand::rng().random();
|
||||
let salt = encode_salt(&salt_bytes);
|
||||
let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce_bytes: [u8; 12] = rand::rng().random();
|
||||
let nonce = aes_gcm::Nonce::from(nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, password.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {e}"))?;
|
||||
|
||||
let mut file_data = Vec::new();
|
||||
file_data.extend_from_slice(E2E_FILE_HEADER);
|
||||
file_data.push(E2E_FILE_VERSION);
|
||||
|
||||
let salt_str = salt.as_str();
|
||||
file_data.push(salt_str.len() as u8);
|
||||
file_data.extend_from_slice(salt_str.as_bytes());
|
||||
file_data.extend_from_slice(&nonce);
|
||||
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
|
||||
file_data.extend_from_slice(&ciphertext);
|
||||
|
||||
std::fs::write(&file_path, file_data)
|
||||
.map_err(|e| format!("Failed to write e2e password file: {e}"))?;
|
||||
crate::app_dirs::restrict_to_owner(std::path::Path::new(&file_path));
|
||||
|
||||
Ok(())
|
||||
crate::vault::seal(&get_e2e_password_path(), &E2E_MAGIC, password)
|
||||
}
|
||||
|
||||
pub fn load_e2e_password() -> Result<Option<String>, String> {
|
||||
let file_path = get_e2e_password_path();
|
||||
if !file_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file_data =
|
||||
std::fs::read(&file_path).map_err(|e| format!("Failed to read e2e password file: {e}"))?;
|
||||
|
||||
if file_data.len() < E2E_FILE_HEADER.len() + 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if &file_data[..E2E_FILE_HEADER.len()] != E2E_FILE_HEADER {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let version = file_data[E2E_FILE_HEADER.len()];
|
||||
if version != E2E_FILE_VERSION {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut offset = E2E_FILE_HEADER.len() + 1;
|
||||
|
||||
if offset >= file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_len = file_data[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
if offset + salt_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_str = std::str::from_utf8(&file_data[offset..offset + salt_len])
|
||||
.map_err(|_| "Invalid salt encoding")?;
|
||||
offset += salt_len;
|
||||
|
||||
let salt_bytes = decode_salt(salt_str)?;
|
||||
|
||||
if offset + 12 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid nonce")?;
|
||||
let nonce = aes_gcm::Nonce::from(nonce_bytes);
|
||||
offset += 12;
|
||||
|
||||
if offset + 4 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext_len =
|
||||
u32::from_le_bytes(file_data[offset..offset + 4].try_into().unwrap()) as usize;
|
||||
offset += 4;
|
||||
|
||||
if offset + ciphertext_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext = &file_data[offset..offset + ciphertext_len];
|
||||
|
||||
let vault_password = get_vault_password();
|
||||
let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(&nonce, ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {e}"))?;
|
||||
|
||||
let password =
|
||||
String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8 in password: {e}"))?;
|
||||
|
||||
Ok(Some(password))
|
||||
crate::vault::open(&get_e2e_password_path(), &E2E_MAGIC)
|
||||
}
|
||||
|
||||
pub fn has_e2e_password() -> bool {
|
||||
@@ -381,138 +271,9 @@ async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let key = [42u8; 32];
|
||||
let plaintext = b"Hello, World!";
|
||||
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_empty_data() {
|
||||
let key = [1u8; 32];
|
||||
let plaintext = b"";
|
||||
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_large_data() {
|
||||
let key = [7u8; 32];
|
||||
let plaintext = vec![0xABu8; 1_048_576]; // 1MB
|
||||
let encrypted = encrypt_bytes(&key, &plaintext).unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_keys_different_ciphertext() {
|
||||
let key1 = [1u8; 32];
|
||||
let key2 = [2u8; 32];
|
||||
let plaintext = b"same data";
|
||||
let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap();
|
||||
let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap();
|
||||
// Nonces are random so ciphertexts will differ regardless,
|
||||
// but decrypting with wrong key should fail
|
||||
assert!(decrypt_bytes(&key2, &encrypted1).is_err());
|
||||
assert!(decrypt_bytes(&key1, &encrypted2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_uniqueness() {
|
||||
let key = [5u8; 32];
|
||||
let plaintext = b"same data encrypted twice";
|
||||
let encrypted1 = encrypt_bytes(&key, plaintext).unwrap();
|
||||
let encrypted2 = encrypt_bytes(&key, plaintext).unwrap();
|
||||
// Different nonces should produce different ciphertext
|
||||
assert_ne!(encrypted1, encrypted2);
|
||||
// But both should decrypt to the same plaintext
|
||||
assert_eq!(
|
||||
decrypt_bytes(&key, &encrypted1).unwrap(),
|
||||
decrypt_bytes(&key, &encrypted2).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_key_fails() {
|
||||
let key = [10u8; 32];
|
||||
let wrong_key = [20u8; 32];
|
||||
let plaintext = b"secret data";
|
||||
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
|
||||
assert!(decrypt_bytes(&wrong_key, &encrypted).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_derivation_deterministic() {
|
||||
let salt = generate_salt();
|
||||
let key1 = derive_profile_key("my_password", &salt).unwrap();
|
||||
let key2 = derive_profile_key("my_password", &salt).unwrap();
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_derivation_different_salts() {
|
||||
let salt1 = generate_salt();
|
||||
let salt2 = generate_salt();
|
||||
let key1 = derive_profile_key("my_password", &salt1).unwrap();
|
||||
let key2 = derive_profile_key("my_password", &salt2).unwrap();
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_salt_generation_unique() {
|
||||
let salt1 = generate_salt();
|
||||
let salt2 = generate_salt();
|
||||
assert_ne!(salt1, salt2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_storage_roundtrip() {
|
||||
let password = "test_password_12345";
|
||||
store_e2e_password(password).unwrap();
|
||||
assert!(has_e2e_password());
|
||||
let loaded = load_e2e_password().unwrap();
|
||||
assert_eq!(loaded, Some(password.to_string()));
|
||||
remove_e2e_password().unwrap();
|
||||
assert!(!has_e2e_password());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_too_short_data() {
|
||||
let key = [1u8; 32];
|
||||
assert!(decrypt_bytes(&key, &[0u8; 5]).is_err());
|
||||
}
|
||||
}
|
||||
#[path = "encryption_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod vault_key_tests {
|
||||
use super::{decode_salt, derive_vault_key, encode_salt};
|
||||
|
||||
/// A stored vault is only readable while this vector holds. It pins the
|
||||
/// Argon2id parameters and the salt encoding together: a dependency bump
|
||||
/// that changed either would fail here instead of at the user's data.
|
||||
#[test]
|
||||
fn vault_key_derivation_is_pinned() {
|
||||
let key = derive_vault_key(b"correct horse battery staple", &[7u8; 16]).unwrap();
|
||||
let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(
|
||||
hex,
|
||||
"799f12b9e17710824482d829835acb69f5a9355bf774c4f07342823b11b90928"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salt_encoding_round_trips_without_padding() {
|
||||
let salt = [0u8, 1, 2, 3, 250, 251, 252, 253, 254, 255, 9, 8, 7, 6, 5, 4];
|
||||
let encoded = encode_salt(&salt);
|
||||
assert!(!encoded.contains('='), "PHC B64 carries no padding");
|
||||
assert_eq!(decode_salt(&encoded).unwrap(), salt);
|
||||
assert!(decode_salt("not*valid").is_err());
|
||||
}
|
||||
}
|
||||
#[path = "encryption_vault_key_tests.rs"]
|
||||
mod vault_key_tests;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let key = [42u8; 32];
|
||||
let plaintext = b"Hello, World!";
|
||||
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_empty_data() {
|
||||
let key = [1u8; 32];
|
||||
let plaintext = b"";
|
||||
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_large_data() {
|
||||
let key = [7u8; 32];
|
||||
let plaintext = vec![0xABu8; 1_048_576]; // 1MB
|
||||
let encrypted = encrypt_bytes(&key, &plaintext).unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_keys_different_ciphertext() {
|
||||
let key1 = [1u8; 32];
|
||||
let key2 = [2u8; 32];
|
||||
let plaintext = b"same data";
|
||||
let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap();
|
||||
let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap();
|
||||
// Nonces are random so ciphertexts will differ regardless,
|
||||
// but decrypting with wrong key should fail
|
||||
assert!(decrypt_bytes(&key2, &encrypted1).is_err());
|
||||
assert!(decrypt_bytes(&key1, &encrypted2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_uniqueness() {
|
||||
let key = [5u8; 32];
|
||||
let plaintext = b"same data encrypted twice";
|
||||
let encrypted1 = encrypt_bytes(&key, plaintext).unwrap();
|
||||
let encrypted2 = encrypt_bytes(&key, plaintext).unwrap();
|
||||
// Different nonces should produce different ciphertext
|
||||
assert_ne!(encrypted1, encrypted2);
|
||||
// But both should decrypt to the same plaintext
|
||||
assert_eq!(
|
||||
decrypt_bytes(&key, &encrypted1).unwrap(),
|
||||
decrypt_bytes(&key, &encrypted2).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_key_fails() {
|
||||
let key = [10u8; 32];
|
||||
let wrong_key = [20u8; 32];
|
||||
let plaintext = b"secret data";
|
||||
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
|
||||
assert!(decrypt_bytes(&wrong_key, &encrypted).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_derivation_deterministic() {
|
||||
let salt = generate_salt();
|
||||
let key1 = derive_profile_key("my_password", &salt).unwrap();
|
||||
let key2 = derive_profile_key("my_password", &salt).unwrap();
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_derivation_different_salts() {
|
||||
let salt1 = generate_salt();
|
||||
let salt2 = generate_salt();
|
||||
let key1 = derive_profile_key("my_password", &salt1).unwrap();
|
||||
let key2 = derive_profile_key("my_password", &salt2).unwrap();
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_salt_generation_unique() {
|
||||
let salt1 = generate_salt();
|
||||
let salt2 = generate_salt();
|
||||
assert_ne!(salt1, salt2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_storage_roundtrip() {
|
||||
let password = "test_password_12345";
|
||||
store_e2e_password(password).unwrap();
|
||||
assert!(has_e2e_password());
|
||||
let loaded = load_e2e_password().unwrap();
|
||||
assert_eq!(loaded, Some(password.to_string()));
|
||||
remove_e2e_password().unwrap();
|
||||
assert!(!has_e2e_password());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_too_short_data() {
|
||||
let key = [1u8; 32];
|
||||
assert!(decrypt_bytes(&key, &[0u8; 5]).is_err());
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use super::{decode_salt, derive_vault_key, encode_salt};
|
||||
|
||||
/// A stored vault is only readable while this vector holds. It pins the
|
||||
/// Argon2id parameters and the salt encoding together: a dependency bump
|
||||
/// that changed either would fail here instead of at the user's data.
|
||||
#[test]
|
||||
fn vault_key_derivation_is_pinned() {
|
||||
let key = derive_vault_key(b"correct horse battery staple", &[7u8; 16]).unwrap();
|
||||
let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(
|
||||
hex,
|
||||
"799f12b9e17710824482d829835acb69f5a9355bf774c4f07342823b11b90928"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salt_encoding_round_trips_without_padding() {
|
||||
let salt = [0u8, 1, 2, 3, 250, 251, 252, 253, 254, 255, 9, 8, 7, 6, 5, 4];
|
||||
let encoded = encode_salt(&salt);
|
||||
assert!(!encoded.contains('='), "PHC B64 carries no padding");
|
||||
assert_eq!(decode_salt(&encoded).unwrap(), salt);
|
||||
assert!(decode_salt("not*valid").is_err());
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use super::manifest::{
|
||||
};
|
||||
use super::types::*;
|
||||
use crate::events;
|
||||
use crate::log_redaction::Plain;
|
||||
use crate::profile::types::{BrowserProfile, SyncMode};
|
||||
use crate::profile::ProfileManager;
|
||||
use crate::settings_manager::SettingsManager;
|
||||
@@ -3555,7 +3556,7 @@ pub async fn set_profile_sync_mode(
|
||||
let _ = engine.client.delete(&manifest_key, None).await;
|
||||
log::info!(
|
||||
"Deleted remote manifest for profile {} due to sync mode change ({:?} -> {:?})",
|
||||
profile_id,
|
||||
Plain(&profile_id),
|
||||
old_mode,
|
||||
new_mode
|
||||
);
|
||||
@@ -3658,9 +3659,13 @@ pub async fn set_profile_sync_mode(
|
||||
match SyncEngine::create_from_settings(&app_handle).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine.delete_profile(&profile_id).await {
|
||||
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
|
||||
log::warn!(
|
||||
"Failed to delete profile {} from sync: {}",
|
||||
Plain(&profile_id),
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!("Profile {} deleted from sync service", profile_id);
|
||||
log::info!("Profile {} deleted from sync service", Plain(&profile_id));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -282,7 +282,7 @@ mod tests {
|
||||
// Exercises the real probe against a host that cannot resolve, which is
|
||||
// what a container-only endpoint looks like from the desktop.
|
||||
let client = probe_client();
|
||||
let error = probe_storage_endpoint(&client, "http://minio.invalid:9000")
|
||||
let error = probe_storage_endpoint(&client, "https://minio.invalid:9000")
|
||||
.await
|
||||
.expect_err("an unresolvable host must not report as reachable");
|
||||
assert!(
|
||||
@@ -313,7 +313,7 @@ mod tests {
|
||||
// names and a bare "connection failed", and could not tell that the host
|
||||
// their server had signed into every URL was one only the server could
|
||||
// resolve.
|
||||
let url = "http://minio.invalid:9000/donut/profiles/p1/Cookies?X-Amz-Signature=abc";
|
||||
let url = "https://minio.invalid:9000/donut/profiles/p1/Cookies?X-Amz-Signature=abc";
|
||||
let error = probe_client()
|
||||
.put(url)
|
||||
.body(b"payload".to_vec())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::engine::SyncEngine;
|
||||
use super::subscription::SyncWorkItem;
|
||||
use crate::events;
|
||||
use crate::log_redaction::Plain;
|
||||
use crate::profile::ProfileManager;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -241,7 +242,7 @@ impl SyncScheduler {
|
||||
);
|
||||
log::debug!(
|
||||
"Profile {} is running, queued sync for after stop",
|
||||
profile_id
|
||||
Plain(&profile_id)
|
||||
);
|
||||
} else {
|
||||
// Profile is not running - sync immediately (set stopped_at to past)
|
||||
@@ -252,7 +253,7 @@ impl SyncScheduler {
|
||||
queued: true,
|
||||
},
|
||||
);
|
||||
log::debug!("Profile {} queued for immediate sync", profile_id);
|
||||
log::debug!("Profile {} queued for immediate sync", Plain(&profile_id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use crate::log_redaction::ShortId;
|
||||
use crate::profile::manager::ProfileManager;
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
@@ -777,7 +778,7 @@ impl SynchronizerManager {
|
||||
tokio::select! {
|
||||
_ = cancel_rx.changed() => {
|
||||
if *cancel_rx.borrow() {
|
||||
log::info!("Synchronizer session {session_id}: cancelled");
|
||||
log::info!("Synchronizer session {}: cancelled", ShortId(&session_id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -827,7 +828,10 @@ impl SynchronizerManager {
|
||||
}
|
||||
|
||||
// Leader closed or session cancelled — kill all followers
|
||||
log::info!("Synchronizer session {session_id}: stopping all followers");
|
||||
log::info!(
|
||||
"Synchronizer session {}: stopping all followers",
|
||||
ShortId(&session_id)
|
||||
);
|
||||
let follower_ids: Vec<String> = {
|
||||
let inner = manager.lock().await;
|
||||
if let Some(session) = inner.sessions.get(&session_id) {
|
||||
@@ -1145,7 +1149,8 @@ impl SynchronizerManager {
|
||||
let info = session.info();
|
||||
let _ = app_handle.emit("sync-session-changed", &info);
|
||||
log::info!(
|
||||
"Synchronizer session {session_id}: mirroring {}",
|
||||
"Synchronizer session {}: mirroring {}",
|
||||
ShortId(session_id),
|
||||
if paused { "paused" } else { "resumed" }
|
||||
);
|
||||
Ok(info)
|
||||
@@ -1239,7 +1244,8 @@ impl SynchronizerManager {
|
||||
return Err(serde_json::json!({ "code": "SYNC_ARRANGE_FAILED" }).to_string());
|
||||
}
|
||||
log::info!(
|
||||
"Synchronizer session {session_id}: placed {placed} of {} windows",
|
||||
"Synchronizer session {}: placed {placed} of {} windows",
|
||||
ShortId(session_id),
|
||||
follower_ids.len()
|
||||
);
|
||||
Ok(info)
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Sealing of the secrets Donut keeps on this machine.
|
||||
//!
|
||||
//! The API and MCP tokens, the cloud session, the sync token and the sync
|
||||
//! encryption password each live in a small file under the settings folder.
|
||||
//! They are sealed with AES-256-GCM under a key derived (Argon2id, per-file
|
||||
//! salt) from this installation's own vault key: 32 random bytes minted on
|
||||
//! first use and kept in `vault.key`, readable by the owner only.
|
||||
//!
|
||||
//! Every build before the per-install key sealed those files under one
|
||||
//! password compiled into the binary, the same for every install whose build
|
||||
//! did not set `DONUT_BROWSER_VAULT_PASSWORD`. A file that still carries that
|
||||
//! seal is opened with the legacy password and re-sealed under the
|
||||
//! installation key on the spot, so an update keeps every login and token.
|
||||
//!
|
||||
//! File layout, unchanged from the earlier per-module copies:
|
||||
//! `magic (5-byte header + 1 version byte) | salt length | PHC base64 salt |
|
||||
//! 12-byte nonce | 4-byte little-endian ciphertext length | ciphertext`.
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||
use rand::RngExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::sync::encryption::{decode_salt, derive_vault_key, encode_salt};
|
||||
|
||||
pub const VAULT_KEY_FILE: &str = "vault.key";
|
||||
const KEY_LEN: usize = 32;
|
||||
const NONCE_LEN: usize = 12;
|
||||
|
||||
/// The sealing password of every build before the per-install key. Written
|
||||
/// by `build.rs` from the build environment, with the historical default when
|
||||
/// nothing was set. Only ever used to open a file sealed by such a build.
|
||||
const LEGACY_PASSWORD: &str = include_str!(concat!(env!("OUT_DIR"), "/legacy_vault_password.txt"));
|
||||
|
||||
/// The installation key, cached with the file it came from so a test that
|
||||
/// moves the settings folder never reads a key from the previous one.
|
||||
static INSTALL_KEY: Mutex<Option<(PathBuf, [u8; KEY_LEN])>> = Mutex::new(None);
|
||||
|
||||
fn key_file() -> PathBuf {
|
||||
crate::app_dirs::settings_dir().join(VAULT_KEY_FILE)
|
||||
}
|
||||
|
||||
/// This installation's vault key, minted the first time anything needs it.
|
||||
///
|
||||
/// A key file of the wrong size is refused rather than replaced: minting a
|
||||
/// new key over it would silently orphan every file sealed under the old one.
|
||||
pub fn install_key() -> Result<[u8; KEY_LEN], String> {
|
||||
let path = key_file();
|
||||
if let Ok(cached) = INSTALL_KEY.lock() {
|
||||
if let Some((cached_path, key)) = cached.as_ref() {
|
||||
if *cached_path == path {
|
||||
return Ok(*key);
|
||||
}
|
||||
}
|
||||
}
|
||||
let key = match std::fs::read(&path) {
|
||||
Ok(bytes) if bytes.len() == KEY_LEN => <[u8; KEY_LEN]>::try_from(bytes.as_slice())
|
||||
.map_err(|_| "The vault key file could not be read whole".to_string())?,
|
||||
Ok(bytes) => {
|
||||
return Err(format!(
|
||||
"The vault key file {} holds {} bytes instead of {KEY_LEN}",
|
||||
path.display(),
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => mint_key(&path)?,
|
||||
Err(e) => return Err(format!("Could not read the vault key: {e}")),
|
||||
};
|
||||
if let Ok(mut cached) = INSTALL_KEY.lock() {
|
||||
*cached = Some((path, key));
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Write a fresh key next to the sealed files. Written to a sibling first and
|
||||
/// renamed into place, so a crash mid-write never leaves a short key behind.
|
||||
fn mint_key(path: &Path) -> Result<[u8; KEY_LEN], String> {
|
||||
let key: [u8; KEY_LEN] = rand::rng().random();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Could not create the settings folder: {e}"))?;
|
||||
}
|
||||
let staging = path.with_extension("key.tmp");
|
||||
std::fs::write(&staging, key).map_err(|e| format!("Could not write the vault key: {e}"))?;
|
||||
crate::app_dirs::restrict_to_owner(&staging);
|
||||
if let Err(e) = std::fs::rename(&staging, path) {
|
||||
let _ = std::fs::remove_file(&staging);
|
||||
// Another process minted the key first; theirs is the one to keep.
|
||||
if path.exists() {
|
||||
return install_key();
|
||||
}
|
||||
return Err(format!("Could not place the vault key: {e}"));
|
||||
}
|
||||
crate::app_dirs::restrict_to_owner(path);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Seal `secret` into `file` under this installation's key.
|
||||
pub fn seal(file: &Path, magic: &[u8; 6], secret: &str) -> Result<(), String> {
|
||||
let key = install_key()?;
|
||||
seal_with(file, magic, secret, &key)
|
||||
}
|
||||
|
||||
fn seal_with(file: &Path, magic: &[u8; 6], secret: &str, material: &[u8]) -> Result<(), String> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
}
|
||||
let salt_bytes: [u8; 16] = rand::rng().random();
|
||||
let salt = encode_salt(&salt_bytes);
|
||||
let key = Key::<Aes256Gcm>::from(derive_vault_key(material, &salt_bytes)?);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce_bytes: [u8; NONCE_LEN] = rand::rng().random();
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, secret.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {e}"))?;
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(magic);
|
||||
let salt_str = salt.as_str();
|
||||
data.push(salt_str.len() as u8);
|
||||
data.extend_from_slice(salt_str.as_bytes());
|
||||
data.extend_from_slice(&nonce);
|
||||
data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
|
||||
data.extend_from_slice(&ciphertext);
|
||||
|
||||
std::fs::write(file, data).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
crate::app_dirs::restrict_to_owner(file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The parts of a sealed file, once the layout has been checked.
|
||||
struct Sealed<'a> {
|
||||
salt: Vec<u8>,
|
||||
nonce: [u8; NONCE_LEN],
|
||||
ciphertext: &'a [u8],
|
||||
}
|
||||
|
||||
/// Take a sealed file apart. A foreign magic or a layout this version does not
|
||||
/// know reads as "no secret", never as an error.
|
||||
fn parse<'a>(data: &'a [u8], magic: &[u8; 6]) -> Result<Option<Sealed<'a>>, String> {
|
||||
if data.len() < magic.len() + 1 || &data[..magic.len()] != magic {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut offset = magic.len();
|
||||
let salt_len = data[offset] as usize;
|
||||
offset += 1;
|
||||
if offset + salt_len > data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_str =
|
||||
std::str::from_utf8(&data[offset..offset + salt_len]).map_err(|_| "Invalid salt encoding")?;
|
||||
let salt = decode_salt(salt_str)?;
|
||||
offset += salt_len;
|
||||
if offset + NONCE_LEN > data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nonce: [u8; NONCE_LEN] = data[offset..offset + NONCE_LEN]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid nonce length".to_string())?;
|
||||
offset += NONCE_LEN;
|
||||
if offset + 4 > data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext_len = u32::from_le_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3],
|
||||
]) as usize;
|
||||
offset += 4;
|
||||
if offset + ciphertext_len > data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Sealed {
|
||||
salt,
|
||||
nonce,
|
||||
ciphertext: &data[offset..offset + ciphertext_len],
|
||||
}))
|
||||
}
|
||||
|
||||
fn unseal(sealed: &Sealed<'_>, material: &[u8]) -> Result<Option<String>, String> {
|
||||
let key = Key::<Aes256Gcm>::from(derive_vault_key(material, &sealed.salt)?);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let Ok(plaintext) = cipher.decrypt(&Nonce::from(sealed.nonce), sealed.ciphertext) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(String::from_utf8(plaintext).ok())
|
||||
}
|
||||
|
||||
/// Read back a secret written by `seal`.
|
||||
///
|
||||
/// A missing file, a foreign magic or a damaged layout all read as "no
|
||||
/// secret" so a stale file never blocks the feature it belongs to. A file
|
||||
/// that opens only under the legacy build password is re-sealed under this
|
||||
/// installation's key before the secret is returned. A seal that neither key
|
||||
/// opens is an error: the file is real, and the caller must not mint over it
|
||||
/// as if it were absent.
|
||||
pub fn open(file: &Path, magic: &[u8; 6]) -> Result<Option<String>, String> {
|
||||
if !file.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = std::fs::read(file).map_err(|e| format!("Failed to read file: {e}"))?;
|
||||
let Some(sealed) = parse(&data, magic)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let key = install_key()?;
|
||||
if let Some(secret) = unseal(&sealed, &key)? {
|
||||
return Ok(Some(secret));
|
||||
}
|
||||
match unseal(&sealed, LEGACY_PASSWORD.trim().as_bytes())? {
|
||||
Some(secret) => {
|
||||
if let Err(e) = seal_with(file, magic, &secret, &key) {
|
||||
log::warn!(
|
||||
"Could not re-seal {} under the vault key: {e}",
|
||||
file.display()
|
||||
);
|
||||
}
|
||||
Ok(Some(secret))
|
||||
}
|
||||
None => Err("Decryption failed".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn isolated() -> (TempDir, crate::app_dirs::TestDirGuard) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
(dir, guard)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_key_is_minted_once_and_reused() {
|
||||
let (_dir, _guard) = isolated();
|
||||
let first = install_key().unwrap();
|
||||
let second = install_key().unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(std::fs::read(key_file()).unwrap().len(), KEY_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seal_round_trips_and_a_foreign_magic_reads_as_nothing() {
|
||||
let (dir, _guard) = isolated();
|
||||
let file = dir.path().join("secret.dat");
|
||||
seal(&file, b"DBTST\x02", "hunter's token").unwrap();
|
||||
assert_eq!(
|
||||
open(&file, b"DBTST\x02").unwrap().as_deref(),
|
||||
Some("hunter's token")
|
||||
);
|
||||
assert_eq!(open(&file, b"DBOTH\x02").unwrap(), None);
|
||||
assert_eq!(
|
||||
open(&dir.path().join("missing.dat"), b"DBTST\x02").unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_seal_opens_once_and_comes_back_under_the_install_key() {
|
||||
let (dir, _guard) = isolated();
|
||||
let file = dir.path().join("legacy.dat");
|
||||
seal_with(
|
||||
&file,
|
||||
b"DBTST\x02",
|
||||
"kept",
|
||||
LEGACY_PASSWORD.trim().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(open(&file, b"DBTST\x02").unwrap().as_deref(), Some("kept"));
|
||||
|
||||
// Re-sealed: the legacy password no longer opens the file, the key does.
|
||||
let data = std::fs::read(&file).unwrap();
|
||||
let sealed = parse(&data, b"DBTST\x02").unwrap().unwrap();
|
||||
assert_eq!(
|
||||
unseal(&sealed, LEGACY_PASSWORD.trim().as_bytes()).unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
unseal(&sealed, &install_key().unwrap()).unwrap().as_deref(),
|
||||
Some("kept")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seal_under_an_unknown_key_is_an_error_not_an_absence() {
|
||||
let (dir, _guard) = isolated();
|
||||
let file = dir.path().join("foreign.dat");
|
||||
let other: [u8; KEY_LEN] = rand::rng().random();
|
||||
seal_with(&file, b"DBTST\x02", "elsewhere", &other).unwrap();
|
||||
assert!(open(&file, b"DBTST\x02").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_key_file_is_refused_rather_than_replaced() {
|
||||
let (_dir, _guard) = isolated();
|
||||
std::fs::create_dir_all(key_file().parent().unwrap()).unwrap();
|
||||
std::fs::write(key_file(), b"short").unwrap();
|
||||
assert!(install_key().is_err());
|
||||
}
|
||||
}
|
||||
@@ -77,9 +77,7 @@ impl VpnStorage {
|
||||
};
|
||||
let encryption_key = if key_path.exists() {
|
||||
if let Ok(key_data) = fs::read(&key_path) {
|
||||
if key_data.len() == 32 {
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&key_data);
|
||||
if let Ok(key) = <[u8; 32]>::try_from(key_data.as_slice()) {
|
||||
key
|
||||
} else {
|
||||
let key: [u8; 32] = rand::rng().random();
|
||||
|
||||
@@ -367,11 +367,54 @@ fn badge_fonts() -> std::sync::Arc<resvg::usvg::fontdb::Database> {
|
||||
.get_or_init(|| {
|
||||
let mut db = resvg::usvg::fontdb::Database::new();
|
||||
db.load_system_fonts();
|
||||
if let Some(family) = badge_sans_family(&db) {
|
||||
db.set_sans_serif_family(family);
|
||||
}
|
||||
std::sync::Arc::new(db)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The family the badge's `sans-serif` resolves to.
|
||||
///
|
||||
/// The database names Arial for the generic family, which macOS and Windows
|
||||
/// have and a Linux desktop usually does not: Ubuntu ships Noto, DejaVu,
|
||||
/// Liberation and Ubuntu instead. An unresolved family draws no initial at
|
||||
/// all, so the first family that is actually installed is chosen, and failing
|
||||
/// every known name, any installed font at all.
|
||||
fn badge_sans_family(db: &resvg::usvg::fontdb::Database) -> Option<String> {
|
||||
use resvg::usvg::fontdb::{Family, Query, Stretch, Style, Weight};
|
||||
const PREFERRED: [&str; 10] = [
|
||||
"Arial",
|
||||
"Helvetica Neue",
|
||||
"Helvetica",
|
||||
"Segoe UI",
|
||||
"Noto Sans",
|
||||
"DejaVu Sans",
|
||||
"Liberation Sans",
|
||||
"Ubuntu",
|
||||
"Cantarell",
|
||||
"Roboto",
|
||||
];
|
||||
let installed = |name: &str| {
|
||||
db.query(&Query {
|
||||
families: &[Family::Name(name)],
|
||||
weight: Weight::NORMAL,
|
||||
stretch: Stretch::Normal,
|
||||
style: Style::Normal,
|
||||
})
|
||||
.is_some()
|
||||
};
|
||||
PREFERRED
|
||||
.iter()
|
||||
.find(|name| installed(name))
|
||||
.map(|name| name.to_string())
|
||||
.or_else(|| {
|
||||
db.faces()
|
||||
.find_map(|face| face.families.first().map(|(name, _)| name.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The first letter (or digit) of a profile name, upper-cased, for its badge.
|
||||
pub fn badge_initial(name: &str) -> String {
|
||||
name
|
||||
|
||||
@@ -40,10 +40,10 @@ pub const FIELD_IDS: [&str; 9] = [
|
||||
"postal_code",
|
||||
];
|
||||
|
||||
/// FNV-1a with the salt folded into the initial state, then splitmix64, so
|
||||
/// neighbouring salts do not produce visibly related values.
|
||||
fn draw(seed: &str, salt: u64) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ salt;
|
||||
/// FNV-1a with the stream number folded into the initial state, then
|
||||
/// splitmix64, so neighbouring streams do not produce visibly related values.
|
||||
fn draw(seed: &str, stream: u64) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ stream;
|
||||
for byte in seed.as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
@@ -54,8 +54,8 @@ fn draw(seed: &str, salt: u64) -> u64 {
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
fn pick<'a>(seed: &str, salt: u64, options: &[&'a str]) -> &'a str {
|
||||
options[(draw(seed, salt) % options.len() as u64) as usize]
|
||||
fn pick<'a>(seed: &str, stream: u64, options: &[&'a str]) -> &'a str {
|
||||
options[(draw(seed, stream) % options.len() as u64) as usize]
|
||||
}
|
||||
|
||||
const GIVEN_NAMES: [&str; 32] = [
|
||||
|
||||
@@ -647,164 +647,5 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::erro
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::proxy_storage::process_start_time;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn short_lived_child() -> Child {
|
||||
Command::new("sh")
|
||||
.args(["-c", "sleep 0.2"])
|
||||
.spawn()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn short_lived_child() -> Child {
|
||||
Command::new("cmd")
|
||||
.args(["/C", "ping -n 2 127.0.0.1 >NUL"])
|
||||
.spawn()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_child_is_reaped_after_exit() {
|
||||
let child = short_lived_child();
|
||||
let pid = child.id();
|
||||
let start_time = resolve_process_start_time(pid).unwrap();
|
||||
spawn_supervisor_reaper(child).join().unwrap();
|
||||
assert!(!process_identity_matches(pid, Some(start_time)));
|
||||
}
|
||||
|
||||
fn readiness_config(port: u16) -> XrayWorkerConfig {
|
||||
let mut config = XrayWorkerConfig::new(
|
||||
"readiness".to_string(),
|
||||
None,
|
||||
"vless://unused".to_string(),
|
||||
port,
|
||||
"local-user".to_string(),
|
||||
"local-password".to_string(),
|
||||
);
|
||||
let pid = std::process::id();
|
||||
config.xray_pid = Some(pid);
|
||||
config.xray_pid_start_time = process_start_time(pid);
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_macos_major_versions_for_sidecar_compatibility() {
|
||||
assert_eq!(parse_macos_major_version("11.7.10\n"), Some(11));
|
||||
assert_eq!(parse_macos_major_version("12.0"), Some(12));
|
||||
assert_eq!(parse_macos_major_version("15.5.1"), Some(15));
|
||||
assert_eq!(parse_macos_major_version("unknown"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_requires_the_expected_authenticated_socks_endpoint() {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let config = readiness_config(listener.local_addr().unwrap().port());
|
||||
let expected_username = config.username.clone();
|
||||
let expected_password = config.password.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut greeting = [0_u8; 3];
|
||||
stream.read_exact(&mut greeting).await.unwrap();
|
||||
assert_eq!(greeting, [5, 1, 2]);
|
||||
stream.write_all(&[5, 2]).await.unwrap();
|
||||
|
||||
let mut header = [0_u8; 2];
|
||||
stream.read_exact(&mut header).await.unwrap();
|
||||
assert_eq!(header[0], 1);
|
||||
let mut username = vec![0_u8; header[1] as usize];
|
||||
stream.read_exact(&mut username).await.unwrap();
|
||||
let password_len = stream.read_u8().await.unwrap();
|
||||
let mut password = vec![0_u8; password_len as usize];
|
||||
stream.read_exact(&mut password).await.unwrap();
|
||||
assert_eq!(username, expected_username.as_bytes());
|
||||
assert_eq!(password, expected_password.as_bytes());
|
||||
stream.write_all(&[1, 0]).await.unwrap();
|
||||
});
|
||||
|
||||
assert!(authenticated_socks_ready(&config).await);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_rejects_an_unrelated_listener_on_the_reserved_port() {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let config = readiness_config(listener.local_addr().unwrap().port());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut greeting = [0_u8; 3];
|
||||
stream.read_exact(&mut greeting).await.unwrap();
|
||||
stream.write_all(&[5, 0]).await.unwrap();
|
||||
});
|
||||
|
||||
assert!(!authenticated_socks_ready(&config).await);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_identity_is_persisted_on_the_exact_worker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
|
||||
let id = format!("xray-browser-owner-{}", uuid::Uuid::new_v4());
|
||||
let config = XrayWorkerConfig::new(
|
||||
id.clone(),
|
||||
Some("profile".to_string()),
|
||||
"vless://unused".to_string(),
|
||||
1080,
|
||||
"local-user".to_string(),
|
||||
"local-password".to_string(),
|
||||
);
|
||||
save_xray_worker_config(&config).unwrap();
|
||||
|
||||
let browser_pid = std::process::id();
|
||||
assert!(set_browser_pid(&id, browser_pid));
|
||||
let saved = get_xray_worker_config(&id).unwrap();
|
||||
assert_eq!(saved.browser_pid, Some(browser_pid));
|
||||
assert_eq!(
|
||||
saved.browser_pid_start_time,
|
||||
process_start_time(browser_pid)
|
||||
);
|
||||
|
||||
assert!(delete_xray_worker_config(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reusable_worker_requires_and_persists_the_exact_live_owner() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
|
||||
let id = format!("xray-worker-lease-{}", uuid::Uuid::new_v4());
|
||||
let mut config = XrayWorkerConfig::new(
|
||||
id.clone(),
|
||||
Some("profile".to_string()),
|
||||
"vless://unused".to_string(),
|
||||
1080,
|
||||
"local-user".to_string(),
|
||||
"local-password".to_string(),
|
||||
);
|
||||
config.browser_pid = Some(u32::MAX);
|
||||
config.browser_pid_start_time = Some(1);
|
||||
save_xray_worker_config(&config).unwrap();
|
||||
|
||||
let owner_pid = std::process::id();
|
||||
let owner_start_time = process_start_time(owner_pid).unwrap();
|
||||
assert!(!worker_is_leased_to(&config, owner_pid, owner_start_time));
|
||||
assert!(persist_browser_identity(
|
||||
&mut config,
|
||||
owner_pid,
|
||||
owner_start_time
|
||||
));
|
||||
assert!(worker_is_leased_to(&config, owner_pid, owner_start_time));
|
||||
let saved = get_xray_worker_config(&id).unwrap();
|
||||
assert_eq!(saved.browser_pid, Some(owner_pid));
|
||||
assert_eq!(saved.browser_pid_start_time, Some(owner_start_time));
|
||||
|
||||
assert!(delete_xray_worker_config(&id));
|
||||
}
|
||||
}
|
||||
#[path = "xray_worker_runner_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
use super::*;
|
||||
use crate::proxy_storage::process_start_time;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn short_lived_child() -> Child {
|
||||
Command::new("sh")
|
||||
.args(["-c", "sleep 0.2"])
|
||||
.spawn()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn short_lived_child() -> Child {
|
||||
Command::new("cmd")
|
||||
.args(["/C", "ping -n 2 127.0.0.1 >NUL"])
|
||||
.spawn()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_child_is_reaped_after_exit() {
|
||||
let child = short_lived_child();
|
||||
let pid = child.id();
|
||||
let start_time = resolve_process_start_time(pid).unwrap();
|
||||
spawn_supervisor_reaper(child).join().unwrap();
|
||||
assert!(!process_identity_matches(pid, Some(start_time)));
|
||||
}
|
||||
|
||||
fn readiness_config(port: u16) -> XrayWorkerConfig {
|
||||
let mut config = XrayWorkerConfig::new(
|
||||
"readiness".to_string(),
|
||||
None,
|
||||
"vless://unused".to_string(),
|
||||
port,
|
||||
"local-user".to_string(),
|
||||
"local-password".to_string(),
|
||||
);
|
||||
let pid = std::process::id();
|
||||
config.xray_pid = Some(pid);
|
||||
config.xray_pid_start_time = process_start_time(pid);
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_macos_major_versions_for_sidecar_compatibility() {
|
||||
assert_eq!(parse_macos_major_version("11.7.10\n"), Some(11));
|
||||
assert_eq!(parse_macos_major_version("12.0"), Some(12));
|
||||
assert_eq!(parse_macos_major_version("15.5.1"), Some(15));
|
||||
assert_eq!(parse_macos_major_version("unknown"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_requires_the_expected_authenticated_socks_endpoint() {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let config = readiness_config(listener.local_addr().unwrap().port());
|
||||
let expected_username = config.username.clone();
|
||||
let expected_password = config.password.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut greeting = [0_u8; 3];
|
||||
stream.read_exact(&mut greeting).await.unwrap();
|
||||
assert_eq!(greeting, [5, 1, 2]);
|
||||
stream.write_all(&[5, 2]).await.unwrap();
|
||||
|
||||
let mut header = [0_u8; 2];
|
||||
stream.read_exact(&mut header).await.unwrap();
|
||||
assert_eq!(header[0], 1);
|
||||
let mut username = vec![0_u8; header[1] as usize];
|
||||
stream.read_exact(&mut username).await.unwrap();
|
||||
let password_len = stream.read_u8().await.unwrap();
|
||||
let mut password = vec![0_u8; password_len as usize];
|
||||
stream.read_exact(&mut password).await.unwrap();
|
||||
assert_eq!(username, expected_username.as_bytes());
|
||||
assert_eq!(password, expected_password.as_bytes());
|
||||
stream.write_all(&[1, 0]).await.unwrap();
|
||||
});
|
||||
|
||||
assert!(authenticated_socks_ready(&config).await);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_rejects_an_unrelated_listener_on_the_reserved_port() {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let config = readiness_config(listener.local_addr().unwrap().port());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut greeting = [0_u8; 3];
|
||||
stream.read_exact(&mut greeting).await.unwrap();
|
||||
stream.write_all(&[5, 0]).await.unwrap();
|
||||
});
|
||||
|
||||
assert!(!authenticated_socks_ready(&config).await);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_identity_is_persisted_on_the_exact_worker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
|
||||
let id = format!("xray-browser-owner-{}", uuid::Uuid::new_v4());
|
||||
let config = XrayWorkerConfig::new(
|
||||
id.clone(),
|
||||
Some("profile".to_string()),
|
||||
"vless://unused".to_string(),
|
||||
1080,
|
||||
"local-user".to_string(),
|
||||
"local-password".to_string(),
|
||||
);
|
||||
save_xray_worker_config(&config).unwrap();
|
||||
|
||||
let browser_pid = std::process::id();
|
||||
assert!(set_browser_pid(&id, browser_pid));
|
||||
let saved = get_xray_worker_config(&id).unwrap();
|
||||
assert_eq!(saved.browser_pid, Some(browser_pid));
|
||||
assert_eq!(
|
||||
saved.browser_pid_start_time,
|
||||
process_start_time(browser_pid)
|
||||
);
|
||||
|
||||
assert!(delete_xray_worker_config(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reusable_worker_requires_and_persists_the_exact_live_owner() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
|
||||
let id = format!("xray-worker-lease-{}", uuid::Uuid::new_v4());
|
||||
let mut config = XrayWorkerConfig::new(
|
||||
id.clone(),
|
||||
Some("profile".to_string()),
|
||||
"vless://unused".to_string(),
|
||||
1080,
|
||||
"local-user".to_string(),
|
||||
"local-password".to_string(),
|
||||
);
|
||||
config.browser_pid = Some(u32::MAX);
|
||||
config.browser_pid_start_time = Some(1);
|
||||
save_xray_worker_config(&config).unwrap();
|
||||
|
||||
let owner_pid = std::process::id();
|
||||
let owner_start_time = process_start_time(owner_pid).unwrap();
|
||||
assert!(!worker_is_leased_to(&config, owner_pid, owner_start_time));
|
||||
assert!(persist_browser_identity(
|
||||
&mut config,
|
||||
owner_pid,
|
||||
owner_start_time
|
||||
));
|
||||
assert!(worker_is_leased_to(&config, owner_pid, owner_start_time));
|
||||
let saved = get_xray_worker_config(&id).unwrap();
|
||||
assert_eq!(saved.browser_pid, Some(owner_pid));
|
||||
assert_eq!(saved.browser_pid_start_time, Some(owner_start_time));
|
||||
|
||||
assert!(delete_xray_worker_config(&id));
|
||||
}
|
||||
@@ -347,182 +347,5 @@ pub fn generate_xray_worker_id() -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config(id: &str) -> XrayWorkerConfig {
|
||||
XrayWorkerConfig::new(
|
||||
id.to_string(),
|
||||
Some("profile".to_string()),
|
||||
"vless://example".to_string(),
|
||||
1080,
|
||||
"local-user".to_string(),
|
||||
"local-password".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_settings_use_authenticated_loopback_socks() {
|
||||
let config = test_config("id");
|
||||
|
||||
let proxy = config.local_proxy_settings();
|
||||
assert_eq!(proxy.proxy_type, "socks5");
|
||||
assert_eq!(proxy.host, "127.0.0.1");
|
||||
assert_eq!(proxy.port, 1080);
|
||||
assert_eq!(proxy.username.as_deref(), Some("local-user"));
|
||||
assert_eq!(proxy.password.as_deref(), Some("local-password"));
|
||||
assert!(proxy.vless_uri.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_storage_round_trips_updates_lists_and_securely_cleans_runtime_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
|
||||
let id = format!("xray-storage-test-{}", uuid::Uuid::new_v4());
|
||||
let mut config = test_config(&id);
|
||||
|
||||
save_xray_worker_config(&config).unwrap();
|
||||
assert_eq!(get_xray_worker_config(&id).unwrap().username, "local-user");
|
||||
assert_eq!(
|
||||
find_xray_worker_by_profile_id("profile").unwrap().id,
|
||||
config.id
|
||||
);
|
||||
assert!(list_xray_worker_configs()
|
||||
.iter()
|
||||
.any(|candidate| candidate.id == id));
|
||||
|
||||
config.pid = Some(41);
|
||||
config.xray_pid = Some(42);
|
||||
config.browser_pid = Some(43);
|
||||
assert!(update_xray_worker_config(&config));
|
||||
let updated = get_xray_worker_config(&id).unwrap();
|
||||
assert_eq!(updated.pid, Some(41));
|
||||
assert_eq!(updated.xray_pid, Some(42));
|
||||
assert_eq!(updated.browser_pid, Some(43));
|
||||
|
||||
let runtime_path = xray_runtime_config_path(&id);
|
||||
write_xray_runtime_config(&id, b"{\"runtime\":true}").unwrap();
|
||||
let log_path = xray_worker_log_path(&id);
|
||||
drop(create_xray_worker_log(&id).unwrap());
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(crate::proxy_storage::get_storage_dir())
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(xray_worker_config_path(&id))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&runtime_path)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&log_path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
assert!(delete_xray_worker_config(&id));
|
||||
assert!(get_xray_worker_config(&id).is_none());
|
||||
assert!(!runtime_path.exists());
|
||||
assert!(!log_path.exists());
|
||||
assert!(!update_xray_worker_config(&config));
|
||||
assert!(write_xray_runtime_config(&id, b"{}").is_err());
|
||||
assert!(create_xray_worker_log(&id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_unstarted_workers_have_a_grace_period_but_legacy_entries_are_stale() {
|
||||
let fresh = test_config("fresh");
|
||||
assert!(!unstarted_worker_is_stale(&fresh));
|
||||
|
||||
let mut legacy = test_config("legacy");
|
||||
legacy.created_at = 0;
|
||||
assert!(unstarted_worker_is_stale(&legacy));
|
||||
|
||||
legacy.pid = Some(1);
|
||||
assert!(!unstarted_worker_is_stale(&legacy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_state_updates_never_expose_partial_json() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("state.json");
|
||||
atomic_write_owner_only(&path, br#"{"value":0}"#).unwrap();
|
||||
let writer_path = path.clone();
|
||||
let writer = std::thread::spawn(move || {
|
||||
for value in 1..=500 {
|
||||
let content = serde_json::to_vec(&serde_json::json!({ "value": value })).unwrap();
|
||||
atomic_write_owner_only(&writer_path, &content).unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
// Bound the reader on the writer's own lifetime. A completion flag the
|
||||
// writer sets last is never set when it panics, which strands this loop
|
||||
// reading the last good file forever instead of failing.
|
||||
while !writer.is_finished() {
|
||||
let content = read_worker_state(&path).expect("state file stays readable while replaced");
|
||||
let value: serde_json::Value = serde_json::from_slice(&content).unwrap();
|
||||
assert!(value["value"].is_number());
|
||||
std::thread::yield_now();
|
||||
}
|
||||
writer.join().unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_state_write_replaces_a_symlink_without_touching_its_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let victim = temp.path().join("victim");
|
||||
let state = temp.path().join("state.json");
|
||||
std::fs::write(&victim, "untouched").unwrap();
|
||||
symlink(&victim, &state).unwrap();
|
||||
|
||||
atomic_write_owner_only(&state, br#"{"safe":true}"#).unwrap();
|
||||
|
||||
assert_eq!(std::fs::read_to_string(victim).unwrap(), "untouched");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&std::fs::read(state).unwrap()).unwrap()["safe"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_worker_config_defaults_missing_browser_pid() {
|
||||
let value = serde_json::json!({
|
||||
"id": "legacy",
|
||||
"profile_id": "profile",
|
||||
"vless_uri": "vless://example",
|
||||
"local_port": 1080,
|
||||
"username": "user",
|
||||
"password": "password",
|
||||
"pid": 1,
|
||||
"xray_pid": 2
|
||||
});
|
||||
let config: XrayWorkerConfig = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(config.created_at, 0);
|
||||
assert_eq!(config.pid_start_time, None);
|
||||
assert_eq!(config.xray_pid_start_time, None);
|
||||
assert!(!config.ready);
|
||||
assert_eq!(config.browser_pid, None);
|
||||
assert_eq!(config.browser_pid_start_time, None);
|
||||
}
|
||||
}
|
||||
#[path = "xray_worker_storage_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::*;
|
||||
|
||||
fn test_config(id: &str) -> XrayWorkerConfig {
|
||||
XrayWorkerConfig::new(
|
||||
id.to_string(),
|
||||
Some("profile".to_string()),
|
||||
"vless://example".to_string(),
|
||||
1080,
|
||||
"local-user".to_string(),
|
||||
"local-password".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_settings_use_authenticated_loopback_socks() {
|
||||
let config = test_config("id");
|
||||
|
||||
let proxy = config.local_proxy_settings();
|
||||
assert_eq!(proxy.proxy_type, "socks5");
|
||||
assert_eq!(proxy.host, "127.0.0.1");
|
||||
assert_eq!(proxy.port, 1080);
|
||||
assert_eq!(proxy.username.as_deref(), Some("local-user"));
|
||||
assert_eq!(proxy.password.as_deref(), Some("local-password"));
|
||||
assert!(proxy.vless_uri.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_storage_round_trips_updates_lists_and_securely_cleans_runtime_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
|
||||
let id = format!("xray-storage-test-{}", uuid::Uuid::new_v4());
|
||||
let mut config = test_config(&id);
|
||||
|
||||
save_xray_worker_config(&config).unwrap();
|
||||
assert_eq!(get_xray_worker_config(&id).unwrap().username, "local-user");
|
||||
assert_eq!(
|
||||
find_xray_worker_by_profile_id("profile").unwrap().id,
|
||||
config.id
|
||||
);
|
||||
assert!(list_xray_worker_configs()
|
||||
.iter()
|
||||
.any(|candidate| candidate.id == id));
|
||||
|
||||
config.pid = Some(41);
|
||||
config.xray_pid = Some(42);
|
||||
config.browser_pid = Some(43);
|
||||
assert!(update_xray_worker_config(&config));
|
||||
let updated = get_xray_worker_config(&id).unwrap();
|
||||
assert_eq!(updated.pid, Some(41));
|
||||
assert_eq!(updated.xray_pid, Some(42));
|
||||
assert_eq!(updated.browser_pid, Some(43));
|
||||
|
||||
let runtime_path = xray_runtime_config_path(&id);
|
||||
write_xray_runtime_config(&id, b"{\"runtime\":true}").unwrap();
|
||||
let log_path = xray_worker_log_path(&id);
|
||||
drop(create_xray_worker_log(&id).unwrap());
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(crate::proxy_storage::get_storage_dir())
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(xray_worker_config_path(&id))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&runtime_path)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&log_path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
assert!(delete_xray_worker_config(&id));
|
||||
assert!(get_xray_worker_config(&id).is_none());
|
||||
assert!(!runtime_path.exists());
|
||||
assert!(!log_path.exists());
|
||||
assert!(!update_xray_worker_config(&config));
|
||||
assert!(write_xray_runtime_config(&id, b"{}").is_err());
|
||||
assert!(create_xray_worker_log(&id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_unstarted_workers_have_a_grace_period_but_legacy_entries_are_stale() {
|
||||
let fresh = test_config("fresh");
|
||||
assert!(!unstarted_worker_is_stale(&fresh));
|
||||
|
||||
let mut legacy = test_config("legacy");
|
||||
legacy.created_at = 0;
|
||||
assert!(unstarted_worker_is_stale(&legacy));
|
||||
|
||||
legacy.pid = Some(1);
|
||||
assert!(!unstarted_worker_is_stale(&legacy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_state_updates_never_expose_partial_json() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("state.json");
|
||||
atomic_write_owner_only(&path, br#"{"value":0}"#).unwrap();
|
||||
let writer_path = path.clone();
|
||||
let writer = std::thread::spawn(move || {
|
||||
for value in 1..=500 {
|
||||
let content = serde_json::to_vec(&serde_json::json!({ "value": value })).unwrap();
|
||||
atomic_write_owner_only(&writer_path, &content).unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
// Bound the reader on the writer's own lifetime. A completion flag the
|
||||
// writer sets last is never set when it panics, which strands this loop
|
||||
// reading the last good file forever instead of failing.
|
||||
while !writer.is_finished() {
|
||||
let content = read_worker_state(&path).expect("state file stays readable while replaced");
|
||||
let value: serde_json::Value = serde_json::from_slice(&content).unwrap();
|
||||
assert!(value["value"].is_number());
|
||||
std::thread::yield_now();
|
||||
}
|
||||
writer.join().unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_state_write_replaces_a_symlink_without_touching_its_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let victim = temp.path().join("victim");
|
||||
let state = temp.path().join("state.json");
|
||||
std::fs::write(&victim, "untouched").unwrap();
|
||||
symlink(&victim, &state).unwrap();
|
||||
|
||||
atomic_write_owner_only(&state, br#"{"safe":true}"#).unwrap();
|
||||
|
||||
assert_eq!(std::fs::read_to_string(victim).unwrap(), "untouched");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&std::fs::read(state).unwrap()).unwrap()["safe"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_worker_config_defaults_missing_browser_pid() {
|
||||
let value = serde_json::json!({
|
||||
"id": "legacy",
|
||||
"profile_id": "profile",
|
||||
"vless_uri": "vless://example",
|
||||
"local_port": 1080,
|
||||
"username": "user",
|
||||
"password": "password",
|
||||
"pid": 1,
|
||||
"xray_pid": 2
|
||||
});
|
||||
let config: XrayWorkerConfig = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(config.created_at, 0);
|
||||
assert_eq!(config.pid_start_time, None);
|
||||
assert_eq!(config.xray_pid_start_time, None);
|
||||
assert!(!config.ready);
|
||||
assert_eq!(config.browser_pid, None);
|
||||
assert_eq!(config.browser_pid_start_time, None);
|
||||
}
|
||||
Reference in New Issue
Block a user