fix: key files, linux routing

This commit is contained in:
zhom committed 2026-10-17 14:46:39 +04:00
1 parent 3d8379453e
commit bbb8372d34
15 files changed
+342 -81

No files matched your search

+10 -8
View File
@@ -486,7 +486,8 @@ pub struct RunRemoteResponse {
pub profile_id: String,
/// Remote session id, for polling or closing the session.
pub session_id: String,
/// Operating system the session was scheduled onto — always the profile's own.
/// Operating system of the host the session was scheduled onto: `linux` for
/// a profile that runs on every OS, else the OS that created the profile.
pub platform: String,
pub status: String,
}
@@ -518,8 +519,9 @@ struct ApiRemoteSessionsResponse {
struct SetCookieBotScheduleRequest {
/// Defaults to the profile's local name.
profile_name: Option<String>,
/// `windows`, `macos` or `linux`. Defaults to the profile's own operating
/// system, and must match it when supplied.
/// `windows`, `macos` or `linux`: the OS of the host the bot runs the
/// profile on, `linux` for a profile that runs on every OS, else the OS that
/// created the profile. Defaults to that OS, and must match it when supplied.
platform: Option<String>,
/// Whether the nightly run is armed. A disabled schedule keeps its settings.
enabled: bool,
@@ -3626,7 +3628,7 @@ async fn run_profile(
}))
}
// API Handler - Launch this profile on a REMOTE VM of its own operating system
// API Handler - Launch this profile on a REMOTE VM of the OS `remote_platform` picks
#[utoipa::path(
post,
path = "/v1/profiles/{id}/run-remote",
@@ -3840,7 +3842,7 @@ pub fn remote_launch_profile_rules(
.to_string(),
);
}
if profile.resolved_os().is_none() {
if profile.remote_platform().is_none() {
return Err(
"profile has no recorded operating system, so it cannot be scheduled \
onto a matching host"
@@ -4377,7 +4379,7 @@ async fn set_cookie_bot_schedule(
// than the request is what stops a caller enrolling a macOS profile onto a
// Windows host.
let platform = profile
.resolved_os()
.remote_platform()
.ok_or((
StatusCode::BAD_REQUEST,
serde_json::json!({ "code": "COOKIE_BOT_UNKNOWN_PLATFORM" }).to_string(),
@@ -5758,8 +5760,8 @@ mod tests {
#[test]
fn remote_launch_requires_a_known_operating_system() {
// Without one there is no way to pick a matching host, and guessing would
// be the cross-OS mismatch this whole design exists to prevent.
// Without one a Linux host has no OS to present the profile as, and a
// guess would change the OS the profile reports to sites.
assert!(remote_launch_profile_rules(&profile_with(SyncMode::Regular, None)).is_err());
}
+12 -1
View File
@@ -346,10 +346,12 @@ impl BrowserRunner {
let launch_started = std::time::Instant::now();
let _launch_in_progress = crate::wayfern_identity_storage::yield_to_launch().await;
let converted;
let mut launch_only = false;
let profile = if crate::wayfern_identity_storage::needs_conversion(profile) {
converted = crate::wayfern_identity_storage::convert_for_launch(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
launch_only = crate::wayfern_identity_storage::is_launch_only(&converted);
&converted
} else {
profile
@@ -855,7 +857,16 @@ impl BrowserRunner {
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
// Save the updated profile
self.save_process_info(&updated_profile)?;
if launch_only {
let mut persisted = updated_profile.clone();
if let Some(disk) = crate::wayfern_identity_storage::read_profile(&updated_profile.id) {
persisted.wayfern_config = disk.wayfern_config;
persisted.host_os = disk.host_os;
}
self.save_process_info(&persisted)?;
} else {
self.save_process_info(&updated_profile)?;
}
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.profile_manager.list_profiles().unwrap_or_default());
});
+7 -4
View File
@@ -632,7 +632,7 @@ pub fn bot_precondition(
// ciphertext and push the corruption back over the real profile.
return Err(error("COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED", &[]));
}
let Some(platform) = profile.resolved_os() else {
let Some(platform) = profile.remote_platform() else {
return Err(error("COOKIE_BOT_UNKNOWN_PLATFORM", &[]));
};
if !BOT_PLATFORMS.contains(&platform) {
@@ -1047,7 +1047,7 @@ pub fn profile_identity(profile: &BrowserProfile) -> ProfileIdentity {
.map(|zone| zone.trim().to_string())
.filter(|zone| !zone.is_empty()),
platform: profile
.resolved_os()
.remote_platform()
.filter(|os| BOT_PLATFORMS.contains(os))
.map(str::to_string),
profile_name: (!name.is_empty() && name.encode_utf16().count() <= MAX_PROFILE_NAME_UNITS)
@@ -1567,19 +1567,20 @@ mod tests {
#[test]
fn a_report_names_the_zone_the_platform_and_the_profile() {
// A Wayfern profile created on macOS runs on a Linux host.
let identity = profile_identity(&profile_in_zone("Europe/Berlin"));
assert_eq!(
identity,
ProfileIdentity {
timezone: Some("Europe/Berlin".to_string()),
platform: Some("macos".to_string()),
platform: Some("linux".to_string()),
profile_name: Some("warm me".to_string()),
}
);
let body = profile_state_body(profile_state(&eligible_profile()), &identity);
assert_eq!(body["timezone"], "Europe/Berlin");
assert_eq!(body["platform"], "macos");
assert_eq!(body["platform"], "linux");
assert_eq!(body["profile_name"], "warm me");
assert_eq!(body["has_proxy"], true);
assert_eq!(body["sync_enabled"], true);
@@ -1789,7 +1790,9 @@ mod tests {
#[test]
fn an_os_the_fleet_cannot_lease_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
// A profile bound to its OS runs on a host of the OS that created it.
let mut profile = eligible_profile();
profile.browser = "camoufox".to_string();
profile.host_os = Some("android".to_string());
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("android has no host to lease");
+40 -9
View File
@@ -359,15 +359,14 @@ pub fn recover_ephemeral_dirs() {
if entry.path().is_dir() {
if let Some(name) = entry.file_name().to_str() {
if uuid::Uuid::parse_str(name).is_ok() {
dirs.insert(
name.to_string(),
EphemeralEntry {
path: entry.path(),
// Judge a recovered directory by the base it was found under, not
// by what some earlier run happened to resolve.
backing,
},
);
// A disk-backed copy kept by the sweep above is the one with
// unsealed changes, so it is not displaced.
dirs.entry(name.to_string()).or_insert(EphemeralEntry {
path: entry.path(),
// Judge a recovered directory by the base it was found under, not
// by what some earlier run happened to resolve.
backing,
});
log::info!("Ephemeral dir recovered profile={name}");
}
}
@@ -384,6 +383,38 @@ fn sweep_disk_fallback_residue(current_base: &Path) {
return;
}
// A decrypted copy that still has its baseline holds changes its sealed
// profile does not have yet. It stays where it is and is mapped, so the next
// launch of that profile seals it, and nothing here is erased until then.
let mut unsealed = 0usize;
if let (Ok(entries), Ok(mut dirs)) = (std::fs::read_dir(&fallback), EPHEMERAL_DIRS.lock()) {
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if uuid::Uuid::parse_str(&name).is_err()
|| !path
.join(crate::profile::encryption::BASELINE_FILE_NAME)
.is_file()
{
continue;
}
dirs.entry(name).or_insert(EphemeralEntry {
path,
backing: EphemeralBacking::Disk,
});
unsealed += 1;
}
}
if unsealed > 0 {
log::warn!(
"Disk-backed decrypted copies kept until sealed count={unsealed} path={}",
fallback.display()
);
return;
}
match crate::fs_secure::secure_remove_dir_all(&fallback, true) {
Ok(files) if files > 0 => log::info!(
"Erased disk-backed ephemeral residue files={files} path={}",
+7 -3
View File
@@ -32,7 +32,9 @@ const PROFILE_KEEP: &[&str] = &[
];
/// Entries kept at the user-data-dir top level (outside profile subdirs).
const TOP_KEEP: &[&str] = &["Local State", "First Run"];
/// `os_crypt_key` and `Local State` hold the key the kept extension data is
/// encrypted with; without them that data cannot be read.
const TOP_KEEP: &[&str] = &["Local State", "os_crypt_key", "First Run"];
fn is_kept(name: &str) -> bool {
PROFILE_KEEP.contains(&name) || TOP_KEEP.contains(&name)
@@ -89,8 +91,8 @@ fn clear_chromium_profile_dir(profile_dir: &Path) -> usize {
/// layouts: profile content at the root (imported profiles copy a Chromium
/// profile dir directly) and the standard `Default` / `Profile N` subdirs
/// Chromium creates itself. Top-level cache dirs (ShaderCache, GrShaderCache,
/// component_crx_cache, …) are removed; `Local State` stays because it holds
/// the os_crypt key extensions may rely on.
/// component_crx_cache, …) are removed; `Local State` and `os_crypt_key` stay
/// because they hold the key extensions' encrypted data needs.
pub fn clear_user_data_dir(user_data_dir: &Path) -> usize {
if !user_data_dir.exists() {
return 0;
@@ -227,6 +229,7 @@ mod tests {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
touch(dir, "Local State");
touch(dir, "os_crypt_key");
mkdir(dir, "ShaderCache");
mkdir(dir, "Default");
let default = dir.join("Default");
@@ -240,6 +243,7 @@ mod tests {
clear_user_data_dir(dir);
assert!(dir.join("Local State").exists());
assert!(dir.join("os_crypt_key").exists());
assert!(!dir.join("ShaderCache").exists());
assert!(default.join("Preferences").exists());
assert!(default.join("Bookmarks").exists());
+48 -1
View File
@@ -88,6 +88,18 @@ pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[
/// erased, so a directory without it is never mistaken for a profile.
pub const BASELINE_FILE_NAME: &str = ".donut-baseline.json";
/// The files that hold the key every encrypted cookie and password in the
/// profile is sealed with. A healthy profile never loses them or holds them
/// blank, so a decrypted copy that lacks one, or holds one empty or
/// zero-filled, was damaged from outside (a temp-folder cleaner, an erase that
/// zero-fills before it unlinks). Sealing that state would lose the key and,
/// with it, every encrypted value, so a seal keeps the sealed file instead.
const KEY_MATERIAL_PATHS: &[&str] = &["os_crypt_key", "Local State"];
fn is_blank(bytes: &[u8]) -> bool {
bytes.iter().all(|byte| *byte == 0)
}
/// A file's size and modification time, enough to tell that it changed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FileStamp {
@@ -416,15 +428,24 @@ pub fn reencrypt_changed_files(
}
let bytes = std::fs::read(&abs)?;
if KEY_MATERIAL_PATHS.contains(&relpath.as_str()) && is_blank(&bytes) {
// Left out of the baseline, so the next seal looks at it again.
sealed.remove(&relpath);
log::error!("Blank key file not sealed, the sealed one is kept path={relpath}");
continue;
}
let encrypted = encrypt_profile_file(key, &relpath, &bytes)?;
let on_disk = encrypted_dir.join(hmac_filename(key, &relpath));
atomic_write(&on_disk, &encrypted)?;
rewrote += 1;
}
// Delete on-disk files for plaintext paths that no longer exist
// Delete on-disk files for plaintext paths that no longer exist. A key file
// is never deleted this way: its absence from the copy is damage.
let valid_names: HashSet<String> = current_paths
.iter()
.map(String::as_str)
.chain(KEY_MATERIAL_PATHS.iter().copied())
.map(|p| hmac_filename(key, p))
.collect();
@@ -449,6 +470,32 @@ pub fn reencrypt_changed_files(
Ok((rewrote, sealed))
}
/// Put back, from the sealed copy, each key file the decrypted copy lacks or
/// holds blank. Returns how many were put back.
pub fn restore_key_material(
key: &[u8; 32],
encrypted_dir: &Path,
plaintext_dir: &Path,
) -> PasswordResult<usize> {
let mut restored = 0usize;
for relpath in KEY_MATERIAL_PATHS {
let dest = plaintext_dir.join(relpath);
if std::fs::read(&dest).is_ok_and(|bytes| !is_blank(&bytes)) {
continue;
}
let Ok(bytes) = std::fs::read(encrypted_dir.join(hmac_filename(key, relpath))) else {
continue;
};
let (path, content) = decrypt_profile_file(key, &bytes)?;
if path != *relpath || is_blank(&content) {
continue;
}
atomic_write(&dest, &content)?;
restored += 1;
}
Ok(restored)
}
/// Re-encrypt every file under `encrypted_dir` from `old_key` to `new_key` in
/// place. Used when changing a profile password without launching it.
/// Returns how many damaged files were dropped: they open with neither key.
+41
View File
@@ -356,3 +356,44 @@ fn the_sealed_exclusions_keep_what_a_database_needs() {
assert!(excludes.is_match(dropped), "{dropped} must not be sealed");
}
}
#[test]
fn a_seal_never_drops_or_blanks_the_key_files() {
const LOCAL_STATE: &[u8] = b"{\"os_crypt\":{}}";
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(plain.join("Default")).unwrap();
std::fs::write(plain.join("os_crypt_key"), [7u8; 32]).unwrap();
std::fs::write(plain.join("Local State"), LOCAL_STATE).unwrap();
std::fs::write(plain.join("Default/Cookies"), b"rows").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
// Damage from outside the browser: one key file zero-filled in place, the
// other deleted.
let copy = work.path().join("copy");
let baseline = decrypt_profile_dir(&key, &enc, &copy).unwrap().baseline;
std::fs::write(copy.join("os_crypt_key"), [0u8; 32]).unwrap();
std::fs::remove_file(copy.join("Local State")).unwrap();
reencrypt_changed_files(&key, &copy, &enc, &[], &baseline).unwrap();
let fresh = work.path().join("fresh");
decrypt_profile_dir(&key, &enc, &fresh).unwrap();
assert_eq!(
std::fs::read(fresh.join("os_crypt_key")).unwrap(),
[7u8; 32]
);
assert_eq!(
std::fs::read(fresh.join("Local State")).unwrap(),
LOCAL_STATE
);
assert_eq!(restore_key_material(&key, &enc, &copy).unwrap(), 2);
assert_eq!(std::fs::read(copy.join("os_crypt_key")).unwrap(), [7u8; 32]);
assert_eq!(
std::fs::read(copy.join("Local State")).unwrap(),
LOCAL_STATE
);
assert_eq!(restore_key_material(&key, &enc, &copy).unwrap(), 0);
}
+11 -2
View File
@@ -9,8 +9,8 @@
use crate::events;
use crate::profile::encryption::{
cache_key, decrypt_profile_dir, drop_cached_key, encrypt_profile_dir, fresh_salt, get_cached_key,
has_cached_key, has_changes, reencrypt_changed_files, rekey_profile_dir, sealed_stamp,
unlock as unlock_dir, verify_key_against_dir, Baseline, FileStamp, PasswordError,
has_cached_key, has_changes, reencrypt_changed_files, rekey_profile_dir, restore_key_material,
sealed_stamp, unlock as unlock_dir, verify_key_against_dir, Baseline, FileStamp, PasswordError,
BASELINE_FILE_NAME, SEALED_EXCLUDE_PATTERNS,
};
use crate::profile::ProfileManager;
@@ -838,6 +838,15 @@ pub fn prepare_for_launch(profile: &crate::profile::BrowserProfile) -> Result<Pa
log::error!("Profile re-encryption before launch failed profile={id} err=\"{e}\"")
}
}
// A key file the copy lost while it waited would otherwise start the
// browser without the profile's key.
match restore_key_material(&key, &store, &dir) {
Ok(0) => {}
Ok(restored) => {
log::warn!("Key files put back into the decrypted copy profile={id} count={restored}")
}
Err(e) => log::error!("Key file restore failed profile={id} err=\"{e}\""),
}
return Ok(dir);
}
}
+15
View File
@@ -352,6 +352,21 @@ pub fn imported_profile(
object.insert("password_protected".to_string(), serde_json::json!(false));
object.insert("temporary".to_string(), serde_json::json!(false));
object.insert("sync_mode".to_string(), serde_json::json!("Disabled"));
let source_os = object
.get("host_os")
.and_then(|v| v.as_str())
.filter(|os| crate::profile::types::is_host_os(os))
.map(str::to_string);
if let (Some(source_os), Some(config)) = (
source_os,
object
.get_mut("wayfern_config")
.and_then(|v| v.as_object_mut()),
) {
if config.get("os").is_none_or(|os| os.is_null()) {
config.insert("os".to_string(), serde_json::json!(source_os));
}
}
object.insert(
"host_os".to_string(),
serde_json::json!(crate::profile::types::get_host_os()),
+50
View File
@@ -153,6 +153,24 @@ impl BrowserProfile {
self.browser == "wayfern"
}
/// The host OS a remote session or a cookie-bot run is scheduled on, or
/// `None` when the profile cannot be scheduled.
///
/// A profile that runs on every OS goes to a Linux host, which presents the
/// fingerprint's `os`, else the OS of the machine that created the profile.
/// Unlike `native_os` there is no fallback to this machine, which is not the
/// host, and a `host_os` that is not a real machine counts as unknown: with
/// neither OS the profile has none to present and no host. Any other profile
/// goes to the OS that created it.
pub fn remote_platform(&self) -> Option<&str> {
if self.runs_on_any_os() {
let fingerprint_os = self.wayfern_config.as_ref().and_then(|c| c.os.as_deref());
let creating_os = self.host_os.as_deref().filter(|os| is_host_os(os));
return fingerprint_os.or(creating_os).map(|_| "linux");
}
self.resolved_os()
}
/// 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.
@@ -258,6 +276,38 @@ mod tests {
assert!(!profile_from("camoufox", None).is_cross_os());
}
#[test]
fn a_profile_that_runs_on_any_os_is_scheduled_on_linux_only_with_an_os_to_present() {
for os in ["macos", "windows", "linux"] {
assert_eq!(
profile_from("wayfern", Some(os)).remote_platform(),
Some("linux"),
"a Wayfern profile created on {os} runs on a Linux host"
);
}
let mut mobile = profile_from("wayfern", None);
mobile.wayfern_config = Some(WayfernConfig {
os: Some("android".to_string()),
..WayfernConfig::default()
});
assert_eq!(mobile.remote_platform(), Some("linux"));
// Nothing says which OS the profile is, so no host can run it.
assert_eq!(profile_from("wayfern", None).remote_platform(), None);
// A fingerprint OS stored as the creating machine is not a machine.
assert_eq!(
profile_from("wayfern", Some("android")).remote_platform(),
None
);
// A profile bound to its OS still goes to the OS that created it.
assert_eq!(
profile_from("camoufox", Some("macos")).remote_platform(),
Some("macos")
);
assert_eq!(profile_from("camoufox", None).remote_platform(), None);
}
#[test]
fn native_os_is_the_creating_machine_not_this_one() {
let other = other_host_os();
+19
View File
@@ -236,6 +236,25 @@ fn windows_has_app_bound_key(dir: &Path) -> bool {
.is_some_and(|k| !k.is_empty())
}
/// Writes `os_crypt_key` from the DPAPI-wrapped key in `Local State` when the
/// profile has no key file yet, so its encrypted records open on every OS.
/// Returns true when it wrote the file.
#[cfg(target_os = "windows")]
pub fn write_key_file_from_local_state(user_data_dir: &Path) -> Result<bool, String> {
let key_file = user_data_dir.join(super::os_crypt::KEY_FILE_NAME);
if std::fs::metadata(&key_file).is_ok_and(|meta| meta.len() > 0) {
return Ok(false);
}
let Some(key) = windows_local_state_key(user_data_dir)? else {
return Ok(false);
};
std::fs::write(&key_file, key).map_err(|e| format!("Failed to write os_crypt_key: {e}"))?;
if std::fs::read(&key_file).map_err(|e| e.to_string())? != key {
return Err("os_crypt_key verification failed after write".to_string());
}
Ok(true)
}
#[cfg(target_os = "windows")]
fn windows_local_state_key(dir: &Path) -> Result<Option<[u8; 32]>, String> {
use base64::Engine;
+7 -6
View File
@@ -76,7 +76,8 @@ pub struct RemoteSessionOutcome {
#[derive(Debug, Serialize)]
struct StartRemoteRequest {
profile_id: String,
/// The profile's own OS. The backend refuses to schedule it anywhere else.
/// The host OS from `BrowserProfile::remote_platform`. The backend refuses
/// to schedule the session anywhere else.
platform: String,
/// Set when the caller wants a page opened once the browser is up.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -164,10 +165,10 @@ fn control_client(total: std::time::Duration) -> reqwest::Client {
/// without a network or a sync scheduler, and so both the REST and the MCP
/// entry points get the identical answer from one place.
///
/// - The platform must be one a leased host serves. `resolved_os` can return a
/// fingerprint OS like `android` for a profile with no recorded host OS, and
/// no remote host runs that; refusing here gives a clear message instead of
/// leasing a host and taking a generic failure back.
/// - The platform must be one a leased host serves. `remote_platform` can
/// return a fingerprint OS like `android` for a profile bound to the OS that
/// created it, and no remote host runs that; refusing here gives a clear
/// message instead of leasing a host and taking a generic failure back.
/// - Sync must have completed at least once. A remote session opens the synced
/// copy and hands it back when it stops, so a profile with sync enabled but
/// `last_sync` still None (the first upload failed, or is only queued) would
@@ -252,7 +253,7 @@ pub async fn start_remote_session(
url: Option<String>,
) -> Result<RemoteSessionOutcome, RemoteSessionError> {
let platform = profile
.resolved_os()
.remote_platform()
.ok_or_else(|| {
RemoteSessionError::Other("profile has no recorded operating system".to_string())
})?
+9
View File
@@ -943,6 +943,15 @@ impl SyncEngine {
// before we generate the manifest (WAL files are excluded from sync)
checkpoint_sqlite_wal_files(&profile_dir);
#[cfg(target_os = "windows")]
match crate::profile_import::keyring::write_key_file_from_local_state(
&profile_dir.join("profile"),
) {
Ok(true) => log::info!("Key file written from Local State profile={profile_id}"),
Ok(false) => {}
Err(e) => log::warn!("Key file not written profile={profile_id} err=\"{e}\""),
}
// Load or create hash cache
let cache_path = get_cache_path(&profile_dir);
let mut hash_cache = HashCache::load(&cache_path);
+51 -47
View File
@@ -21,20 +21,12 @@ pub enum StoredShape {
#[derive(Debug)]
enum ConversionError {
Deferred(String),
LaunchOnly(Box<BrowserProfile>),
Changed,
Yielded,
Failed(String),
}
impl ConversionError {
fn coded(self) -> String {
match self {
Self::Deferred(coded) | Self::Failed(coded) => coded,
Self::Changed | Self::Yielded => crate::backend_error("WAYFERN_BROWSER_BUSY"),
}
}
}
const COMMAND: &str = "Wayfern.matchLegacyProfile";
const PASS_DELAY: Duration = Duration::from_secs(3);
const RETRY_DELAY: Duration = Duration::from_secs(600);
@@ -182,7 +174,7 @@ pub fn converted_profile(
Ok(updated)
}
fn read_profile(id: &uuid::Uuid) -> Option<BrowserProfile> {
pub(crate) fn read_profile(id: &uuid::Uuid) -> Option<BrowserProfile> {
let path = ProfileManager::instance()
.get_profiles_dir()
.join(id.to_string())
@@ -192,7 +184,9 @@ fn read_profile(id: &uuid::Uuid) -> Option<BrowserProfile> {
}
async fn is_running(profile: &BrowserProfile) -> bool {
if profile.process_id.is_some() {
if profile.process_id.is_some()
|| crate::remote_handoff::state_for(&profile.id.to_string()).is_some()
{
return true;
}
let profiles_dir = ProfileManager::instance().get_profiles_dir();
@@ -206,17 +200,25 @@ async fn is_running(profile: &BrowserProfile) -> bool {
fn write_backup(profile: &BrowserProfile) -> Result<(), String> {
let dir = crate::app_dirs::data_subdir().join("wayfern-config-backups");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.json", profile.id));
if path.exists() {
return Ok(());
}
let body = serde_json::to_vec_pretty(&json!({
"profileId": profile.id.to_string(),
"version": profile.version,
"wayfernConfig": profile.wayfern_config,
}))
.map_err(|e| e.to_string())?;
crate::app_dirs::write_owner_only(&path, &body).map_err(|e| e.to_string())
for index in 0u32.. {
let path = if index == 0 {
dir.join(format!("{}.json", profile.id))
} else {
dir.join(format!("{}-{index}.json", profile.id))
};
match std::fs::read(&path) {
Ok(existing) if existing == body => return Ok(()),
Ok(_) => continue,
Err(_) => return crate::app_dirs::write_owner_only(&path, &body).map_err(|e| e.to_string()),
}
}
Err("no free backup name".to_string())
}
fn config_value(profile: &BrowserProfile) -> Value {
@@ -272,24 +274,21 @@ async fn convert_and_save(
session: &HeadlessWayfern,
profile: &BrowserProfile,
interruptible: bool,
wait_for_withheld: bool,
token_resolved: bool,
) -> Result<BrowserProfile, ConversionError> {
let config = profile.wayfern_config.clone().unwrap_or_default();
let response = request(session, conversion_params(profile, &config), interruptible).await?;
if wait_for_withheld
&& response.get("withheld").and_then(Value::as_bool) == Some(true)
&& !CLOUD_AUTH.is_logged_in().await
{
return Err(ConversionError::Deferred(crate::backend_error(
"WAYFERN_PLAN_CHECK_UNAVAILABLE",
)));
}
let updated = converted_profile(profile, &response).map_err(|reason| {
ConversionError::Failed(crate::backend_error_with_detail(
"WAYFERN_FINGERPRINT_APPLY_FAILED",
reason,
))
})?;
if response.get("withheld").and_then(Value::as_bool) == Some(true)
&& !(token_resolved && CLOUD_AUTH.is_logged_in().await)
{
return Err(ConversionError::LaunchOnly(Box::new(updated)));
}
let Some(current) = read_profile(&profile.id) else {
return Err(ConversionError::Failed(crate::backend_error(
@@ -342,11 +341,9 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result<BrowserProfi
if !needs_conversion(&current) {
return Ok(current);
}
// A LAUNCH NEVER FAILS OVER PLAN STATE. A token that does not arrive means
// the caller is unentitled, signed out, or the verifier is briefly
// unreachable -- none of which is a reason the browser should not start. It
// proceeds without one and the browser drops what the plan does not carry.
let token = token_for_conversion().await.unwrap_or(None);
let token = token_for_conversion().await;
let token_resolved = token.is_ok();
let token = token.unwrap_or(None);
let session = HeadlessWayfern::start(
WayfernManager::instance(),
&current,
@@ -355,11 +352,11 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result<BrowserProfi
)
.await
.map_err(|e| wayfern_failure(&e.to_string(), "WAYFERN_FINGERPRINT_APPLY_FAILED", None))?;
let mut result = convert_and_save(&session, &current, false, false).await;
let mut result = convert_and_save(&session, &current, false, token_resolved).await;
if matches!(result, Err(ConversionError::Changed)) {
result = match read_profile(&profile.id) {
Some(latest) if needs_conversion(&latest) && latest.version == current.version => {
convert_and_save(&session, &latest, false, false).await
convert_and_save(&session, &latest, false, token_resolved).await
}
Some(latest) if !needs_conversion(&latest) => Ok(latest),
Some(_) => Err(ConversionError::Changed),
@@ -372,20 +369,13 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result<BrowserProfi
launch_outcome(result, current)
}
/// What a conversion result means to the person clicking launch.
///
/// CONVERSION IS NOT A PRECONDITION FOR LAUNCHING. A refusal, a transient
/// verifier failure and a browser that did not answer all mean the same thing
/// here: nothing yet. The profile still has its stored device, so it opens on
/// that and the background sweep converts it later, once whatever was briefly
/// true has stopped being true. Returning an error instead turns "your plan
/// does not include custom fingerprints" into "your browser does not start".
fn launch_outcome(
result: Result<BrowserProfile, ConversionError>,
current: BrowserProfile,
) -> Result<BrowserProfile, String> {
match result {
Ok(converted) => Ok(converted),
Err(ConversionError::LaunchOnly(launch_copy)) => Ok(*launch_copy),
Err(ConversionError::Failed(reason)) | Err(ConversionError::Deferred(reason)) => {
log::warn!(
"Identity conversion failed, launching on the stored device profile={} err=\"{reason}\"",
@@ -393,10 +383,18 @@ fn launch_outcome(
);
Ok(current)
}
Err(other) => Err(other.coded()),
Err(ConversionError::Changed) | Err(ConversionError::Yielded) => {
Ok(read_profile(&current.id).unwrap_or(current))
}
}
}
/// True when `launched` carries a config that the disk copy does not hold yet.
pub fn is_launch_only(launched: &BrowserProfile) -> bool {
!needs_conversion(launched)
&& read_profile(&launched.id).is_some_and(|disk| needs_conversion(&disk))
}
fn any_wayfern_running(profiles: &[BrowserProfile]) -> bool {
profiles
.iter()
@@ -496,7 +494,7 @@ async fn run_pass() -> bool {
deferred = true;
break;
}
Err(ConversionError::Changed) => deferred = true,
Err(ConversionError::Changed) | Err(ConversionError::LaunchOnly(_)) => deferred = true,
Err(ConversionError::Deferred(reason)) => {
log::debug!("Identity conversion deferred profile={id} reason=\"{reason}\"");
deferred = true;
@@ -700,7 +698,6 @@ 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" {
@@ -901,10 +898,6 @@ mod tests {
#[test]
fn a_launch_never_fails_because_the_conversion_could_not_finish() {
// The rule this pins: plan state, a transient verifier failure and a
// browser that did not answer must all still open the profile on its
// stored device. Returning an error here is a launch outage, and it has
// reached a user twice.
let stored = profile_with("152.0.7977.64", WayfernConfig::default());
for refusal in [
@@ -920,6 +913,17 @@ mod tests {
);
}
let launch_copy = profile_with("152.0.7977.64", WayfernConfig::default());
let outcome = launch_outcome(
Err(ConversionError::LaunchOnly(Box::new(launch_copy.clone()))),
stored.clone(),
);
assert_eq!(
outcome.expect("a withheld answer still launches").id,
launch_copy.id
);
assert!(launch_outcome(Err(ConversionError::Changed), stored.clone()).is_ok());
// A successful conversion is what the caller gets instead.
let converted = profile_with("152.0.7977.64", WayfernConfig::default());
let converted_id = converted.id;
@@ -69,6 +69,21 @@ async fn stored_profiles_convert_through_a_real_browser() {
};
let first_answer = answer(&before, token).await;
if token.is_none() && first_answer["withheld"] == json!(true) {
let launched = convert_for_launch(&before)
.await
.unwrap_or_else(|e| panic!("profile {} did not launch: {e}", before.id));
assert!(!needs_conversion(&launched));
let on_disk = read_profile(&before.id).expect("the stored profile is on disk");
assert_eq!(
config_value(&on_disk),
config_value(&before),
"profile {} keeps its stored config while the plan is unknown",
before.id
);
continue;
}
let converted = match token {
Some(token) => {
let session = session_for(&before, Some(token)).await;