feat: cross-OS profile sync

This commit is contained in:
zhom committed 2026-10-11 17:09:42 +04:00
1 parent b7a89ac94e
commit c359049e09
28 files changed
+532 -122

No files matched your search

+40 -20
View File
@@ -65,9 +65,10 @@ pub struct ApiProfile {
/// OS the profile was created on (`"macos"`, `"windows"`, `"linux"`).
/// `null` when neither `host_os` nor the browser config records one.
pub host_os: Option<String>,
/// True when the profile belongs to a different OS than this machine.
/// 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.
/// True when the profile cannot be launched on this machine because a
/// browser bound to its OS created it on another OS; such a profile can only
/// run on a remote host of its own OS. Always false for Wayfern profiles,
/// which sync their data and launch on every OS.
pub is_cross_os: bool,
/// The fingerprint operating system set at creation via `wayfern_config.os`
/// (`"windows"`, `"macos"`, `"linux"`, `"android"` or `"ios"`), or `null`
@@ -1292,9 +1293,10 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
StatusCode::CONFLICT
} else if code == "INTERNAL_ERROR" {
StatusCode::INTERNAL_SERVER_ERROR
} else if code == "PROFILE_LOCK_UNAVAILABLE" {
// The lock service could not be reached. The launch is refused because
// it cannot be proven safe, which is an upstream failure, not the
} else if code == "PROFILE_LOCK_UNAVAILABLE" || code == "PROFILE_DATA_NOT_DOWNLOADED" {
// The lock service, or the sync storage holding a profile's first
// download, could not be reached. The launch is refused because it
// cannot be proven safe, which is an upstream failure, not the
// caller's fault.
StatusCode::SERVICE_UNAVAILABLE
} else if code.ends_with("_REQUIRES_PRO")
@@ -2905,7 +2907,7 @@ async fn remove_extension_from_group_api(
(status = 404, description = "Profile not found"),
(status = 409, description = "Profile is locked by another team member, running on the remote fleet, or waiting for a finished remote session to be pulled back"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 503, description = "The profile lock service could not be reached"),
(status = 503, description = "The profile lock service could not be reached, or the profile's files could not be downloaded from sync before its first launch here"),
(status = 500, description = "Internal server error")
),
security(
@@ -3058,9 +3060,9 @@ async fn run_profile_remote(
}
}
// Deliberately NO is_cross_os() guard here. Local /run refuses a foreign
// profile because this machine is the wrong OS; running it remotely on a host
// of its OWN OS is precisely what this endpoint exists for.
// Deliberately NO is_cross_os() guard here. Local /run refuses a profile
// bound to another OS because this machine is the wrong OS; running it
// remotely on a host of its OWN OS is precisely what this endpoint exists for.
let outcome =
crate::remote_session::start_remote_session(state.app_handle.clone(), profile, request.url)
.await
@@ -4044,7 +4046,7 @@ async fn get_cookie_bot_usage(
(status = 404, description = "Profile not found"),
(status = 409, description = "Profile is locked by another team member, or waiting for a finished remote session to be pulled back"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 503, description = "The profile lock service could not be reached"),
(status = 503, description = "The profile lock service could not be reached, or the profile's files could not be downloaded from sync before its first launch here"),
(status = 500, description = "Internal server error")
),
security(
@@ -5062,14 +5064,16 @@ mod tests {
"windows"
};
let foreign = profile_with(SyncMode::Regular, Some(other));
// A Wayfern profile from another OS runs locally too, and remotely.
assert!(!foreign.is_cross_os());
assert!(remote_launch_profile_rules(&foreign).is_ok());
assert!(
foreign.is_cross_os(),
"test setup: profile should be foreign"
);
let mut bound = foreign.clone();
bound.browser = "camoufox".to_string();
assert!(bound.is_cross_os(), "test setup: profile should be foreign");
// Local /run refuses this; running it remotely on a host of its own OS is
// exactly what /run-remote is for.
assert!(remote_launch_profile_rules(&foreign).is_ok());
assert!(remote_launch_profile_rules(&bound).is_ok());
}
#[tokio::test]
@@ -5113,9 +5117,9 @@ mod tests {
assert!(encrypted.cloud_sync_enabled);
}
// A profile must only ever run on its own operating system: Chromium's
// on-disk state is OS-specific, so replaying a macOS profile on Windows is a
// mismatch no amount of user-agent spoofing repairs.
// `host_os` reports where the profile was created; `is_cross_os` reports
// whether that stops it from launching here, which it never does for a
// Wayfern profile.
#[test]
fn api_profile_reports_its_operating_system() {
let host = crate::profile::types::get_host_os();
@@ -5130,7 +5134,13 @@ mod tests {
};
let foreign = ApiProfile::from(&profile_with(SyncMode::Regular, Some(other)));
assert_eq!(foreign.host_os.as_deref(), Some(other));
assert!(foreign.is_cross_os);
assert!(!foreign.is_cross_os);
let mut bound = profile_with(SyncMode::Regular, Some(other));
bound.browser = "camoufox".to_string();
let bound = ApiProfile::from(&bound);
assert_eq!(bound.host_os.as_deref(), Some(other));
assert!(bound.is_cross_os);
}
#[test]
@@ -6072,6 +6082,16 @@ mod tests {
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn a_first_download_that_cannot_complete_is_not_the_callers_fault() {
let (status, body) = manager_error_response(crate::backend_error_with_detail(
"PROFILE_DATA_NOT_DOWNLOADED",
"offline",
));
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(body.contains("PROFILE_DATA_NOT_DOWNLOADED"));
}
#[test]
fn a_rejected_extension_upload_is_the_callers_problem_not_a_server_fault() {
// Every one of these is something about the request: the wrong file type, a
+74 -13
View File
@@ -66,12 +66,13 @@ fn log_err(err: &str, profile: &BrowserProfile) -> String {
}
/// Outcomes the person meets as a dialog or a notice, not a fault.
const LAUNCH_BLOCK_CODES: [&str; 10] = [
const LAUNCH_BLOCK_CODES: [&str; 11] = [
"FINGERPRINT_EXIT_MISMATCH",
"LAUNCH_CONSENT_EXPIRED",
"PROFILE_RUNNING",
"PROFILE_RUNNING_REMOTELY",
"PROFILE_REMOTE_SYNC_PENDING",
"PROFILE_DATA_NOT_DOWNLOADED",
"PROFILE_LOCKED_BY_MEMBER",
"PROFILE_LOCKED_ELSEWHERE",
"PROFILE_ROUTE_REQUIRED",
@@ -122,6 +123,17 @@ fn log_launch_failure(profile: &BrowserProfile, err: &str, started: std::time::I
);
}
/// Why this machine refuses to launch the profile locally, or `None` when it
/// may. A Wayfern profile is never refused for the OS that created it.
fn local_os_refusal(profile: &BrowserProfile) -> Option<String> {
profile.is_cross_os().then(|| {
format!(
"this profile was created on {} and cannot be launched on a different operating system",
profile.host_os.as_deref().unwrap_or("another OS"),
)
})
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -1453,10 +1465,10 @@ impl BrowserRunner {
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
// A profile already open on the leased fleet is driven, not launched. This
// sits above the cross-OS guard on purpose: a Windows profile cannot run on
// this Mac, which is the whole reason it is running remotely, and refusing
// to point it at a URL for that reason would make the remote session
// unusable from the one endpoint that exists to use it.
// sits above the cross-OS guard on purpose: a profile bound to another OS
// cannot run here, and refusing to point its remote session at a URL for
// that reason would make the session unusable from the one endpoint that
// exists to use it.
//
// Guarded on there being no local process, so a profile running here never
// pays for the lookup: a local launch records a pid, and the profile lock
@@ -1483,11 +1495,10 @@ impl BrowserRunner {
}
}
if profile.is_cross_os() {
if let Some(reason) = local_os_refusal(&profile) {
return Err(format!(
"Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
"Cannot open URL with profile '{}': {reason}",
profile.name
));
}
@@ -1498,6 +1509,10 @@ impl BrowserRunner {
// to a local launch on a profile a host was writing to.
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
if let Err(e) = crate::sync::download_before_first_launch(&app_handle, &profile).await {
unwind_launch(&profile, acquired_team_lock).await;
return Err(e);
}
let started = std::time::Instant::now();
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
@@ -1662,11 +1677,10 @@ async fn launch_browser_profile_tracked(
} = options;
emit_launch_stage(&profile, "preparing", None);
if profile.is_cross_os() {
if let Some(reason) = local_os_refusal(&profile) {
return Err(format!(
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
"Cannot launch profile '{}': {reason}",
profile.name
));
}
@@ -1679,6 +1693,13 @@ async fn launch_browser_profile_tracked(
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
// A profile that reached this machine as metadata alone downloads its files
// first; launched empty, its next sync would erase the real profile.
if let Err(e) = crate::sync::download_before_first_launch(&app_handle, &profile).await {
unwind_launch(&profile, acquired_team_lock).await;
return Err(e);
}
// Notify sync scheduler that profile is now running and queue sync for when it stops
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let pid = profile.id.to_string();
@@ -1940,4 +1961,44 @@ mod tests {
.is_ok()
);
}
fn profile_created_on(browser: &str, os: &str) -> BrowserProfile {
BrowserProfile {
name: "p".to_string(),
browser: browser.to_string(),
host_os: Some(os.to_string()),
..BrowserProfile::default()
}
}
#[test]
fn a_wayfern_profile_from_any_os_launches_locally() {
for os in ["macos", "windows", "linux"] {
assert_eq!(
local_os_refusal(&profile_created_on("wayfern", os)),
None,
"a Wayfern profile created on {os} must launch here"
);
}
}
#[test]
fn a_profile_bound_to_another_os_is_refused_locally() {
let host = crate::profile::types::get_host_os();
let other = if host == "windows" {
"macos"
} else {
"windows"
};
let reason = local_os_refusal(&profile_created_on("camoufox", other))
.expect("a profile bound to another OS must be refused");
assert!(
reason.contains(other),
"the reason must name the OS: {reason}"
);
assert_eq!(
local_os_refusal(&profile_created_on("camoufox", &host)),
None
);
}
}
+3 -3
View File
@@ -9469,9 +9469,9 @@ impl McpServer {
/// Start this profile on a host of its own operating system.
///
/// Deliberately no `is_cross_os` guard: local `run_profile` refuses a foreign
/// profile because THIS machine is the wrong OS, and running it on a host of
/// its own OS is precisely what this exists for.
/// Deliberately no `is_cross_os` guard: local `run_profile` refuses a profile
/// bound to another OS because THIS machine is the wrong OS, and running it
/// on a host of its own OS is precisely what this exists for.
async fn handle_run_profile_remote(
&self,
arguments: &serde_json::Value,
+11 -7
View File
@@ -601,10 +601,10 @@ impl ProfileManager {
//
// 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.
// and may be "android"/"ios". Persisting that records a creating
// machine that cannot exist, with no way to undo it from the UI.
// Leaving `host_os` as None means "unknown", 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
@@ -1617,9 +1617,13 @@ impl ProfileManager {
{
return Err(crate::backend_error("FINGERPRINT_REQUIRES_PRO").into());
}
if !CLOUD_AUTH
.is_fingerprint_os_allowed(config.os.as_deref())
.await
// Only a new claim is checked: a profile created on another OS keeps the
// claim it arrived with, and editing anything else must not refuse it.
let os_changed = config.os != stored.and_then(|c| c.os.clone());
if os_changed
&& !CLOUD_AUTH
.is_fingerprint_os_allowed(config.os.as_deref())
.await
{
return Err(crate::backend_error("FINGERPRINT_REQUIRES_PRO").into());
}
+6 -2
View File
@@ -446,7 +446,8 @@ pub fn purge_expired(trash_root: &Path, now: u64, retention_days: u32) -> Vec<St
/// A profile whose browser process is alive on this machine cannot be
/// trashed: its data directory is in use. A stale process id (the browser
/// crashed) does not count, and a cross-OS profile can never be running here.
/// crashed) does not count, and a profile bound to another OS can never be
/// running here.
pub fn is_running_locally(profile: &BrowserProfile) -> bool {
profile
.process_id
@@ -1043,12 +1044,15 @@ mod tests {
let mut profile = sample_profile("Running");
profile.process_id = Some(std::process::id());
assert!(is_running_locally(&profile));
// A cross-OS profile can never be running on this machine.
// A Wayfern profile created on another OS runs here like any other.
profile.host_os = Some(if cfg!(target_os = "macos") {
"linux".to_string()
} else {
"macos".to_string()
});
assert!(is_running_locally(&profile));
// A profile bound to another OS can never be running on this machine.
profile.browser = "camoufox".to_string();
assert!(!is_running_locally(&profile));
let mut idle = sample_profile("Idle");
idle.process_id = None;
+84 -4
View File
@@ -114,8 +114,8 @@ pub fn get_host_os() -> String {
///
/// 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.
/// in `host_os` records a creating machine that cannot exist, which every
/// reader of `host_os` then treats as foreign.
pub fn is_host_os(value: &str) -> bool {
matches!(value, "macos" | "windows" | "linux")
}
@@ -136,15 +136,39 @@ impl BrowserProfile {
.or_else(|| self.wayfern_config.as_ref().and_then(|c| c.os.as_deref()))
}
/// Returns true when the profile was created on a different OS than the current host.
/// Checks `host_os` first, then falls back to the browser config's `os` field.
/// Whether this profile's directory runs on every supported OS, whichever OS
/// wrote it. True for Wayfern; other browsers stay on the OS that created
/// their profile.
pub fn runs_on_any_os(&self) -> bool {
self.browser == "wayfern"
}
/// Returns true when the profile cannot run here because a browser bound to
/// its OS created it on a different OS. A Wayfern profile is never cross-OS:
/// it syncs its data and launches on every OS.
pub fn is_cross_os(&self) -> bool {
if self.runs_on_any_os() {
return false;
}
match self.resolved_os() {
Some(os) => os != get_host_os(),
None => false,
}
}
/// The OS a fingerprint without an explicit `os` describes: the machine that
/// created the profile, or this machine when that is not recorded. Such a
/// fingerprint was generated for its creating machine, so it keeps that OS
/// when the profile moves to another one.
pub fn native_os(&self) -> String {
self
.host_os
.as_deref()
.filter(|os| is_host_os(os))
.map(str::to_string)
.unwrap_or_else(get_host_os)
}
/// Returns true if sync is enabled (either Regular or Encrypted mode).
pub fn is_sync_enabled(&self) -> bool {
self.sync_mode != SyncMode::Disabled
@@ -179,4 +203,60 @@ mod tests {
assert!(!is_host_os(os), "{os} must not be stored as a host OS");
}
}
fn other_host_os() -> &'static str {
if get_host_os() == "windows" {
"macos"
} else {
"windows"
}
}
fn profile_from(browser: &str, host_os: Option<&str>) -> BrowserProfile {
BrowserProfile {
browser: browser.to_string(),
host_os: host_os.map(str::to_string),
..BrowserProfile::default()
}
}
#[test]
fn a_wayfern_profile_from_another_os_is_not_cross_os() {
for os in ["macos", "windows", "linux"] {
let profile = profile_from("wayfern", Some(os));
assert!(profile.runs_on_any_os());
assert!(
!profile.is_cross_os(),
"a Wayfern profile created on {os} must run here"
);
}
// A claimed OS with no recorded creating machine is not a reason either.
let mut claimed = profile_from("wayfern", None);
claimed.wayfern_config = Some(WayfernConfig {
os: Some(other_host_os().to_string()),
..WayfernConfig::default()
});
assert!(!claimed.is_cross_os());
}
#[test]
fn a_profile_of_another_browser_stays_on_its_own_os() {
let foreign = profile_from("camoufox", Some(other_host_os()));
assert!(!foreign.runs_on_any_os());
assert!(foreign.is_cross_os());
assert!(!profile_from("camoufox", Some(&get_host_os())).is_cross_os());
assert!(!profile_from("camoufox", None).is_cross_os());
}
#[test]
fn native_os_is_the_creating_machine_not_this_one() {
let other = other_host_os();
assert_eq!(profile_from("wayfern", Some(other)).native_os(), other);
assert_eq!(profile_from("wayfern", None).native_os(), get_host_os());
// A fingerprint OS stored by mistake is not a machine.
assert_eq!(
profile_from("wayfern", Some("android")).native_os(),
get_host_os()
);
}
}
+201 -19
View File
@@ -210,6 +210,76 @@ fn is_safe_manifest_path(path: &str) -> bool {
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}
/// Whether this machine syncs the profile's browser files, or only its
/// metadata. Every Wayfern profile syncs its files, whichever OS created it;
/// only a profile of a browser bound to another OS keeps to its metadata.
fn syncs_browser_files(profile: &BrowserProfile) -> bool {
!profile.is_cross_os()
}
/// True when `dir` holds no file at any depth, or does not exist.
fn holds_no_files(dir: &Path) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return true;
};
entries.flatten().all(|entry| {
entry
.file_type()
.is_ok_and(|kind| kind.is_dir() && holds_no_files(&entry.path()))
})
}
/// Whether a synced profile created on another OS is on this machine as
/// metadata alone, with no browser files yet. Earlier versions synced a
/// profile from another OS that way.
pub fn awaits_first_download(profile: &BrowserProfile, profiles_dir: &Path) -> bool {
profile.is_sync_enabled()
&& syncs_browser_files(profile)
&& profile
.resolved_os()
.is_some_and(|os| os != crate::profile::types::get_host_os())
&& holds_no_files(&profile.get_profile_data_path(profiles_dir))
}
/// Download the browser files of a profile that `awaits_first_download`
/// before it launches. Launched empty, the browser would write a fresh
/// profile, and the sync after it closes would publish that as the newer copy
/// and delete the real files everywhere, so a download that does not complete
/// refuses the launch.
pub async fn download_before_first_launch(
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<(), String> {
let profiles_dir = ProfileManager::instance().get_profiles_dir();
if !awaits_first_download(profile, &profiles_dir) {
return Ok(());
}
let refused = |detail: String| {
log::warn!(
"Launch refused, profile data not downloaded profile={} err=\"{detail}\"",
profile.id
);
crate::backend_error_with_detail("PROFILE_DATA_NOT_DOWNLOADED", detail)
};
let engine = SyncEngine::create_from_settings(app_handle)
.await
.map_err(|e| refused(e.to_string()))?;
match engine
.sync_profile_with_bias(app_handle, profile, DiffBias::Auto)
.await
{
Ok(ProfileSyncOutcome::Completed) => {
log::info!(
"Profile data downloaded before launch profile={}",
profile.id
);
Ok(())
}
Ok(ProfileSyncOutcome::Skipped(reason)) => Err(refused(reason.to_string())),
Err(e) => Err(refused(e.to_string())),
}
}
/// Parse an S3 `lastModified` (RFC3339) into unix seconds.
fn rfc3339_secs(value: &str) -> Option<u64> {
DateTime::parse_from_rfc3339(value)
@@ -678,7 +748,7 @@ impl SyncEngine {
.ok_or_else(|| SyncError::InvalidData(format!("Profile {profile_id} not found")))?;
let profile = &current_profile;
if profile.is_cross_os() {
if !syncs_browser_files(profile) {
log::debug!(
"Profile sync: cross-OS, metadata only profile={}",
profile.id
@@ -1176,8 +1246,8 @@ impl SyncEngine {
Ok(remote)
}
/// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups).
/// No browser files are synced.
/// Sync only metadata for profiles bound to another OS (tags, notes,
/// proxies, groups). No browser files are synced.
async fn sync_cross_os_metadata(
&self,
app_handle: &tauri::AppHandle,
@@ -2737,8 +2807,9 @@ impl SyncEngine {
let mut profile: BrowserProfile = serde_json::from_slice(&metadata_data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?;
// Cross-OS profile: save metadata only, skip manifest + file downloads
if profile.is_cross_os() {
// A profile bound to another OS: save metadata only, skip manifest + file
// downloads. A Wayfern profile from any OS downloads in full below.
if !syncs_browser_files(&profile) {
fs::create_dir_all(&profile_dir).map_err(|e| {
SyncError::IoError(format!(
"Failed to create profile directory {}: {e}",
@@ -2837,18 +2908,8 @@ impl SyncEngine {
// Verify critical files after download
let os_crypt_key_path = profile_dir.join("profile").join("os_crypt_key");
let cookies_path = {
let network = profile_dir
.join("profile")
.join("Default")
.join("Network")
.join("Cookies");
if network.exists() {
network
} else {
profile_dir.join("profile").join("Default").join("Cookies")
}
};
let cookies_path =
crate::cookie_manager::CookieManager::wayfern_cookie_path(&profile_dir.join("profile"));
let has_os_crypt_key = os_crypt_key_path.exists();
let cookies_bytes = fs::metadata(&cookies_path).ok().map(|meta| meta.len());
if !has_os_crypt_key || cookies_bytes.is_none() {
@@ -3081,7 +3142,7 @@ impl SyncEngine {
.list_profiles()
.unwrap_or_default()
.iter()
.filter(|p| p.is_cross_os() && p.is_sync_enabled())
.filter(|p| !syncs_browser_files(p) && p.is_sync_enabled())
.map(|p| (p.id.to_string(), p.sync_mode, p.created_by_id.clone()))
.collect();
@@ -3530,7 +3591,7 @@ async fn apply_profile_sync_mode(
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
if profile.is_cross_os() {
if !syncs_browser_files(&profile) {
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
}
@@ -4742,6 +4803,127 @@ mod tests {
assert!(tombstone_outranks_local(true, None, 501));
}
fn other_host_os() -> &'static str {
if crate::profile::types::get_host_os() == "windows" {
"macos"
} else {
"windows"
}
}
fn synced_profile(browser: &str, host_os: &str) -> BrowserProfile {
BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: browser.to_string(),
host_os: Some(host_os.to_string()),
sync_mode: SyncMode::Regular,
..BrowserProfile::default()
}
}
#[test]
fn a_wayfern_profile_from_another_os_syncs_its_browser_files() {
for os in ["macos", "windows", "linux"] {
assert!(
syncs_browser_files(&synced_profile("wayfern", os)),
"a Wayfern profile created on {os} must sync its data here"
);
}
}
#[test]
fn a_profile_bound_to_another_os_syncs_metadata_only() {
assert!(!syncs_browser_files(&synced_profile(
"camoufox",
other_host_os()
)));
assert!(syncs_browser_files(&synced_profile(
"camoufox",
&crate::profile::types::get_host_os()
)));
}
#[test]
fn a_profile_from_another_os_transfers_its_whole_directory() {
// A profile directory as Chromium on Windows leaves it: a raw key file,
// migrated network data, and the per-machine files sync never carries.
let source = tempfile::TempDir::new().unwrap();
let uuid_dir = source.path();
let data_dir = uuid_dir.join("profile");
let network_dir = data_dir.join("Default").join("Network");
fs::create_dir_all(&network_dir).unwrap();
fs::create_dir_all(data_dir.join("Default").join("Cache")).unwrap();
fs::write(data_dir.join("os_crypt_key"), [0x42u8; 32]).unwrap();
fs::write(data_dir.join("Local State"), b"{}").unwrap();
fs::write(data_dir.join("Default").join("Preferences"), b"{}").unwrap();
fs::write(network_dir.join("Cookies"), b"cookies").unwrap();
fs::write(network_dir.join("NetworkDataMigrated"), b"").unwrap();
fs::write(data_dir.join("SingletonLock"), b"host-1234").unwrap();
fs::write(data_dir.join("Default").join("LOCK"), b"").unwrap();
fs::write(data_dir.join("Default").join("Cache").join("data_0"), b"").unwrap();
fs::write(uuid_dir.join("metadata.json"), b"{}").unwrap();
let mut cache = HashCache::default();
let remote = generate_manifest("p", uuid_dir, &mut cache).unwrap();
let mut uploaded: Vec<&str> = remote.files.iter().map(|f| f.path.as_str()).collect();
uploaded.sort_unstable();
assert_eq!(
uploaded,
vec![
"profile/Default/Network/Cookies",
"profile/Default/Network/NetworkDataMigrated",
"profile/Default/Preferences",
"profile/Local State",
"profile/os_crypt_key",
],
"every data file syncs and only the per-machine files stay behind"
);
// The machine on the other OS holds metadata alone; it pulls everything.
let target = tempfile::TempDir::new().unwrap();
fs::write(target.path().join("metadata.json"), b"{}").unwrap();
let mut target_cache = HashCache::default();
let local = generate_manifest("p", target.path(), &mut target_cache).unwrap();
let diff = compute_diff_with_bias(&local, Some(&remote), DiffBias::Auto);
assert_eq!(diff.files_to_download.len(), remote.files.len());
assert!(diff.files_to_upload.is_empty());
assert!(diff.files_to_delete_remote.is_empty());
}
#[test]
fn a_metadata_only_copy_from_another_os_awaits_its_first_download() {
let profiles_dir = tempfile::TempDir::new().unwrap();
let profile = synced_profile("wayfern", other_host_os());
let uuid_dir = profiles_dir.path().join(profile.id.to_string());
fs::create_dir_all(&uuid_dir).unwrap();
fs::write(uuid_dir.join("metadata.json"), b"{}").unwrap();
assert!(awaits_first_download(&profile, profiles_dir.path()));
// An empty directory tree is still nothing to launch on.
let data_dir = profile.get_profile_data_path(profiles_dir.path());
fs::create_dir_all(data_dir.join("Default")).unwrap();
assert!(awaits_first_download(&profile, profiles_dir.path()));
// Once any file is there, the launch goes ahead as for any profile.
fs::write(data_dir.join("Local State"), b"{}").unwrap();
assert!(!awaits_first_download(&profile, profiles_dir.path()));
}
#[test]
fn only_a_synced_profile_from_another_os_waits_for_a_download() {
let profiles_dir = tempfile::TempDir::new().unwrap();
// A new profile created here launches on an empty directory.
let local = synced_profile("wayfern", &crate::profile::types::get_host_os());
assert!(!awaits_first_download(&local, profiles_dir.path()));
// Without sync there is nothing to download.
let mut unsynced = synced_profile("wayfern", other_host_os());
unsynced.sync_mode = SyncMode::Disabled;
assert!(!awaits_first_download(&unsynced, profiles_dir.path()));
// A profile bound to another OS never launches here at all.
let bound = synced_profile("camoufox", other_host_os());
assert!(!awaits_first_download(&bound, profiles_dir.path()));
}
#[test]
fn test_rfc3339_secs() {
assert_eq!(rfc3339_secs("1970-01-01T00:00:00Z"), Some(0));
+4 -3
View File
@@ -13,9 +13,10 @@ pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{
cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
awaits_first_download, cancel_profile_sync, download_before_first_launch,
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
pull_profile_after_remote_session, request_profile_sync, resume_profile_sync,
+2 -2
View File
@@ -99,7 +99,6 @@ pub fn stored_shape(config: &WayfernConfig) -> StoredShape {
pub fn needs_conversion(profile: &BrowserProfile) -> bool {
profile.browser == "wayfern"
&& supports_wayfern_152(&profile.version)
&& !profile.is_cross_os()
&& profile.wayfern_config.as_ref().is_some_and(|config| {
config.randomize_fingerprint_on_launch != Some(true)
&& matches!(
@@ -698,6 +697,7 @@ mod tests {
camoufox.browser = "camoufox".to_string();
assert!(!needs_conversion(&camoufox));
// A profile created on another OS launches here, so it converts here too.
let mut foreign = profile_with("152.0.7977.64", payload_only);
foreign.host_os = Some(
if get_host_os() == "windows" {
@@ -707,7 +707,7 @@ mod tests {
}
.to_string(),
);
assert!(!needs_conversion(&foreign));
assert!(needs_conversion(&foreign));
}
#[test]
+49 -33
View File
@@ -1718,7 +1718,13 @@ impl WayfernManager {
/// operating system, or no timezone. The browser requires the timezone
/// because it does not resolve the exit itself; donut holds the proxy and
/// resolved it when the location was written.
pub fn launch_identity_document(config: &WayfernConfig) -> Option<serde_json::Value> {
///
/// `native_os` is `BrowserProfile::native_os`: the OS a profile without an
/// explicit claim was generated for, which stays its claim on every machine.
pub fn launch_identity_document(
config: &WayfernConfig,
native_os: &str,
) -> Option<serde_json::Value> {
if config.fingerprint.is_some() {
return None;
}
@@ -1727,11 +1733,10 @@ impl WayfernManager {
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())?;
// The browser requires the operating system in the document. Over CDP an
// omitted `operatingSystem` means the host, so the document says so
// explicitly, and the profile's own claim wins when it has one.
let host_os = crate::profile::types::get_host_os();
let os = Self::claimed_operating_system(config, None).unwrap_or(host_os.as_str());
// The browser requires the operating system in the document. A profile
// with no explicit claim was generated for the machine that created it,
// so the document names that OS rather than the machine launching it.
let os = Self::claimed_operating_system(config, None).unwrap_or(native_os);
let location = Self::stored_object(config.location.as_deref());
let geo = Self::geo_params(&location);
let timezone = geo
@@ -2390,8 +2395,10 @@ impl WayfernManager {
.await?;
let ws_url = session.page_ws_url().to_string();
let host_os = crate::profile::types::get_host_os();
let os = config.os.as_deref().unwrap_or(&host_os);
// A profile without an explicit claim keeps the OS of the machine that
// created it, wherever it is regenerated.
let native_os = profile.native_os();
let os = config.os.as_deref().unwrap_or(&native_os);
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
let mut generate_params = json!({ "operatingSystem": os });
@@ -2606,17 +2613,8 @@ impl WayfernManager {
{
let profile_path_buf = std::path::PathBuf::from(profile_path);
let key_path = profile_path_buf.join("os_crypt_key");
let cookies_path = {
let network = profile_path_buf
.join("Default")
.join("Network")
.join("Cookies");
if network.exists() {
network
} else {
profile_path_buf.join("Default").join("Cookies")
}
};
let cookies_path =
crate::cookie_manager::CookieManager::wayfern_cookie_path(&profile_path_buf);
// Length only. The contents are the profile's encryption key, and this
// log is the first thing a user attaches to a bug report.
@@ -2880,7 +2878,7 @@ impl WayfernManager {
// device payloads and a profile whose location carries no timezone keep
// the post-launch CDP apply below.
let launch_identity = if supports_wayfern_152(&profile.version) {
Self::launch_identity_document(config)
Self::launch_identity_document(config, &profile.native_os())
} else {
None
};
@@ -4405,7 +4403,7 @@ mod tests {
#[test]
fn the_launch_identity_document_carries_exactly_what_the_browser_reads() {
let document =
WayfernManager::launch_identity_document(&identity_config(Some("Europe/Berlin")))
WayfernManager::launch_identity_document(&identity_config(Some("Europe/Berlin")), "macos")
.expect("a complete identity profile yields a document");
assert_eq!(
document,
@@ -4425,22 +4423,40 @@ mod tests {
fn the_launch_identity_document_needs_a_timezone_and_an_identity() {
// The browser refuses a document without a timezone, and does not resolve
// one itself, so no document is written at all.
assert!(WayfernManager::launch_identity_document(&identity_config(None)).is_none());
// No claim means the host, which is what an omitted `operatingSystem`
// means over CDP as well.
let mut no_os = identity_config(Some("Europe/Berlin"));
no_os.os = None;
assert_eq!(
WayfernManager::launch_identity_document(&no_os).unwrap()["operatingSystem"],
json!(crate::profile::types::get_host_os())
);
assert!(WayfernManager::launch_identity_document(&identity_config(None), "windows").is_none());
let mut no_identity = identity_config(Some("Europe/Berlin"));
no_identity.identity_id = Some(" ".into());
assert!(WayfernManager::launch_identity_document(&no_identity).is_none());
assert!(WayfernManager::launch_identity_document(&no_identity, "windows").is_none());
// A legacy device payload is only reproducible through setFingerprint.
let mut legacy = identity_config(Some("Europe/Berlin"));
legacy.fingerprint = Some("{}".into());
assert!(WayfernManager::launch_identity_document(&legacy).is_none());
assert!(WayfernManager::launch_identity_document(&legacy, "windows").is_none());
}
#[test]
fn an_unclaimed_identity_keeps_the_os_of_the_machine_that_created_it() {
// No explicit claim means the fingerprint was generated for the creating
// machine, so a profile synced to another OS still names that OS.
let mut no_os = identity_config(Some("Europe/Berlin"));
no_os.os = None;
for created_on in ["macos", "windows", "linux"] {
let profile = BrowserProfile {
browser: "wayfern".into(),
host_os: Some(created_on.into()),
..Default::default()
};
assert_eq!(
WayfernManager::launch_identity_document(&no_os, &profile.native_os()).unwrap()
["operatingSystem"],
json!(created_on)
);
}
// An explicit claim wins over the creating machine.
assert_eq!(
WayfernManager::launch_identity_document(&identity_config(Some("Europe/Berlin")), "linux")
.unwrap()["operatingSystem"],
json!("windows")
);
}
#[test]
@@ -4448,7 +4464,7 @@ mod tests {
let mut config = identity_config(Some("Asia/Tokyo"));
config.location = Some(r#"{"timezone":"Asia/Tokyo","latitude":35.6}"#.into());
config.identity_overrides = None;
let document = WayfernManager::launch_identity_document(&config).unwrap();
let document = WayfernManager::launch_identity_document(&config, "macos").unwrap();
assert_eq!(
document,
json!({