mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-24 13:20:57 +02:00
refactor: harden tests
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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>");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user