refactor: cleanup

This commit is contained in:
zhom
2026-08-16 02:09:27 +04:00
parent c07039e0a6
commit 927fe37cda
6 changed files with 192 additions and 24 deletions
+1 -1
View File
@@ -1803,7 +1803,7 @@ dependencies = [
[[package]] [[package]]
name = "donutbrowser" name = "donutbrowser"
version = "0.29.2" version = "0.29.3"
dependencies = [ dependencies = [
"aes 0.9.1", "aes 0.9.1",
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
+100 -11
View File
@@ -56,6 +56,12 @@ pub struct ApiProfile {
/// Such a profile cannot be launched locally, and must only ever run on a /// Such a profile cannot be launched locally, and must only ever run on a
/// remote host of its own OS — Chromium profile state is OS-specific. /// remote host of its own OS — Chromium profile state is OS-specific.
pub is_cross_os: bool, pub is_cross_os: bool,
/// The fingerprint operating system set at creation via `wayfern_config.os`
/// (`"windows"`, `"macos"`, `"linux"`, `"android"` or `"ios"`), or `null`
/// when the fingerprint was generated for the host. This is what the browser
/// reports to sites; `host_os` is the machine the profile was created on and
/// is a different thing.
pub fingerprint_os: Option<String>,
} }
impl From<&crate::profile::types::BrowserProfile> for ApiProfile { impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
@@ -83,6 +89,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
cloud_sync_enabled: profile.is_sync_enabled(), cloud_sync_enabled: profile.is_sync_enabled(),
host_os: profile.resolved_os().map(|os| os.to_string()), host_os: profile.resolved_os().map(|os| os.to_string()),
is_cross_os: profile.is_cross_os(), is_cross_os: profile.is_cross_os(),
fingerprint_os: profile.wayfern_config.as_ref().and_then(|c| c.os.clone()),
} }
} }
} }
@@ -1228,15 +1235,19 @@ async fn get_profile(
/// locally (this endpoint does not download new versions); 400 if none is. /// locally (this endpoint does not download new versions); 400 if none is.
/// - Omitting the matching `wayfern_config`, or passing an /// - Omitting the matching `wayfern_config`, or passing an
/// empty object `{}`, generates a fresh fingerprint automatically. /// empty object `{}`, generates a fresh fingerprint automatically.
/// - `wayfern_config.os` picks the fingerprint OS (`"windows"`, `"macos"`,
/// `"linux"`, `"android"`, `"ios"`). Omit it to match the host. Any other
/// OS is cross-OS spoofing and needs an active Pro plan; 402 otherwise.
/// A `wayfern_config` that fails to parse is a 400, never a silent default.
#[utoipa::path( #[utoipa::path(
post, post,
path = "/v1/profiles", path = "/v1/profiles",
request_body = CreateProfileRequest, request_body = CreateProfileRequest,
responses( responses(
(status = 200, description = "Profile created successfully", body = ApiProfileResponse), (status = 200, description = "Profile created successfully", body = ApiProfileResponse),
(status = 400, description = "Invalid browser, or no downloaded version available"), (status = 400, description = "Invalid browser, invalid wayfern_config, or no downloaded version available"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
(status = 402, description = "Selected proxy requires payment"), (status = 402, description = "Selected proxy requires payment, or a cross-OS fingerprint requires Pro"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error")
), ),
security( security(
@@ -1292,13 +1303,33 @@ async fn create_profile(
} }
}; };
// Parse wayfern config if provided // Parse wayfern config if provided. A malformed config is a 400, never a
let wayfern_config = if let Some(config) = &request.wayfern_config { // silent fallback: swallowing it here produced a host-OS profile from a
serde_json::from_value(config.clone()).ok() // request that explicitly asked for another OS, with a 200 and no diagnostic.
} else { let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> = match &request.wayfern_config
None {
Some(config) => Some(serde_json::from_value(config.clone()).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid wayfern_config: {e}"),
)
})?),
None => None,
}; };
// Cross-OS fingerprints are a paid capability. The Tauri command, the
// importer and MCP each check this; REST did not, so the restriction was
// bypassable through this endpoint alone.
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(wayfern_config.as_ref().and_then(|c| c.os.as_deref()))
.await
{
return Err((
StatusCode::PAYMENT_REQUIRED,
serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string(),
));
}
// Reject a dead/unreachable proxy or VPN before creating the profile. A 402 // Reject a dead/unreachable proxy or VPN before creating the profile. A 402
// (expired proxy subscription) maps to 402; anything else is a 400. // (expired proxy subscription) maps to 402; anything else is a 400.
if let Err(err) = if let Err(err) =
@@ -3769,10 +3800,19 @@ async fn import_profiles_api(
State(state): State<ApiServerState>, State(state): State<ApiServerState>,
Json(request): Json<ImportProfilesRequest>, Json(request): Json<ImportProfilesRequest>,
) -> Result<Json<crate::profile_importer::ProfileImportBatchResult>, (StatusCode, String)> { ) -> Result<Json<crate::profile_importer::ProfileImportBatchResult>, (StatusCode, String)> {
let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> = request // A malformed config is a 400. Dropping it silently also dropped the `os`
.wayfern_config // it carried, which made `is_fingerprint_os_allowed(None)` return true and
.as_ref() // bypassed the Pro gate below while generating host-OS fingerprints.
.and_then(|config| serde_json::from_value(config.clone()).ok()); let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> =
match request.wayfern_config.as_ref() {
Some(config) => Some(serde_json::from_value(config.clone()).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid wayfern_config: {e}"),
)
})?),
None => None,
};
// The Pro gate for fingerprint OS spoofing lives inside import_profiles, so // The Pro gate for fingerprint OS spoofing lives inside import_profiles, so
// every surface inherits it; manager_error_response maps the code to 402. // every surface inherits it; manager_error_response maps the code to 402.
@@ -4148,6 +4188,55 @@ mod tests {
assert!(parsed.wayfern_config.is_none()); assert!(parsed.wayfern_config.is_none());
} }
#[test]
fn wayfern_config_os_survives_the_untyped_request_field() {
// `wayfern_config` arrives as an untyped Value and is only turned into a
// WayfernConfig inside the handler. That second hop is where an `os` used
// to be lost, so assert it round-trips.
let json = r#"{"name": "p", "browser": "wayfern", "wayfern_config": {"os": "android"}}"#;
let parsed: CreateProfileRequest = serde_json::from_str(json).expect("body must parse");
let config: crate::wayfern_manager::WayfernConfig =
serde_json::from_value(parsed.wayfern_config.expect("config present"))
.expect("a well-formed config must parse");
assert_eq!(config.os.as_deref(), Some("android"));
}
#[test]
fn malformed_wayfern_config_is_an_error_not_a_default() {
// `fingerprint` is a JSON-encoded string, so passing an object fails to
// parse. The handler must surface that as a 400: previously `.ok()` threw
// the whole config away, dropping the caller's `os` with it and returning
// a host-OS profile with 200 and no diagnostic.
let json = r#"{"os": "android", "fingerprint": {"platform": "Linux armv81"}}"#;
let value: serde_json::Value = serde_json::from_str(json).expect("value parses");
let parsed = serde_json::from_value::<crate::wayfern_manager::WayfernConfig>(value);
assert!(
parsed.is_err(),
"an object fingerprint must not silently deserialize"
);
}
#[test]
fn api_profile_exposes_the_fingerprint_os_separately_from_host_os() {
// host_os is the machine; fingerprint_os is what the browser reports. A
// cross-OS profile has to be distinguishable through the API alone.
let spec = ApiDoc::openapi();
let spec = serde_json::to_value(&spec).expect("spec serializes");
let props = &spec["components"]["schemas"]["ApiProfile"]["properties"];
assert!(
props.get("fingerprint_os").is_some(),
"ApiProfile must publish fingerprint_os"
);
let required = spec["components"]["schemas"]["ApiProfile"]["required"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
!required.iter().any(|r| r == "fingerprint_os"),
"fingerprint_os is nullable and must stay optional"
);
}
#[test] #[test]
fn create_profile_browser_validation_matches_supported_engines() { fn create_profile_browser_validation_matches_supported_engines() {
// The handler rejects anything that isn't a launchable engine; this is the // The handler rejects anything that isn't a launchable engine; this is the
+31 -3
View File
@@ -1138,6 +1138,11 @@ impl CloudAuthManager {
/// is nothing to fetch and nothing wrong. /// is nothing to fetch and nothing wrong.
pub async fn request_wayfern_token(&self) -> Result<(), String> { pub async fn request_wayfern_token(&self) -> Result<(), String> {
if !self.is_entitled_to_wayfern_token().await { if !self.is_entitled_to_wayfern_token().await {
// Ok(()) here means callers log nothing, so a session that declined to
// mint left no trace at all and looked identical to one that succeeded.
log::info!(
"Skipping wayfern token request: the cached plan does not include browser automation"
);
self.clear_wayfern_token().await; self.clear_wayfern_token().await;
return Ok(()); return Ok(());
} }
@@ -1273,9 +1278,11 @@ impl CloudAuthManager {
} }
} }
// Refresh profile data periodically // Refresh profile data periodically. A failure here leaves the cached
// plan stale, which silently gates paid features, so it belongs at warn
// rather than debug where the shipped log level hides it.
if let Err(e) = CLOUD_AUTH.fetch_profile().await { if let Err(e) = CLOUD_AUTH.fetch_profile().await {
log::debug!("Failed to refresh cloud profile: {e}"); log::warn!("Failed to refresh cloud profile: {e}");
} }
// Reconnect profile lock manager if needed // Reconnect profile lock manager if needed
@@ -1291,7 +1298,14 @@ impl CloudAuthManager {
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop). // Refresh wayfern token every 10 hours (60 iterations of 10-minute loop).
// request_wayfern_token owns the entitlement check and clears the cached // request_wayfern_token owns the entitlement check and clears the cached
// token when the plan doesn't include automation. // token when the plan doesn't include automation.
if wayfern_refresh_counter >= 60 { //
// Also mint one as soon as the plan starts granting it. `fetch_profile`
// above picks up an upgrade within ten minutes, but nothing watched that
// transition, so a session that signed in before upgrading stayed
// tokenless for up to ten hours while reporting the feature as unlocked.
let missing_entitled_token = CLOUD_AUTH.is_entitled_to_wayfern_token().await
&& CLOUD_AUTH.get_wayfern_token().await.is_none();
if wayfern_refresh_counter >= 60 || missing_entitled_token {
wayfern_refresh_counter = 0; wayfern_refresh_counter = 0;
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await { if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to refresh wayfern token: {e}"); log::warn!("Failed to refresh wayfern token: {e}");
@@ -1411,6 +1425,20 @@ pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> { pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
let mut user = CLOUD_AUTH.fetch_profile().await?; let mut user = CLOUD_AUTH.fetch_profile().await?;
user.entitlements = Some(user.entitlements()); user.entitlements = Some(user.entitlements());
// Minting the token is what actually unlocks cross-OS fingerprints, and it
// only happened at login, at startup and once every 10 hours. An account
// that upgraded after its last sign-in therefore refreshed into the correct
// entitlements while still holding no token, and "Refresh" did not fix it.
// Only mint when one is genuinely missing, so this stays a no-op afterwards.
if CLOUD_AUTH.is_entitled_to_wayfern_token().await
&& CLOUD_AUTH.get_wayfern_token().await.is_none()
{
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Refresh could not obtain a wayfern token: {e}");
}
}
Ok(user) Ok(user)
} }
+20 -8
View File
@@ -2,7 +2,7 @@ use crate::browser::{create_browser, BrowserType};
use crate::cloud_auth::CLOUD_AUTH; use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events; use crate::events;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode}; use crate::profile::types::{get_host_os, is_host_os, BrowserProfile, SyncMode};
use crate::proxy_manager::PROXY_MANAGER; use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig; use crate::wayfern_manager::WayfernConfig;
use std::fs::{self, create_dir_all}; use std::fs::{self, create_dir_all};
@@ -384,11 +384,23 @@ impl ProfileManager {
}; };
// Backfill host_os from browser config for profiles created before // Backfill host_os from browser config for profiles created before
// the field existed (or synced without it). // the field existed (or synced without it), and repair any profile
if profile.host_os.is_none() { // already stamped with a fingerprint-only OS.
let inferred_os = profile.resolved_os().map(str::to_string); //
if let Some(os) = inferred_os { // Only a real host OS may be stored here. The fallback in
profile.host_os = Some(os); // `resolved_os` reads `wayfern_config.os`, which is a fingerprint OS
// and may be "android"/"ios". Persisting that made `is_cross_os`
// permanently true and locked the profile out of every local launch,
// with no way to undo it from the UI. Leaving `host_os` as None keeps
// the profile launchable, which is what it was before the field.
let needs_repair = profile.host_os.as_deref().is_some_and(|os| !is_host_os(os));
if profile.host_os.is_none() || needs_repair {
let inferred_os = profile
.resolved_os()
.filter(|os| is_host_os(os))
.map(str::to_string);
if inferred_os != profile.host_os {
profile.host_os = inferred_os;
if let Ok(json) = serde_json::to_string_pretty(&profile) { if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = atomic_write(&metadata_file, json.as_bytes()); let _ = atomic_write(&metadata_file, json.as_bytes());
} }
@@ -1924,7 +1936,7 @@ pub async fn create_browser_profile_new(
.is_fingerprint_os_allowed(fingerprint_os) .is_fingerprint_os_allowed(fingerprint_os)
.await .await
{ {
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string()); return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
} }
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy // A dead/unreachable proxy or VPN (or a 402 from an expired proxy
@@ -1968,7 +1980,7 @@ pub async fn update_wayfern_config(
.is_fingerprint_os_allowed(config.os.as_deref()) .is_fingerprint_os_allowed(config.os.as_deref())
.await .await
{ {
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string()); return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
} }
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
+35
View File
@@ -103,6 +103,16 @@ pub fn get_host_os() -> String {
} }
} }
/// Whether a value is one `get_host_os` can actually return.
///
/// A fingerprint OS is a wider set than a host OS: `"android"` and `"ios"` are
/// valid fingerprints but no machine ever reports them as its host. Storing one
/// in `host_os` makes `is_cross_os` permanently true, which bars the profile
/// from every local launch path on the very machine that created it.
pub fn is_host_os(value: &str) -> bool {
matches!(value, "macos" | "windows" | "linux")
}
impl BrowserProfile { impl BrowserProfile {
/// Get the path to the profile data directory (profiles/{uuid}/profile) /// Get the path to the profile data directory (profiles/{uuid}/profile)
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf { pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
@@ -138,3 +148,28 @@ impl BrowserProfile {
self.sync_mode == SyncMode::Encrypted self.sync_mode == SyncMode::Encrypted
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_os_is_always_a_valid_host_os() {
// The invariant the host_os backfill guard rests on: whatever this machine
// reports must satisfy is_host_os, on every platform.
assert!(is_host_os(&get_host_os()));
}
#[test]
fn mobile_fingerprint_targets_are_not_host_operating_systems() {
// Backfilling host_os from a fingerprint OS used to store these, and since
// get_host_os can never return them, is_cross_os stayed true forever and
// the profile could not be launched on the machine that created it.
for os in ["macos", "windows", "linux"] {
assert!(is_host_os(os), "{os} must count as a host OS");
}
for os in ["android", "ios", "", "Windows", "chromeos"] {
assert!(!is_host_os(os), "{os} must not be stored as a host OS");
}
}
}
+5 -1
View File
@@ -657,9 +657,13 @@ impl WayfernManager {
let fingerprint_json = serde_json::to_string(&fingerprint) let fingerprint_json = serde_json::to_string(&fingerprint)
.map_err(|e| format!("Failed to serialize fingerprint: {e}"))?; .map_err(|e| format!("Failed to serialize fingerprint: {e}"))?;
// Report the platform the engine actually produced alongside the one that
// was asked for. Logging only the request made this line useless for
// diagnosing a fingerprint that came back as something else.
log::info!( log::info!(
"Generated Wayfern fingerprint for OS: {}, fields: {:?}", "Generated Wayfern fingerprint for requested OS: {}, produced platform: {:?}, fields: {:?}",
os, os,
fingerprint.get("platform").and_then(|p| p.as_str()),
fingerprint fingerprint
.as_object() .as_object()
.map(|o| o.keys().collect::<Vec<_>>()) .map(|o| o.keys().collect::<Vec<_>>())