mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-17 08:31:23 +02:00
refactor: cleanup
This commit is contained in:
Generated
+1
-1
@@ -1803,7 +1803,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.29.2"
|
||||
version = "0.29.3"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm 0.11.0",
|
||||
|
||||
+100
-11
@@ -56,6 +56,12 @@ pub struct ApiProfile {
|
||||
/// 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.
|
||||
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 {
|
||||
@@ -83,6 +89,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
|
||||
cloud_sync_enabled: profile.is_sync_enabled(),
|
||||
host_os: profile.resolved_os().map(|os| os.to_string()),
|
||||
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.
|
||||
/// - Omitting the matching `wayfern_config`, or passing an
|
||||
/// 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(
|
||||
post,
|
||||
path = "/v1/profiles",
|
||||
request_body = CreateProfileRequest,
|
||||
responses(
|
||||
(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 = 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")
|
||||
),
|
||||
security(
|
||||
@@ -1292,13 +1303,33 @@ async fn create_profile(
|
||||
}
|
||||
};
|
||||
|
||||
// Parse wayfern config if provided
|
||||
let wayfern_config = if let Some(config) = &request.wayfern_config {
|
||||
serde_json::from_value(config.clone()).ok()
|
||||
} else {
|
||||
None
|
||||
// Parse wayfern config if provided. A malformed config is a 400, never a
|
||||
// silent fallback: swallowing it here produced a host-OS profile from a
|
||||
// request that explicitly asked for another OS, with a 200 and no diagnostic.
|
||||
let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> = match &request.wayfern_config
|
||||
{
|
||||
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
|
||||
// (expired proxy subscription) maps to 402; anything else is a 400.
|
||||
if let Err(err) =
|
||||
@@ -3769,10 +3800,19 @@ async fn import_profiles_api(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<ImportProfilesRequest>,
|
||||
) -> Result<Json<crate::profile_importer::ProfileImportBatchResult>, (StatusCode, String)> {
|
||||
let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> = request
|
||||
.wayfern_config
|
||||
.as_ref()
|
||||
.and_then(|config| serde_json::from_value(config.clone()).ok());
|
||||
// A malformed config is a 400. Dropping it silently also dropped the `os`
|
||||
// it carried, which made `is_fingerprint_os_allowed(None)` return true and
|
||||
// bypassed the Pro gate below while generating host-OS fingerprints.
|
||||
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
|
||||
// every surface inherits it; manager_error_response maps the code to 402.
|
||||
@@ -4148,6 +4188,55 @@ mod tests {
|
||||
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]
|
||||
fn create_profile_browser_validation_matches_supported_engines() {
|
||||
// The handler rejects anything that isn't a launchable engine; this is the
|
||||
|
||||
@@ -1138,6 +1138,11 @@ impl CloudAuthManager {
|
||||
/// is nothing to fetch and nothing wrong.
|
||||
pub async fn request_wayfern_token(&self) -> Result<(), String> {
|
||||
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;
|
||||
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 {
|
||||
log::debug!("Failed to refresh cloud profile: {e}");
|
||||
log::warn!("Failed to refresh cloud profile: {e}");
|
||||
}
|
||||
|
||||
// Reconnect profile lock manager if needed
|
||||
@@ -1291,7 +1298,14 @@ impl CloudAuthManager {
|
||||
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop).
|
||||
// request_wayfern_token owns the entitlement check and clears the cached
|
||||
// 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;
|
||||
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
|
||||
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> {
|
||||
let mut user = CLOUD_AUTH.fetch_profile().await?;
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::browser::{create_browser, BrowserType};
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
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::wayfern_manager::WayfernConfig;
|
||||
use std::fs::{self, create_dir_all};
|
||||
@@ -384,11 +384,23 @@ impl ProfileManager {
|
||||
};
|
||||
|
||||
// Backfill host_os from browser config for profiles created before
|
||||
// the field existed (or synced without it).
|
||||
if profile.host_os.is_none() {
|
||||
let inferred_os = profile.resolved_os().map(str::to_string);
|
||||
if let Some(os) = inferred_os {
|
||||
profile.host_os = Some(os);
|
||||
// the field existed (or synced without it), and repair any profile
|
||||
// already stamped with a fingerprint-only OS.
|
||||
//
|
||||
// Only a real host OS may be stored here. The fallback in
|
||||
// `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) {
|
||||
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)
|
||||
.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
|
||||
@@ -1968,7 +1980,7 @@ pub async fn update_wayfern_config(
|
||||
.is_fingerprint_os_allowed(config.os.as_deref())
|
||||
.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();
|
||||
|
||||
@@ -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 {
|
||||
/// Get the path to the profile data directory (profiles/{uuid}/profile)
|
||||
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
|
||||
@@ -138,3 +148,28 @@ impl BrowserProfile {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,9 +657,13 @@ impl WayfernManager {
|
||||
let fingerprint_json = serde_json::to_string(&fingerprint)
|
||||
.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!(
|
||||
"Generated Wayfern fingerprint for OS: {}, fields: {:?}",
|
||||
"Generated Wayfern fingerprint for requested OS: {}, produced platform: {:?}, fields: {:?}",
|
||||
os,
|
||||
fingerprint.get("platform").and_then(|p| p.as_str()),
|
||||
fingerprint
|
||||
.as_object()
|
||||
.map(|o| o.keys().collect::<Vec<_>>())
|
||||
|
||||
Reference in New Issue
Block a user