mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 14:37:30 +02:00
fix: key files, linux routing
This commit is contained in:
1 parent
3d8379453e
commit
bbb8372d34
15 files changed
+342
-81
No files matched your search
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ©).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, ©, &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, ©).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, ©).unwrap(), 0);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
})?
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(¤t) {
|
||||
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(),
|
||||
¤t,
|
||||
@@ -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, ¤t, false, false).await;
|
||||
let mut result = convert_and_save(&session, ¤t, 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(¤t.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;
|
||||
|
||||
Reference in new issue
Block a user