refactor: harden tests

This commit is contained in:
zhom
2026-07-21 23:13:17 +04:00
parent f7daf68b52
commit 9624ec846d
81 changed files with 11771 additions and 983 deletions
+3 -3
View File
@@ -967,7 +967,7 @@ impl AppAutoUpdater {
// rejected before the multi-hundred-MB download, not after.
let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?;
log::info!("Downloading update from: {}", update_info.download_url);
log::info!("Downloading update");
let download_path = self
.download_update_silent(&update_info.download_url, &temp_dir, &filename)
@@ -2038,7 +2038,7 @@ rm "{}"
#[tauri::command]
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled()
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping automatic app update check");
@@ -2099,7 +2099,7 @@ pub async fn restart_application() -> Result<(), String> {
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled()
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping manual app update check");
+37
View File
@@ -209,6 +209,30 @@ pub fn restrict_to_owner(path: &std::path::Path) {
}
}
/// Write sensitive data without creating a wider-permission file first.
pub fn create_owner_only(path: &std::path::Path) -> std::io::Result<std::fs::File> {
if path.exists() {
restrict_to_owner(path);
}
let mut options = std::fs::OpenOptions::new();
options.create(true).truncate(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(path)?;
restrict_to_owner(path);
Ok(file)
}
pub fn write_owner_only(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut file = create_owner_only(path)?;
file.write_all(content)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -222,6 +246,19 @@ mod tests {
);
}
#[cfg(unix)]
#[test]
fn owner_only_writer_uses_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("secret.json");
write_owner_only(&path, b"secret").unwrap();
assert_eq!(
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn test_data_dir_returns_path() {
let dir = data_dir();
+14 -35
View File
@@ -2,8 +2,8 @@ use clap::{Arg, Command};
use donutbrowser_lib::proxy_runner::{
start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process,
};
use donutbrowser_lib::proxy_server::run_proxy_server;
use donutbrowser_lib::proxy_storage::get_proxy_config;
use donutbrowser_lib::proxy_server::{redacted_upstream, run_proxy_server};
use donutbrowser_lib::proxy_storage::{build_proxy_url, get_proxy_config};
use std::process;
fn set_high_priority() {
@@ -55,31 +55,6 @@ fn set_high_priority() {
}
}
fn build_proxy_url(
proxy_type: &str,
host: &str,
port: u16,
username: Option<&str>,
password: Option<&str>,
) -> String {
let mut url = format!("{}://", proxy_type.to_lowercase());
if let (Some(user), Some(pass)) = (username, password) {
let encoded_user = urlencoding::encode(user);
let encoded_pass = urlencoding::encode(pass);
url.push_str(&format!("{}:{}@", encoded_user, encoded_pass));
} else if let Some(user) = username {
let encoded_user = urlencoding::encode(user);
url.push_str(&format!("{}@", encoded_user));
}
url.push_str(host);
url.push(':');
url.push_str(&port.to_string());
url
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
// Initialize logger to write to stderr (which will be redirected to file).
@@ -129,8 +104,6 @@ async fn main() {
.long("type")
.help("Proxy type (http, https, socks4, socks5, ss)"),
)
.arg(Arg::new("username").long("username").help("Proxy username"))
.arg(Arg::new("password").long("password").help("Proxy password"))
.arg(
Arg::new("port")
.short('p')
@@ -243,16 +216,22 @@ async fn main() {
start_matches.get_one::<u16>("proxy-port"),
start_matches.get_one::<String>("type"),
) {
let username = start_matches.get_one::<String>("username");
let password = start_matches.get_one::<String>("password");
let username = std::env::var("DONUT_PROXY_USERNAME").ok();
let password = std::env::var("DONUT_PROXY_PASSWORD").ok();
upstream_url = Some(build_proxy_url(
proxy_type,
host,
*port,
username.map(|s| s.as_str()),
password.map(|s| s.as_str()),
username.as_deref(),
password.as_deref(),
));
} else if let Some(upstream) = start_matches.get_one::<String>("upstream") {
if url::Url::parse(upstream)
.is_ok_and(|parsed| !parsed.username().is_empty() || parsed.password().is_some())
{
eprintln!("Credentialed upstream URLs are not accepted as process arguments");
process::exit(2);
}
upstream_url = Some(upstream.clone());
}
@@ -286,7 +265,7 @@ async fn main() {
"id": config.id,
"localPort": config.local_port,
"localUrl": config.local_url,
"upstreamUrl": config.upstream_url,
"upstreamUrl": redacted_upstream(&config.upstream_url),
})
);
process::exit(0);
@@ -380,7 +359,7 @@ async fn main() {
"Found config: id={}, port={:?}, upstream={}",
config.id,
config.local_port,
config.upstream_url
redacted_upstream(&config.upstream_url)
);
break config;
}
+28 -20
View File
@@ -115,10 +115,9 @@ impl BrowserRunner {
}
let url = parsed.to_string();
let profile_name = profile.name.clone();
let profile_id = profile.id.to_string();
let url_label = crate::log_redaction::url_label(&url);
log::info!("Firing launch hook GET {url} for profile {profile_name} (ID: {profile_id})");
log::info!("Firing launch hook GET {url_label}");
tokio::spawn(async move {
let client = match reqwest::Client::builder()
@@ -127,20 +126,23 @@ impl BrowserRunner {
{
Ok(c) => c,
Err(e) => {
log::warn!("Launch hook client build failed for {url}: {e}");
log::warn!(
"Launch hook client build failed: {}",
crate::log_redaction::text(&e.to_string())
);
return;
}
};
match client.get(&url).send().await {
Ok(resp) => {
log::info!(
"Launch hook {url} for profile {profile_name} returned status {}",
resp.status()
);
log::info!("Launch hook {url_label} returned status {}", resp.status());
}
Err(e) => {
log::warn!("Launch hook {url} for profile {profile_name} failed: {e}");
log::warn!(
"Launch hook {url_label} failed: {}",
crate::log_redaction::text(&e.to_string())
);
}
}
});
@@ -675,18 +677,18 @@ impl BrowserRunner {
.unwrap_or_else(|| updated_profile.clone());
log::info!(
"Browser status check - Profile: {} (ID: {}), Running: {}, URL: {:?}, PID: {:?}",
final_profile.name,
final_profile.id,
is_running,
url,
final_profile.process_id
"Browser status check: running={is_running}, URL requested={}, PID present={}",
url.is_some(),
final_profile.process_id.is_some()
);
if is_running && url.is_some() {
// Browser is running and we have a URL to open
if let Some(url_ref) = url.as_ref() {
log::info!("Opening URL in existing browser: {url_ref}");
log::info!(
"Opening {} in existing browser",
crate::log_redaction::url_label(url_ref)
);
match self
.open_url_in_existing_browser(
@@ -702,7 +704,10 @@ impl BrowserRunner {
Ok(final_profile)
}
Err(e) => {
log::info!("Failed to open URL in existing browser: {e}");
log::info!(
"Failed to open URL in existing browser: {}",
crate::log_redaction::text(&e.to_string())
);
// Fall back to launching a new instance
log::info!(
@@ -1163,18 +1168,21 @@ impl BrowserRunner {
));
}
log::info!("Opening URL '{url}' with profile '{profile_id}'");
log::info!("Opening URL with selected profile");
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
self
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
.await
.map_err(|e| {
log::info!("Failed to open URL with profile '{profile_id}': {e}");
log::info!(
"Failed to open URL with selected profile: {}",
crate::log_redaction::text(&e.to_string())
);
format!("Failed to open URL with profile: {e}")
})?;
log::info!("Successfully opened URL '{url}' with profile '{profile_id}'");
log::info!("Successfully opened URL with selected profile");
Ok(())
}
}
+9 -20
View File
@@ -609,9 +609,8 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
log::warn!("Token refresh failed ({status}): {body}");
return Err(format!("Token refresh failed ({status}): {body}"));
log::warn!("Token refresh failed ({status})");
return Err(format!("Token refresh failed ({status})"));
}
let result: RefreshTokenResponse = response
@@ -923,14 +922,12 @@ impl CloudAuthManager {
let status = response.status();
if status == reqwest::StatusCode::FORBIDDEN {
let body = response.text().await.unwrap_or_default();
log::warn!("Proxy config returned 403: {body}");
log::warn!("Proxy config returned 403");
return Err("__403__".to_string());
}
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("Proxy config fetch failed ({status}): {body}"));
return Err(format!("Proxy config fetch failed ({status})"));
}
response
@@ -1192,8 +1189,7 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Wayfern token request failed ({status}): {body}"));
return Err(format!("Wayfern token request failed ({status})"));
}
let result: WayfernTokenResponse = response
@@ -1209,17 +1205,10 @@ impl CloudAuthManager {
let token = match result {
Ok(token) => token,
Err(e) => {
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
// token-reuse throttle, "active subscription required", and the
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
// This is distinct from a 401 (dead access token) — the session is still
// valid, the user is just temporarily/conditionally not entitled. So we
// do NOT invalidate the session. Instead: drop the stale wayfern token so
// no browser launches half-authenticated, re-fetch the profile so the
// cached plan reflects the backend's real state (it may have changed),
// and signal the UI so the user learns why automation stopped working.
// A 403 rejects the entitlement without invalidating the login session.
// Clear the browser token and refresh account state before notifying UI.
if e.contains("(403") || e.contains("Forbidden") {
log::warn!("Wayfern token blocked by backend (403): {e}");
log::warn!("Wayfern token blocked by backend (403)");
self.clear_wayfern_token().await;
if let Err(fetch_err) = self.fetch_profile().await {
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
@@ -1239,7 +1228,7 @@ impl CloudAuthManager {
/// Get the current wayfern token, if any.
pub async fn get_wayfern_token(&self) -> Option<String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled() {
if crate::e2e_automation_enabled() {
if let Some(token) = std::env::var_os("WAYFERN_TEST_TOKEN")
.filter(|token| !token.is_empty())
.and_then(|token| token.into_string().ok())
+28 -41
View File
@@ -1057,6 +1057,23 @@ impl CookieManager {
mod tests {
use super::*;
#[cfg(target_os = "macos")]
const SYNTHETIC_COOKIE_HOST: &str = ".example.test";
#[cfg(target_os = "macos")]
const SYNTHETIC_COOKIE_VALUE: &str = "synthetic-cookie-value";
#[cfg(target_os = "macos")]
const SYNTHETIC_OS_CRYPT_PASSWORD: &[u8] = b"donut-synthetic-cookie-key";
#[cfg(target_os = "macos")]
const SYNTHETIC_ENCRYPTED_COOKIE_HEX: &str = "763130d83b9fd3e6d1b1c793769f55251f5e9d1193be72c0c08ea32e2cf068a85d9d0b97d8b2e6deca93a2b3c290e98e1a851f83d5566f9aa9314befe56dc6bdbd423d";
#[cfg(target_os = "macos")]
fn synthetic_encrypted_cookie() -> Vec<u8> {
(0..SYNTHETIC_ENCRYPTED_COOKIE_HEX.len())
.step_by(2)
.map(|i| u8::from_str_radix(&SYNTHETIC_ENCRYPTED_COOKIE_HEX[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn test_parse_netscape_cookies_valid() {
let content = "# Netscape HTTP Cookie File\n\
@@ -1482,50 +1499,29 @@ mod tests {
let _ = std::fs::remove_file(&tmp);
}
/// Regression: decrypting a real v10-encrypted Chromium cookie with the
/// correct PBKDF2 iterations and the `SHA-256(host_key)` integrity-prefix
/// strip. Captured from a real Wayfern profile:
/// host_key = ".github.com"
/// name = "_octo"
/// password = "OSfgzI5GUqy/pK4ANrYugw==" (contents of os_crypt_key)
/// value = "GH1.1.2077424036.1774792325"
///
/// If PBKDF2 iterations or the host-hash prefix handling ever regress,
/// this test fails and we instantly know why all copied cookies end up
/// with empty values — which is exactly the bug that shipped and made
/// issue-265-style silent failures reappear.
#[test]
#[cfg(target_os = "macos")]
fn test_decrypt_v10_cookie_with_real_vector() {
fn test_decrypt_v10_cookie_with_synthetic_vector() {
let profile_dir =
std::env::temp_dir().join(format!("donut_decrypt_vector_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&profile_dir).unwrap();
std::fs::write(
profile_dir.join("os_crypt_key"),
b"OSfgzI5GUqy/pK4ANrYugw==",
SYNTHETIC_OS_CRYPT_PASSWORD,
)
.unwrap();
let key = chrome_decrypt::get_encryption_key(&profile_dir)
.expect("should derive key from os_crypt_key file");
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07";
let encrypted: Vec<u8> = (0..encrypted_hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&encrypted_hex[i..i + 2], 16).unwrap())
.collect();
let decrypted = chrome_decrypt::decrypt(&encrypted, ".github.com", &key)
.expect("decryption must succeed with correct key and host");
assert_eq!(decrypted, "GH1.1.2077424036.1774792325");
let decrypted =
chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), SYNTHETIC_COOKIE_HOST, &key)
.expect("decryption must succeed with correct key and host");
assert_eq!(decrypted, SYNTHETIC_COOKIE_VALUE);
let _ = std::fs::remove_dir_all(&profile_dir);
}
/// Sanity: decrypting with the wrong host_key (hash mismatch) must not
/// return a half-garbage value — it should fall back to the full
/// decrypted bytes, which for a modern cookie includes the 32-byte hash
/// prefix and therefore won't be valid UTF-8 → `None`.
#[test]
#[cfg(target_os = "macos")]
fn test_decrypt_with_wrong_host_returns_none_or_raw() {
@@ -1534,25 +1530,16 @@ mod tests {
std::fs::create_dir_all(&profile_dir).unwrap();
std::fs::write(
profile_dir.join("os_crypt_key"),
b"OSfgzI5GUqy/pK4ANrYugw==",
SYNTHETIC_OS_CRYPT_PASSWORD,
)
.unwrap();
let key = chrome_decrypt::get_encryption_key(&profile_dir).unwrap();
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07";
let encrypted: Vec<u8> = (0..encrypted_hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&encrypted_hex[i..i + 2], 16).unwrap())
.collect();
// Wrong host: the prefix won't match, so we fall through to
// `String::from_utf8(full_decrypted)` which fails on the binary hash
// bytes and returns `None`. Either way, we must NOT return the real
// value "GH1.1.2077424036.1774792325".
let result = chrome_decrypt::decrypt(&encrypted, ".facebook.com", &key);
let result =
chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), ".wrong.example.test", &key);
assert!(
result.as_deref() != Some("GH1.1.2077424036.1774792325"),
"decrypt must not return the real cookie value when host_key is wrong"
result.as_deref() != Some(SYNTHETIC_COOKIE_VALUE),
"decrypt must not return the cookie value when host_key is wrong"
);
let _ = std::fs::remove_dir_all(&profile_dir);
@@ -1262,7 +1262,7 @@ pub async fn ensure_active_browsers_downloaded(
app_handle: tauri::AppHandle,
) -> Result<Vec<String>, String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled()
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping proactive browser download");
@@ -1419,7 +1419,7 @@ pub async fn ensure_all_binaries_exist(
app_handle: tauri::AppHandle,
) -> Result<Vec<String>, String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled()
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping proactive binary and GeoIP downloads");
+1 -1
View File
@@ -231,7 +231,7 @@ impl Downloader {
let download_url = self
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
log::info!("Download URL resolved: {}", download_url);
log::info!("Download URL resolved");
// In-session resume: a large (~1GB) download over a flaky connection can
// drop mid-stream. Rather than surfacing the first stall/chunk error as a
+28 -17
View File
@@ -18,7 +18,8 @@ static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
fn e2e_automation_enabled() -> bool {
#[cfg(feature = "e2e")]
{
tauri_plugin_cross_platform_webdriver::automation_enabled()
std::env::var("TAURI_AUTOMATION")
.is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
}
#[cfg(not(feature = "e2e"))]
{
@@ -26,6 +27,13 @@ fn e2e_automation_enabled() -> bool {
}
}
#[cfg(feature = "e2e")]
fn e2e_automation_profile_dir() -> Option<std::path::PathBuf> {
e2e_automation_enabled()
.then(|| std::env::var_os("TAURI_AUTOMATION_PROFILE_DIR").map(std::path::PathBuf::from))
.flatten()
}
mod api_client;
mod api_server;
mod app_auto_updater;
@@ -47,6 +55,7 @@ mod geolocation;
mod group_manager;
mod human_typing;
mod ip_utils;
mod log_redaction;
mod platform_browser;
mod profile;
mod profile_importer;
@@ -239,7 +248,7 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
// Called internally for deep-link / startup URL handling — not invoked from the
// frontend, so it is intentionally not a `#[tauri::command]`.
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
log::info!("handle_url_open called with URL: {url}");
log::info!("Handling URL open request");
// Check if the main window exists and is ready
if let Some(window) = app.get_webview_window("main") {
@@ -1382,11 +1391,18 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
run_with_builder(|builder| builder);
}
#[doc(hidden)]
pub fn run_with_builder(
configure_builder: impl FnOnce(tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry>,
) {
let args: Vec<String> = env::args().collect();
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
if let Some(url) = startup_url.clone() {
log::info!("Found startup URL in command line: {url}");
log::info!("Found startup URL in command line");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url.clone());
}
@@ -1405,10 +1421,7 @@ pub fn run() {
}),
};
let builder = tauri::Builder::default();
#[cfg(feature = "e2e")]
let builder = builder.plugin(tauri_plugin_cross_platform_webdriver::init());
let builder = configure_builder(tauri::Builder::default());
let builder = builder.plugin(
tauri_plugin_log::Builder::new()
@@ -1416,11 +1429,10 @@ pub fn run() {
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
// Keep enough context for customer support without letting a long-running
// installation accumulate logs without bound.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(10))
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
@@ -1503,8 +1515,7 @@ pub fn run() {
.visible(true);
#[cfg(feature = "e2e")]
let win_builder =
match tauri_plugin_cross_platform_webdriver::automation_profile_dir() {
let win_builder = match e2e_automation_profile_dir() {
Some(profile_dir) => win_builder
.data_directory(profile_dir.join("webview"))
// WKWebView ignores data_directory on macOS. Incognito gives every
@@ -1514,7 +1525,7 @@ pub fn run() {
// DONUTBROWSER_DATA_ROOT; only browser-engine storage is ephemeral.
.incognito(true),
None => win_builder,
};
};
#[cfg(target_os = "windows")]
let win_builder = win_builder.decorations(false);
@@ -1601,7 +1612,7 @@ pub fn run() {
for url in urls {
let url_string = url.to_string();
log::info!("Deep link received: {url_string}");
log::info!("Processing deep link URL");
let handle_clone = handle.clone();
tauri::async_runtime::spawn(async move {
@@ -1617,7 +1628,7 @@ pub fn run() {
if let Some(startup_url) = startup_url {
let handle_clone = handle.clone();
tauri::async_runtime::spawn(async move {
log::info!("Processing startup URL from command line: {startup_url}");
log::info!("Processing startup URL from command line");
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
log::error!("Failed to handle startup URL: {e}");
}
@@ -1828,7 +1839,7 @@ pub fn run() {
};
for url in pending_urls {
log::info!("Processing pending URL: {url}");
log::info!("Processing pending URL");
if let Err(e) = handle_url_open(handle_pending.clone(), url).await {
log::error!("Failed to handle pending URL: {e}");
}
+96
View File
@@ -0,0 +1,96 @@
use regex_lite::Regex;
use std::sync::LazyLock;
static URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)\b[a-z][a-z0-9+.-]{1,20}://[^\s<>"']+"#).expect("valid URL regex")
});
static PRIVATE_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----")
.expect("valid private-key regex")
});
static BEARER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+").expect("valid bearer regex"));
static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)\b(api[_-]?key|authorization|password|passwd|private[_-]?key|proxy[_-]?(password|username)|refresh[_-]?token|secret|token|username)\b\s*[:=]\s*[^\s,;]+",
)
.expect("valid secret regex")
});
static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b").expect("valid email regex")
});
static UNIX_HOME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"/(Users|home)/[^/\s]+").expect("valid Unix home regex"));
static WINDOWS_HOME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b[A-Z]:\\Users\\[^\\\s]+").expect("valid Windows home regex"));
static IPV4_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b([0-9]{1,3}\.){3}[0-9]{1,3}\b").expect("valid IPv4 regex"));
static DOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b([a-z0-9-]+\.)+[a-z]{2,}\b").expect("valid domain regex"));
static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b")
.expect("valid UUID regex")
});
pub fn url_label(value: &str) -> String {
url::Url::parse(value)
.map(|parsed| format!("{}://<redacted>", parsed.scheme()))
.unwrap_or_else(|_| "<redacted-url>".to_string())
}
pub fn text(value: &str) -> String {
let redacted = PRIVATE_KEY_RE.replace_all(value, "<redacted-private-key>");
let redacted = URL_RE.replace_all(&redacted, "<redacted-url>");
let redacted = BEARER_RE.replace_all(&redacted, "Bearer <redacted-secret>");
let redacted = SECRET_RE.replace_all(&redacted, "<redacted-secret>");
let redacted = EMAIL_RE.replace_all(&redacted, "<redacted-email>");
let redacted = UNIX_HOME_RE.replace_all(&redacted, "/<redacted-home>");
let redacted = WINDOWS_HOME_RE.replace_all(&redacted, "<redacted-home>");
let redacted = IPV4_RE.replace_all(&redacted, "<redacted-ip>");
let redacted = DOMAIN_RE.replace_all(&redacted, "<redacted-domain>");
UUID_RE
.replace_all(&redacted, "<redacted-identifier>")
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_sensitive_log_content() {
let input = format!(
concat!(
"URL https://user:pass@example.com/callback?code=private\n",
"Authorization: Bearer secret-token\n",
"password=hunter2\n",
"user@example.com /Users/alice/Library C:\\Users\\alice\\AppData\n",
"exit 203.0.113.42\n",
"-----BEGIN {0} KEY-----\nprivate-material\n-----END {0} KEY-----\n",
),
"PRIVATE"
);
let output = text(&input);
for sensitive in [
"user:pass",
"example.com",
"private-material",
"secret-token",
"hunter2",
"user@example.com",
"alice",
"203.0.113.42",
] {
assert!(!output.contains(sensitive), "log output leaked {sensitive}");
}
}
#[test]
fn url_labels_retain_only_the_scheme() {
assert_eq!(
url_label("https://user:pass@example.com/path?token=value"),
"https://<redacted>"
);
assert_eq!(url_label("not a URL"), "<redacted-url>");
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ fn is_kept(name: &str) -> bool {
/// Identity must not rest on `Preferences` existing. Chromium writes it lazily,
/// so a crash — or a user deleting a corrupt copy, a standard troubleshooting
/// step since it regenerates — leaves a populated `Default/` without it. Such a
/// directory would then be taken for junk and removed wholesale, destroying the
/// directory would then be treated as stale and removed wholesale, destroying the
/// Extensions and Bookmarks this feature exists to preserve.
fn is_profile_dir_name(name: &str) -> bool {
matches!(name, "Default" | "Guest Profile" | "System Profile")
+19 -65
View File
@@ -279,7 +279,7 @@ impl ProxyManager {
) -> Result<(), Box<dyn std::error::Error>> {
let cache_file = self.get_proxy_check_cache_file(proxy_id)?;
let content = serde_json::to_string_pretty(result)?;
fs::write(&cache_file, content)?;
crate::app_dirs::write_owner_only(&cache_file, content.as_bytes())?;
Ok(())
}
@@ -404,7 +404,7 @@ impl ProxyManager {
let proxy_file = self.get_proxy_file_path(&proxy.id);
let content = serde_json::to_string_pretty(proxy)?;
fs::write(&proxy_file, content)?;
crate::app_dirs::write_owner_only(&proxy_file, content.as_bytes())?;
Ok(())
}
@@ -1636,12 +1636,13 @@ impl ProxyManager {
.arg("--type")
.arg(&proxy_settings.proxy_type);
// Add credentials if provided
// Keep credentials out of process arguments. The short-lived sidecar
// removes these variables before it spawns the detached worker.
if let Some(username) = &proxy_settings.username {
proxy_cmd = proxy_cmd.arg("--username").arg(username);
proxy_cmd = proxy_cmd.env("DONUT_PROXY_USERNAME", username);
}
if let Some(password) = &proxy_settings.password {
proxy_cmd = proxy_cmd.arg("--password").arg(password);
proxy_cmd = proxy_cmd.env("DONUT_PROXY_PASSWORD", password);
}
}
@@ -2526,7 +2527,7 @@ mod tests {
Ok(())
}
// Test that validates the command line arguments are constructed correctly
// Validate that non-secret proxy settings remain command arguments.
#[test]
fn test_proxy_command_construction() {
let proxy_settings = ProxySettings {
@@ -2547,10 +2548,6 @@ mod tests {
"8080",
"--type",
"http",
"--username",
"user",
"--password",
"pass",
];
// This test verifies the argument structure without actually running the command
@@ -2674,61 +2671,6 @@ mod tests {
Ok(())
}
// Test that validates URL encoding for special characters in credentials
#[tokio::test]
async fn test_proxy_credentials_encoding() -> Result<(), Box<dyn std::error::Error>> {
let proxy_path = ensure_donut_proxy_binary().await?;
// Test with credentials that include special characters
let mut cmd = Command::new(&proxy_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg("test.example.com")
.arg("--proxy-port")
.arg("8080")
.arg("--type")
.arg("http")
.arg("--username")
.arg("user@domain.com")
.arg("--password")
.arg("pass word!");
let output = tokio::time::timeout(Duration::from_secs(10), cmd.output()).await??;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
let upstream_url = config["upstreamUrl"].as_str().unwrap();
println!("Generated upstream URL: {upstream_url}");
// Verify that special characters are properly encoded
assert!(upstream_url.contains("user%40domain.com"));
assert!(upstream_url.contains("pass%20word"));
println!("URL encoding test passed - special characters handled correctly");
// Clean up
let proxy_id = config["id"].as_str().unwrap();
let mut stop_cmd = Command::new(&proxy_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let _ = stop_cmd.output().await;
} else {
let stdout = String::from_utf8(output.stdout)?;
let stderr = String::from_utf8(output.stderr)?;
println!("Command failed (expected for non-existent upstream):");
println!("Stdout: {stdout}");
println!("Stderr: {stderr}");
println!("URL encoding test completed - credentials should be properly encoded");
}
Ok(())
}
// ──────────────────────────────────────────────────────────────────────
// Complex proxy process monitoring tests
// ──────────────────────────────────────────────────────────────────────
@@ -3064,6 +3006,18 @@ mod tests {
// Save
save_proxy_config(&config).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode =
std::fs::metadata(crate::proxy_storage::get_storage_dir().join(format!("{id}.json")))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "proxy credentials must be owner-only");
}
// Load and compare
let loaded = get_proxy_config(&id).expect("Config should be loadable");
assert_eq!(loaded.id, config.id);
+68 -5
View File
@@ -11,6 +11,44 @@ lazy_static::lazy_static! {
}
static SIDECAR_VERSION_VERIFIED: AtomicBool = AtomicBool::new(false);
const RETAINED_PROXY_LOGS: usize = 20;
fn prune_stale_proxy_logs(temp_dir: &Path, retain: usize) {
let active_ids = PROXY_PROCESSES
.lock()
.map(|processes| processes.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let Ok(entries) = std::fs::read_dir(temp_dir) else {
return;
};
let mut logs = entries
.flatten()
.filter_map(|entry| {
let file_name = entry.file_name();
let file_name = file_name.to_str()?;
let id = file_name
.strip_prefix("donut-proxy-")?
.strip_suffix(".log")?;
if active_ids.iter().any(|active_id| active_id == id) {
return None;
}
let modified = entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(std::time::UNIX_EPOCH);
Some((modified, entry.path()))
})
.collect::<Vec<_>>();
logs.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.0));
for (_, path) in logs.into_iter().skip(retain) {
if let Err(error) = std::fs::remove_file(&path) {
log::debug!(
"Failed to prune stale proxy log {}: {error}",
path.display()
);
}
}
}
fn target_binary_name(base_name: &str) -> Option<String> {
let target = std::env::var("TARGET").ok()?;
@@ -277,6 +315,10 @@ pub async fn start_proxy_process_with_profile(
// Spawn proxy worker process in the background using std::process::Command
// This ensures proper process detachment on Unix systems
let exe = find_sidecar_executable("donut-proxy")?;
let temp_dir = std::env::temp_dir();
let log_path = temp_dir.join(format!("donut-proxy-{id}.log"));
let log_file = crate::app_dirs::create_owner_only(&log_path);
prune_stale_proxy_logs(&temp_dir, RETAINED_PROXY_LOGS);
#[cfg(unix)]
{
@@ -288,13 +330,14 @@ pub async fn start_proxy_process_with_profile(
cmd.arg("start");
cmd.arg("--id");
cmd.arg(&id);
cmd.env_remove("DONUT_PROXY_USERNAME");
cmd.env_remove("DONUT_PROXY_PASSWORD");
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
// Always log to file for diagnostics (both debug and release builds)
let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
if let Ok(file) = log_file {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
@@ -365,13 +408,14 @@ pub async fn start_proxy_process_with_profile(
cmd.arg("start");
cmd.arg("--id");
cmd.arg(&id);
cmd.env_remove("DONUT_PROXY_USERNAME");
cmd.env_remove("DONUT_PROXY_PASSWORD");
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
// Log to file for diagnostics (matching Unix behavior)
let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
if let Ok(file) = log_file {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
@@ -521,7 +565,9 @@ pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>
#[cfg(test)]
mod tests {
use super::parse_sidecar_version;
use super::{parse_sidecar_version, prune_stale_proxy_logs};
use std::fs;
use std::time::Duration;
#[test]
fn parses_exact_sidecar_version_output() {
@@ -545,4 +591,21 @@ mod tests {
None
);
}
#[test]
fn prunes_only_old_proxy_logs() {
let temp = tempfile::tempdir().unwrap();
for id in ["oldest", "middle", "newest"] {
fs::write(temp.path().join(format!("donut-proxy-{id}.log")), id).unwrap();
std::thread::sleep(Duration::from_millis(10));
}
fs::write(temp.path().join("unrelated.log"), "keep").unwrap();
prune_stale_proxy_logs(temp.path(), 2);
assert!(!temp.path().join("donut-proxy-oldest.log").exists());
assert!(temp.path().join("donut-proxy-middle.log").exists());
assert!(temp.path().join("donut-proxy-newest.log").exists());
assert!(temp.path().join("unrelated.log").exists());
}
}
+12 -3
View File
@@ -1351,9 +1351,8 @@ pub async fn handle_proxy_connection(
/// Render an upstream proxy URL for logging with any embedded credentials
/// stripped. `config.upstream_url` carries `scheme://user:pass@host:port`, and
/// these logs land in a world-readable file under the system temp dir, so the
/// userinfo must never be emitted.
fn redacted_upstream(upstream: &str) -> String {
/// diagnostic logs and command responses must never expose the userinfo.
pub fn redacted_upstream(upstream: &str) -> String {
if upstream.is_empty() {
return "none".to_string();
}
@@ -2238,6 +2237,16 @@ mod tests {
assert_eq!(upstream_userpass(&u), ("u@name".into(), String::new()));
}
#[test]
fn upstream_log_value_never_contains_credentials() {
assert_eq!(
redacted_upstream("http://user:p%40ss@example.com:8080"),
"http://example.com:8080"
);
assert_eq!(redacted_upstream("not a URL"), "<redacted>");
assert_eq!(redacted_upstream(""), "none");
}
#[test]
fn test_blocklist_exact_match() {
let mut matcher = BlocklistMatcher::new();
+45 -4
View File
@@ -87,6 +87,29 @@ impl ProxyConfig {
}
}
pub fn build_proxy_url(
proxy_type: &str,
host: &str,
port: u16,
username: Option<&str>,
password: Option<&str>,
) -> String {
let mut url = format!("{}://", proxy_type.to_lowercase());
if let (Some(user), Some(pass)) = (username, password) {
url.push_str(&format!(
"{}:{}@",
urlencoding::encode(user),
urlencoding::encode(pass)
));
} else if let Some(user) = username {
url.push_str(&format!("{}@", urlencoding::encode(user)));
}
url.push_str(host);
url.push(':');
url.push_str(&port.to_string());
url
}
pub fn get_storage_dir() -> PathBuf {
crate::app_dirs::proxy_workers_dir()
}
@@ -97,7 +120,7 @@ pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error:
let file_path = storage_dir.join(format!("{}.json", config.id));
let content = serde_json::to_string_pretty(config)?;
fs::write(&file_path, content)?;
crate::app_dirs::write_owner_only(&file_path, content.as_bytes())?;
Ok(())
}
@@ -159,10 +182,13 @@ pub fn update_proxy_config(config: &ProxyConfig) -> bool {
return false;
}
match serde_json::to_string_pretty(config) {
Ok(content) => fs::write(&file_path, content).is_ok(),
Err(_) => false,
let Ok(content) = serde_json::to_string_pretty(config) else {
return false;
};
if crate::app_dirs::write_owner_only(&file_path, content.as_bytes()).is_err() {
return false;
}
true
}
pub fn generate_proxy_id() -> String {
@@ -195,6 +221,21 @@ pub fn is_process_running(pid: u32) -> bool {
mod tests {
use super::*;
#[test]
fn proxy_url_encodes_credentials() {
let url = build_proxy_url(
"HTTP",
"test.example.com",
8080,
Some("user@domain.com"),
Some("pass word!"),
);
assert_eq!(
url,
"http://user%40domain.com:pass%20word%21@test.example.com:8080"
);
}
#[test]
fn test_is_process_running_detects_current_process() {
let pid = std::process::id();
+8 -2
View File
@@ -852,7 +852,13 @@ pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, Stri
const MAX_BYTES: usize = 5 * 1024 * 1024;
let mut out = String::with_capacity(64 * 1024);
for (path, _) in entries.iter().rev() {
let header = format!("===== {} =====\n", path.display());
let header = format!(
"===== {} =====\n",
path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("log")
);
if out.len() + header.len() >= MAX_BYTES {
break;
}
@@ -884,7 +890,7 @@ pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, Stri
.map(|s| format!("===== {s}"))
.collect::<String>();
Ok(final_out)
Ok(crate::log_redaction::text(&final_out))
}
/// Reveal the log directory in the OS file manager.
+1 -1
View File
@@ -207,7 +207,7 @@ impl SyncSubscription {
));
}
log::info!("Connected to sync subscription at {url}");
log::info!("Connected to sync subscription");
let _ = events::emit("sync-subscription-status", "connected");
let mut buffer = String::new();
+3 -3
View File
@@ -359,7 +359,7 @@ impl SynchronizerManager {
log::info!("Synchronizer: leader CDP port = {leader_port}, getting WS URL");
let leader_ws_url = Self::get_page_ws_url(leader_port).await?;
log::info!("Synchronizer: connecting to leader page at {leader_ws_url}");
log::info!("Synchronizer: connecting to leader page");
let (mut ws_stream, _) = connect_async(&leader_ws_url)
.await
@@ -504,7 +504,7 @@ impl SynchronizerManager {
Ok(url) => {
match tokio_tungstenite::connect_async(&url).await {
Ok((ws, _)) => {
log::info!("Synchronizer: follower {} connected at {}", fp.name, url);
log::info!("Synchronizer: follower connected");
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<CapturedEvent>();
follower_senders.insert(fid.clone(), tx);
@@ -674,7 +674,7 @@ impl SynchronizerManager {
if is_top {
if let Some(url) = frame.get("url").and_then(|v| v.as_str()) {
if !url.starts_with("about:") && !url.starts_with("chrome://") {
log::info!("Synchronizer: replaying address-bar navigation to {url}");
log::info!("Synchronizer: replaying address-bar navigation");
let nav_event = CapturedEvent {
event_type: "navigate".to_string(),
url: Some(url.to_string()),
+1 -1
View File
@@ -1158,7 +1158,7 @@ mod tests {
#[test]
fn test_parse_key_valid() {
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
assert!(parse_key(key).is_ok());
}
+3 -5
View File
@@ -349,12 +349,11 @@ mod tests {
fn create_test_config() -> WireGuardConfig {
WireGuardConfig {
// These are test keys, not real ones
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
private_key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(),
address: "10.0.0.2/24".to_string(),
dns: Some("1.1.1.1".to_string()),
mtu: Some(1420),
peer_public_key: "aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=".to_string(),
peer_public_key: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string(),
peer_endpoint: "127.0.0.1:51820".to_string(),
allowed_ips: vec!["0.0.0.0/0".to_string()],
persistent_keepalive: Some(25),
@@ -375,8 +374,7 @@ mod tests {
#[test]
fn test_parse_key_valid() {
// Valid base64-encoded 32-byte key
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
let result = WireGuardTunnel::parse_key(key);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 32);
+8 -12
View File
@@ -910,11 +910,6 @@ impl WayfernManager {
);
}
}
if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}"));
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
}
if let Some(proxy) = proxy_url {
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
@@ -942,6 +937,10 @@ impl WayfernManager {
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Some(ref token) = wayfern_token {
command.env("WAYFERN_TOKEN", token);
log::info!("Wayfern authorization configured for browser process");
}
let child = command
.spawn()
@@ -1040,16 +1039,13 @@ impl WayfernManager {
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
log::info!("Applying fingerprint to target via WebSocket: {}", ws_url);
log::info!("Applying fingerprint to page target");
match self
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await
{
Ok(result) => {
log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
);
log::info!("Successfully applied fingerprint to page target");
// Wayfern.setFingerprint echoes back the fingerprint it actually
// used, which may be UPGRADED from what we sent (e.g. when the
// stored fingerprint targets an older browser version). Capture
@@ -1080,7 +1076,7 @@ impl WayfernManager {
// Geolocation is handled internally by the browser binary.
if let Some(url) = url {
log::info!("Navigating to URL via CDP: {}", url);
log::info!("Navigating to URL via CDP");
if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url {
if let Err(e) = self
@@ -1212,7 +1208,7 @@ impl WayfernManager {
return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into());
}
log::info!("Opened URL in new tab via CDP: {}", url);
log::info!("Opened URL in new tab via CDP");
Ok(())
}