feat: browser-side migration

This commit is contained in:
zhom committed 2026-10-17 22:22:35 +04:00
1 parent 295c0ae27b
commit 2346bbf2ba
8 files changed
+834 -10

No files matched your search

+6
View File
@@ -852,6 +852,12 @@ impl BrowserRunner {
launch_started.elapsed().as_millis()
);
// The browser returned a config to store, and it is on disk already.
if let Some(stored) = wayfern_result.stored_profile.as_ref() {
updated_profile.wayfern_config = stored.wayfern_config.clone();
updated_profile.host_os = stored.host_os.clone();
}
// Update profile with the process info
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
+215
View File
@@ -184,6 +184,138 @@ pub(crate) fn resolve_archive_i18n(
/// Read an unpacked extension's `manifest.json` off disk. The directory
/// equivalent of `read_manifest_from_archive`, so both payload shapes feed the
/// same metadata and icon extraction below.
/// The manifest `key` of a staged copy. Chromium derives the id of an unpacked
/// extension from its absolute path unless the manifest has a `key`, and the
/// staging path differs on every machine and OS, so a path id leaves the
/// extension's storage and settings on the machine that staged it. The key is
/// derived from the profile and the extension, so the id is the same on every
/// machine and still differs between profiles.
fn portable_manifest_key(profile_id: &str, ext_id: &str) -> Vec<u8> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(b"donut-staged-extension-key\0");
hasher.update(profile_id.as_bytes());
hasher.update([0u8]);
hasher.update(ext_id.as_bytes());
hasher.finalize().to_vec()
}
/// The extension id Chromium derives from a manifest `key`: the first 16 bytes
/// of its SHA-256, each nibble written as a letter from `a` to `p`.
fn extension_id_for_key(key: &[u8]) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(key)[..16]
.iter()
.flat_map(|byte| [byte >> 4, byte & 0x0f])
.map(|nibble| char::from(b'a' + nibble))
.collect()
}
/// Whether two spellings name the same directory, as the browser records it.
fn same_dir(recorded: &str, dir: &Path) -> bool {
let recorded = Path::new(recorded);
let canonical = dir.canonicalize().ok();
[Some(dir.to_path_buf()), canonical]
.into_iter()
.flatten()
.any(|candidate| {
let candidate = candidate.to_string_lossy().to_string();
let candidate = candidate.strip_prefix(r"\\?\").unwrap_or(&candidate);
if cfg!(windows) {
recorded.to_string_lossy().eq_ignore_ascii_case(candidate)
} else {
recorded == Path::new(candidate)
}
})
}
/// The id under which the profile already keeps state for the extension staged
/// at `dir`, when that id is not `portable_id`. Such state was recorded before
/// staged copies carried a key.
fn recorded_path_id(profile_data_path: &Path, dir: &Path, portable_id: &str) -> Option<String> {
let mut recorded_path_ids = Vec::new();
for file in ["Preferences", "Secure Preferences"] {
let Ok(contents) = fs::read_to_string(profile_data_path.join("Default").join(file)) else {
continue;
};
let Ok(prefs) = serde_json::from_str::<serde_json::Value>(&contents) else {
continue;
};
let Some(settings) = prefs
.pointer("/extensions/settings")
.and_then(|v| v.as_object())
else {
continue;
};
if settings.contains_key(portable_id) {
return None;
}
recorded_path_ids.extend(
settings
.iter()
.filter(|(_, entry)| {
entry
.get("path")
.and_then(|p| p.as_str())
.is_some_and(|p| same_dir(p, dir))
})
.map(|(id, _)| id.clone()),
);
}
recorded_path_ids.into_iter().next()
}
/// Give the staged copy at `dir` a manifest `key`, so the browser gives it the
/// same id on every machine. A manifest that already has a key keeps it. A
/// profile that already keeps state under the copy's path id keeps that id, so
/// an update never resets an extension on the machine it runs on.
fn give_portable_id(dir: &Path, profile_id: &str, ext_id: &str, profile_data_path: &Path) {
use base64::Engine;
let manifest_path = dir.join("manifest.json");
let Ok(contents) = fs::read_to_string(&manifest_path) else {
return;
};
let Ok(mut manifest) =
serde_json::from_str::<serde_json::Value>(contents.trim_start_matches('\u{feff}'))
else {
log::warn!("Extension keeps a path id, its manifest is not plain JSON extension={ext_id}");
return;
};
let Some(object) = manifest.as_object_mut() else {
return;
};
if object.contains_key("key") {
return;
}
let key = portable_manifest_key(profile_id, ext_id);
let portable_id = extension_id_for_key(&key);
if let Some(recorded) = recorded_path_id(profile_data_path, dir, &portable_id) {
log::info!(
"Extension keeps the id its data is under extension={ext_id} id={recorded} profile={profile_id}"
);
return;
}
object.insert(
"key".to_string(),
serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(key)),
);
match serde_json::to_vec_pretty(&manifest) {
Ok(bytes) => {
if let Err(e) = fs::write(&manifest_path, bytes) {
log::warn!(
"Extension keeps a path id, manifest write failed extension={ext_id} err=\"{e}\""
);
}
}
Err(e) => {
log::warn!(
"Extension keeps a path id, manifest encode failed extension={ext_id} err=\"{e}\""
);
}
}
}
pub(crate) fn read_manifest_from_dir(dir: &Path) -> Option<serde_json::Value> {
let contents = fs::read_to_string(dir.join("manifest.json")).ok()?;
serde_json::from_str(&contents).ok()
@@ -1582,6 +1714,12 @@ impl ExtensionManager {
// Extract .crx or .zip
match Self::unpack_extension(&src_file, &unpack_dir) {
Ok(()) => {
give_portable_id(
&unpack_dir,
&profile.id.to_string(),
ext_id,
_profile_data_path,
);
extension_paths.push(unpack_dir.to_string_lossy().to_string());
}
Err(e) => {
@@ -2011,6 +2149,83 @@ pub async fn get_extension_group_for_profile(
mod tests {
use super::*;
#[test]
fn extension_ids_follow_the_browser_derivation() {
assert_eq!(
extension_id_for_key(b"test"),
"jpignaibiiemhngfjkcpokkamffknabf"
);
assert_eq!(
extension_id_for_key(b"_"),
"ncocknphbhhlhkikpnnlmbcnbgdempcd"
);
}
fn staged_copy(root: &Path, name: &str, manifest: &str) -> PathBuf {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("manifest.json"), manifest).unwrap();
dir
}
fn manifest_key(dir: &Path) -> Option<String> {
read_manifest_from_dir(dir)?
.get("key")?
.as_str()
.map(str::to_string)
}
#[test]
fn a_staged_copy_gets_one_key_per_profile_on_every_machine() {
let machine_a = tempfile::tempdir().unwrap();
let machine_b = tempfile::tempdir().unwrap();
let manifest = r#"{"name":"x","version":"1","manifest_version":3}"#;
let on_a = staged_copy(machine_a.path(), "staged", manifest);
let on_b = staged_copy(machine_b.path(), "elsewhere/staged", manifest);
let other_profile = staged_copy(machine_a.path(), "other", manifest);
let data = tempfile::tempdir().unwrap();
give_portable_id(&on_a, "profile-1", "ext-1", data.path());
give_portable_id(&on_b, "profile-1", "ext-1", data.path());
give_portable_id(&other_profile, "profile-2", "ext-1", data.path());
let key = manifest_key(&on_a).expect("a key is written");
assert_eq!(Some(key.clone()), manifest_key(&on_b));
assert_ne!(Some(key), manifest_key(&other_profile));
}
#[test]
fn a_manifest_key_and_recorded_extension_state_are_kept() {
let root = tempfile::tempdir().unwrap();
let keyed = staged_copy(
root.path(),
"keyed",
r#"{"name":"x","version":"1","manifest_version":3,"key":"AAAA"}"#,
);
let data = tempfile::tempdir().unwrap();
give_portable_id(&keyed, "profile-1", "ext-1", data.path());
assert_eq!(manifest_key(&keyed).as_deref(), Some("AAAA"));
let used = staged_copy(
root.path(),
"used",
r#"{"name":"x","version":"1","manifest_version":3}"#,
);
fs::create_dir_all(data.path().join("Default")).unwrap();
let prefs = serde_json::json!({
"extensions": {"settings": {"abcdefghijklmnopabcdefghijklmnop": {
"path": used.to_string_lossy(),
}}}
});
fs::write(
data.path().join("Default").join("Preferences"),
prefs.to_string(),
)
.unwrap();
give_portable_id(&used, "profile-1", "ext-2", data.path());
assert_eq!(manifest_key(&used), None, "the recorded path id stays");
}
#[test]
fn test_get_file_type() {
assert_eq!(get_file_type("ext.crx"), Some("crx".to_string()));
+1
View File
@@ -79,6 +79,7 @@ pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[
"*.tmp",
"**/*.donut-tmp",
"**/wayfern-identity.json",
"**/wayfern-identity-result.json",
"**/wayfern-persona.json",
BASELINE_FILE_NAME,
];
@@ -350,6 +350,7 @@ fn the_sealed_exclusions_keep_what_a_database_needs() {
"Default/Local Storage/leveldb/LOCK",
"SingletonLock",
"wayfern-identity.json",
"wayfern-identity-result.json",
BASELINE_FILE_NAME,
"Default/x.donut-tmp",
] {
+8 -1
View File
@@ -153,8 +153,9 @@ pub fn is_excluded(relative: &str) -> bool {
"CacheStorage",
"ScriptCache",
];
const LAUNCH_FILES: [&str; 3] = [
const LAUNCH_FILES: [&str; 4] = [
"wayfern-identity.json",
"wayfern-identity-result.json",
"wayfern-persona.json",
"window-icon.png",
];
@@ -246,6 +247,12 @@ pub fn export_to(
.map_err(|e| err("profile", e))?;
if carries_data {
// A profile created before it had a key file holds its key only in a form
// this Windows account can open; the archive must carry the key file.
#[cfg(target_os = "windows")]
if let Err(e) = crate::profile_import::keyring::write_key_file_from_local_state(data_dir) {
log::warn!("Profile export carries no key file err=\"{e}\"");
}
let mut written = 0u64;
for (relative, absolute) in collect_files(data_dir)? {
let data = match fs::read(&absolute) {
+1
View File
@@ -57,6 +57,7 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
// Rewritten by donut before every launch from the profile metadata that
// already syncs; uploading it would only duplicate that state.
"**/wayfern-identity.json",
"**/wayfern-identity-result.json",
"**/wayfern-persona.json",
"**/SingletonSocket",
"**/SingletonCookie",
+387 -2
View File
@@ -2,10 +2,11 @@ use crate::cloud_auth::CLOUD_AUTH;
use crate::profile::types::get_host_os;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::wayfern_manager::{
cdp_error_is_invalid_params, is_temporary_wayfern_failure, supports_wayfern_152, wayfern_failure,
HeadlessWayfern, WayfernConfig, WayfernManager,
cdp_error_is_invalid_params, is_temporary_wayfern_failure, supports_launch_result,
supports_wayfern_152, wayfern_failure, HeadlessWayfern, WayfernConfig, WayfernManager,
};
use serde_json::{json, Map, Value};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::LazyLock;
use std::time::Duration;
@@ -91,6 +92,7 @@ pub fn stored_shape(config: &WayfernConfig) -> StoredShape {
pub fn needs_conversion(profile: &BrowserProfile) -> bool {
profile.browser == "wayfern"
&& supports_wayfern_152(&profile.version)
&& !supports_launch_result(&profile.version)
&& profile.wayfern_config.as_ref().is_some_and(|config| {
config.randomize_fingerprint_on_launch != Some(true)
&& matches!(
@@ -221,6 +223,170 @@ fn write_backup(profile: &BrowserProfile) -> Result<(), String> {
Err("no free backup name".to_string())
}
/// The config fields the launch document carries, exactly as stored.
fn stored_fields(config: &WayfernConfig) -> Map<String, Value> {
[
("fingerprint", &config.fingerprint),
("identity_id", &config.identity_id),
("identity_baseline", &config.identity_baseline),
("identity_overrides", &config.identity_overrides),
("os", &config.os),
("location", &config.location),
]
.into_iter()
.filter_map(|(key, value)| Some((key.to_string(), json!(value.as_ref()?))))
.collect()
}
/// The launch document keys that let the browser report the config to store.
pub fn launch_document_keys(
profile: &BrowserProfile,
config: &WayfernConfig,
result_file: &Path,
account_known: bool,
) -> Map<String, Value> {
let mut keys = Map::new();
keys.insert("profileSalt".to_string(), json!(profile.id.to_string()));
keys.insert(
"resultFile".to_string(),
json!(result_file.to_string_lossy()),
);
keys.insert("nativeOs".to_string(), json!(profile.native_os()));
keys.insert("accountKnown".to_string(), json!(account_known));
keys.insert("stored".to_string(), Value::Object(stored_fields(config)));
keys
}
/// What the browser reported about a launch.
#[derive(Debug, Clone, PartialEq)]
pub struct LaunchResult {
pub status: String,
/// The identity the browser launched on, when it launched on one.
pub identity_id: Option<String>,
pub operating_system: Option<String>,
config: Option<Map<String, Value>>,
}
/// This launch's result for `profile_id`, or `None` when there is no
/// readable result for that profile.
pub fn read_launch_result(path: &Path, profile_id: &uuid::Uuid) -> Option<LaunchResult> {
let content = std::fs::read_to_string(path).ok()?;
let result: Value = match serde_json::from_str(&content) {
Ok(result) => result,
Err(e) => {
log::warn!("Launch result unreadable profile={profile_id} err=\"{e}\"");
return None;
}
};
if result.get("profileSalt").and_then(Value::as_str) != Some(profile_id.to_string().as_str()) {
log::warn!("Launch result is for another profile profile={profile_id}");
return None;
}
let text = |key: &str| {
result
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
let status = text("status")?;
log::debug!("Launch result profile={profile_id} status={status}");
Some(LaunchResult {
identity_id: text("identityId"),
operating_system: text("operatingSystem"),
config: result.get("config").and_then(Value::as_object).cloned(),
status,
})
}
/// `profile` with the config the browser returned, or `None` when the result
/// carries no config to store.
fn profile_with_result(profile: &BrowserProfile, result: &LaunchResult) -> Option<BrowserProfile> {
if result.status != "converted" {
return None;
}
let returned = result.config.as_ref()?;
let text = |key: &str| {
returned
.get(key)
.and_then(Value::as_str)
.map(str::to_string)
};
let identity_id = text("identity_id").filter(|id| !id.trim().is_empty())?;
let os = text("os")?;
let os = WayfernManager::normalize_os_name(&os)?;
let mut updated = profile.clone();
let config = updated
.wayfern_config
.get_or_insert_with(WayfernConfig::default);
config.identity_id = Some(identity_id);
config.os = Some(os.to_string());
config.identity_overrides = text("identity_overrides");
if let Some(location) = text("location") {
config.location = Some(location);
}
config.fingerprint = None;
config.identity_baseline = None;
let host_os = get_host_os();
if updated.host_os.is_none() && os != host_os {
updated.host_os = Some(host_os);
}
Some(updated)
}
/// Store the config the browser returned, and return the stored profile.
/// Nothing is stored when the profile changed since the launch began.
pub fn store_launch_result(
profile: &BrowserProfile,
sent: &WayfernConfig,
result: &LaunchResult,
) -> Option<BrowserProfile> {
if result.status != "converted" {
return None;
}
let current = read_profile(&profile.id)?;
let held = current.wayfern_config.clone().unwrap_or_default();
if stored_fields(&held) != stored_fields(sent) {
log::debug!(
"Launch result not stored, profile changed profile={}",
profile.id
);
return None;
}
let stored = profile_with_result(&current, result)?;
if let Err(e) = write_backup(&current) {
log::warn!(
"Launch result not stored, backup failed profile={} err=\"{e}\"",
profile.id
);
return None;
}
match ProfileManager::instance().save_profile_unchanged(&current, &stored) {
Ok(true) => {
log::info!("Profile stored as an identity profile={}", stored.id);
let _ = crate::events::emit_empty("profiles-changed");
Some(stored)
}
Ok(false) => {
log::debug!(
"Launch result not stored, profile changed profile={}",
profile.id
);
None
}
Err(e) => {
log::warn!(
"Launch result not stored profile={} err=\"{e}\"",
profile.id
);
None
}
}
}
fn config_value(profile: &BrowserProfile) -> Value {
serde_json::to_value(&profile.wayfern_config).unwrap_or(Value::Null)
}
@@ -680,6 +846,10 @@ mod tests {
"150.0.7801.12",
payload_only.clone()
)));
assert!(!needs_conversion(&profile_with(
"155.0.8059.79",
payload_only.clone()
)));
let mut randomized = payload_only.clone();
randomized.randomize_fingerprint_on_launch = Some(true);
@@ -935,6 +1105,221 @@ mod tests {
);
}
#[test]
fn the_launch_document_carries_the_stored_config_verbatim() {
let config = WayfernConfig {
fingerprint: Some(format!(r#"{{"fingerprint":{}}}"#, payload())),
identity_id: Some(format!(" {LEGACY_ID} ")),
identity_overrides: Some("not json".to_string()),
persona: Some("p".to_string()),
proxy: Some("socks5://127.0.0.1:1".to_string()),
..Default::default()
};
let profile = profile_with("155.0.8059.79", config.clone());
let result_file = std::path::PathBuf::from("/tmp/p/wayfern-identity-result.json");
let keys = launch_document_keys(&profile, &config, &result_file, true);
assert_eq!(keys["profileSalt"], json!(profile.id.to_string()));
assert_eq!(
keys["resultFile"],
json!("/tmp/p/wayfern-identity-result.json")
);
assert_eq!(keys["nativeOs"], json!(profile.native_os()));
assert_eq!(keys["accountKnown"], json!(true));
let stored = keys["stored"].as_object().unwrap();
assert_eq!(stored["fingerprint"], json!(config.fingerprint.unwrap()));
assert_eq!(stored["identity_id"], json!(format!(" {LEGACY_ID} ")));
assert_eq!(stored["identity_overrides"], json!("not json"));
let mut names: Vec<&str> = stored.keys().map(String::as_str).collect();
names.sort_unstable();
assert_eq!(names, ["fingerprint", "identity_id", "identity_overrides"]);
let empty = launch_document_keys(&profile, &WayfernConfig::default(), &result_file, false);
assert_eq!(empty["stored"], json!({}));
assert_eq!(empty["accountKnown"], json!(false));
}
fn result_body(profile: &BrowserProfile, status: &str, config: Value) -> Value {
json!({
"version": 1,
"profileSalt": profile.id.to_string(),
"status": status,
"identityId": CURRENT_ID,
"operatingSystem": "windows",
"config": config,
})
}
fn write_result(dir: &Path, body: &Value) -> std::path::PathBuf {
let path = dir.join(crate::wayfern_manager::LAUNCH_RESULT_FILE);
std::fs::write(&path, body.to_string()).unwrap();
path
}
#[test]
fn only_a_result_for_this_profile_is_read() {
let dir = tempfile::tempdir().unwrap();
let profile = profile_with("155.0.8059.79", WayfernConfig::default());
let missing = dir.path().join("absent.json");
assert!(read_launch_result(&missing, &profile.id).is_none());
let path = write_result(dir.path(), &result_body(&profile, "unchanged", Value::Null));
let result = read_launch_result(&path, &profile.id).unwrap();
assert_eq!(result.status, "unchanged");
assert_eq!(result.identity_id.as_deref(), Some(CURRENT_ID));
assert_eq!(result.operating_system.as_deref(), Some("windows"));
let mut other = result_body(&profile, "converted", json!({}));
other["profileSalt"] = json!(LEGACY_ID);
let path = write_result(dir.path(), &other);
assert!(read_launch_result(&path, &profile.id).is_none());
std::fs::write(&path, "{").unwrap();
assert!(read_launch_result(&path, &profile.id).is_none());
let mut failed = result_body(&profile, "failed", Value::Null);
let object = failed.as_object_mut().unwrap();
object.remove("identityId");
object.remove("operatingSystem");
let path = write_result(dir.path(), &failed);
let result = read_launch_result(&path, &profile.id).unwrap();
assert_eq!(result.status, "failed");
assert!(result.identity_id.is_none());
}
#[test]
fn a_converted_result_is_stored_as_returned() {
let dir = tempfile::tempdir().unwrap();
let _data = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
let sent = WayfernConfig {
fingerprint: Some(payload()),
identity_baseline: Some(payload()),
identity_overrides: Some(r#"{"doNotTrack":"1"}"#.to_string()),
location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()),
persona: Some("p".to_string()),
..Default::default()
};
let profile = profile_with("155.0.8059.79", sent.clone());
ProfileManager::instance().save_profile(&profile).unwrap();
let returned = json!({
"identity_id": CURRENT_ID,
"os": "windows",
"identity_overrides": null,
});
let path = write_result(dir.path(), &result_body(&profile, "converted", returned));
let result = read_launch_result(&path, &profile.id).unwrap();
let mut launch_copy = sent.clone();
launch_copy.proxy = Some("socks5://127.0.0.1:1".to_string());
let stored = store_launch_result(&profile, &launch_copy, &result).expect("stored");
let on_disk = read_profile(&profile.id).unwrap();
assert_eq!(config_value(&on_disk), config_value(&stored));
let config = on_disk.wayfern_config.unwrap();
assert_eq!(config.identity_id.as_deref(), Some(CURRENT_ID));
assert_eq!(config.os.as_deref(), Some("windows"));
assert!(config.identity_overrides.is_none());
assert!(config.fingerprint.is_none());
assert!(config.identity_baseline.is_none());
assert_eq!(
config.location, sent.location,
"an absent location keeps the stored one"
);
assert_eq!(config.persona.as_deref(), Some("p"));
assert!(config.proxy.is_none());
let backup = crate::app_dirs::data_subdir()
.join("wayfern-config-backups")
.join(format!("{}.json", profile.id));
let backup: Value = serde_json::from_slice(&std::fs::read(backup).unwrap()).unwrap();
assert_eq!(backup["wayfernConfig"]["fingerprint"], json!(payload()));
// The stored config is no longer the one that was sent: nothing is stored.
assert!(store_launch_result(&profile, &launch_copy, &result).is_none());
assert_eq!(
config_value(&read_profile(&profile.id).unwrap()),
config_value(&stored)
);
}
#[test]
fn only_a_converted_result_with_a_config_is_stored() {
let dir = tempfile::tempdir().unwrap();
let _data = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
let sent = WayfernConfig {
fingerprint: Some(payload()),
..Default::default()
};
let profile = profile_with("155.0.8059.79", sent.clone());
ProfileManager::instance().save_profile(&profile).unwrap();
let returned = json!({ "identity_id": CURRENT_ID, "os": "windows" });
for status in ["unchanged", "deferred", "failed"] {
let path = write_result(dir.path(), &result_body(&profile, status, returned.clone()));
let result = read_launch_result(&path, &profile.id).unwrap();
assert!(
store_launch_result(&profile, &sent, &result).is_none(),
"{status}"
);
}
for unusable in [
Value::Null,
json!({ "os": "windows" }),
json!({ "identity_id": CURRENT_ID, "os": "beos" }),
] {
let path = write_result(
dir.path(),
&result_body(&profile, "converted", unusable.clone()),
);
let result = read_launch_result(&path, &profile.id).unwrap();
assert!(
store_launch_result(&profile, &sent, &result).is_none(),
"{unusable}"
);
}
let mut edited = profile.clone();
edited.wayfern_config.as_mut().unwrap().identity_overrides =
Some(r#"{"doNotTrack":"1"}"#.to_string());
ProfileManager::instance().save_profile(&edited).unwrap();
let path = write_result(dir.path(), &result_body(&profile, "converted", returned));
let result = read_launch_result(&path, &profile.id).unwrap();
assert!(
store_launch_result(&profile, &sent, &result).is_none(),
"an edit made during the launch wins"
);
assert_eq!(
config_value(&read_profile(&profile.id).unwrap()),
config_value(&edited)
);
}
#[test]
fn a_returned_claim_off_the_host_keeps_the_profile_launchable_here() {
let other = if get_host_os() == "macos" {
"windows"
} else {
"macos"
};
let mut profile = profile_with("155.0.8059.79", WayfernConfig::default());
profile.host_os = None;
let result = LaunchResult {
status: "converted".to_string(),
identity_id: Some(CURRENT_ID.to_string()),
operating_system: Some(other.to_string()),
config: json!({ "identity_id": CURRENT_ID, "os": other, "location": "{}" })
.as_object()
.cloned(),
};
let updated = profile_with_result(&profile, &result).unwrap();
assert_eq!(updated.host_os.as_deref(), Some(get_host_os().as_str()));
assert!(!updated.is_cross_os());
assert_eq!(
updated.wayfern_config.unwrap().location.as_deref(),
Some("{}")
);
}
#[test]
fn protocol_refusals_are_permanent_and_everything_else_waits() {
let invalid =
+215 -7
View File
@@ -412,10 +412,26 @@ pub fn supports_wayfern_152(version: &str) -> bool {
crate::api_client::compare_versions(version, WAYFERN_152_MIN_VERSION) != std::cmp::Ordering::Less
}
/// First Wayfern version that reads the stored config from the launch document
/// and writes the config to store back to a result file. Older browsers keep
/// the launcher-side path in `wayfern_identity_storage`.
const LAUNCH_RESULT_MIN_VERSION: &str = "155";
/// Whether `version` takes the stored config in the launch document and
/// reports the config to store. Same rule as [`supports_wayfern_152`].
pub fn supports_launch_result(version: &str) -> bool {
crate::api_client::compare_versions(version, LAUNCH_RESULT_MIN_VERSION)
!= std::cmp::Ordering::Less
}
/// Where the launch-time identity document lives inside the profile directory.
/// Rewritten before every launch; the sync manifest excludes it.
pub const LAUNCH_IDENTITY_FILE: &str = "wayfern-identity.json";
/// Where the browser writes the config to store after a launch. Deleted before
/// every launch, so a file that exists afterwards came from that launch.
pub const LAUNCH_RESULT_FILE: &str = "wayfern-identity-result.json";
/// What kind of launch this is, from the browser's point of view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchKind {
@@ -797,10 +813,60 @@ pub fn persona_switch(
Some(format!("--wayfern-profile-persona={}", path.display()))
}
/// The folder in the profile directory that holds a copy of the camera source,
/// so the source travels with the profile to every machine.
const CARRIED_CAMERA_DIR: &str = "Camera Source";
/// The last component of a path written on any OS.
fn path_file_name(path: &str) -> Option<&str> {
path
.rsplit(['/', '\\'])
.next()
.filter(|name| !name.is_empty() && *name != "." && *name != "..")
}
/// The camera source for a launch. The configured file is an absolute path on
/// the machine where it was chosen. When it is here, a copy goes into the
/// profile directory; when it is not, the copy that came with the profile is
/// used.
fn camera_source(profile_path: &Path, file: &str) -> String {
let Some(name) = path_file_name(file) else {
return file.to_string();
};
let carried_dir = profile_path.join(CARRIED_CAMERA_DIR);
let carried = carried_dir.join(name);
let source = Path::new(file);
if source.is_file() {
let current = match (std::fs::metadata(source), std::fs::metadata(&carried)) {
(Ok(source), Ok(copy)) => {
source.len() == copy.len()
&& matches!(
(source.modified(), copy.modified()),
(Ok(source), Ok(copy)) if copy >= source
)
}
_ => false,
};
if !current {
let _ = std::fs::remove_dir_all(&carried_dir);
let copied =
std::fs::create_dir_all(&carried_dir).and_then(|_| std::fs::copy(source, &carried));
if let Err(e) = copied {
log::warn!("Camera source not copied into the profile file={file} err=\"{e}\"");
}
}
return file.to_string();
}
if carried.is_file() {
return carried.to_string_lossy().to_string();
}
file.to_string()
}
/// The camera switches for a launch. A file that is not there is still passed,
/// so the camera serves dark frames rather than the computer's own camera; the
/// log line here names the file.
pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec<String> {
pub fn camera_switches(version: &str, profile_path: &Path, config: &WayfernConfig) -> Vec<String> {
if !supports_wayfern_152(version) {
return Vec::new();
}
@@ -812,7 +878,8 @@ pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec<String> {
else {
return Vec::new();
};
if !Path::new(file).is_file() {
let file = camera_source(profile_path, file);
if !Path::new(&file).is_file() {
log::warn!("Camera source missing, camera serves dark frames file={file}");
}
let mut switches = vec![format!("--wayfern-camera-file={file}")];
@@ -1327,6 +1394,10 @@ pub struct WayfernLaunchResult {
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
/// The profile as stored after this launch, when the browser returned a
/// config to store. The caller persists this copy instead of its own.
#[serde(skip)]
pub stored_profile: Option<BrowserProfile>,
}
struct WayfernInstance {
@@ -1882,6 +1953,50 @@ impl WayfernManager {
Ok(path)
}
/// Delete the result file a previous launch left and return the absolute
/// path the browser writes this launch's result to. `None` when the old file
/// cannot be removed, so it can never be read as this launch's result.
fn clear_launch_result(profile_path: &str, profile: &BrowserProfile) -> Option<PathBuf> {
let dir = PathBuf::from(profile_path);
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!(
"Profile directory not created for the launch result profile={} err=\"{e}\"",
profile.id
);
return None;
}
let path = dir.canonicalize().unwrap_or(dir).join(LAUNCH_RESULT_FILE);
match std::fs::remove_file(&path) {
Ok(()) => Some(path),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(path),
Err(e) => {
log::warn!(
"Old launch result not removed profile={} err=\"{e}\"",
profile.id
);
None
}
}
}
/// Read this launch's result and store the config it returns.
fn store_launch_result(
profile: &BrowserProfile,
sent: &WayfernConfig,
result_file: Option<&Path>,
) -> (
Option<crate::wayfern_identity_storage::LaunchResult>,
Option<BrowserProfile>,
) {
let Some(result) = result_file
.and_then(|path| crate::wayfern_identity_storage::read_launch_result(path, &profile.id))
else {
return (None, None);
};
let stored = crate::wayfern_identity_storage::store_launch_result(profile, sent, &result);
(Some(result), stored)
}
/// Whether the browser started on the identity the launcher handed it.
///
/// The browser's own stderr verdict is authoritative when present: a
@@ -3002,12 +3117,20 @@ impl WayfernManager {
// the profile's device rather than the host's. Older browsers, legacy
// 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) {
let identity_document = if supports_wayfern_152(&profile.version) {
Self::launch_identity_document(config, &profile.native_os())
} else {
None
};
if launch_identity.is_none()
// A browser that reports a result also gets the stored config, and the
// launcher stores what the result says after the launch.
let result_file = if supports_launch_result(&profile.version) {
Self::clear_launch_result(profile_path, profile)
} else {
None
};
if identity_document.is_none()
&& result_file.is_none()
&& supports_wayfern_152(&profile.version)
&& config.identity_id.is_some()
&& config.fingerprint.is_none()
@@ -3017,6 +3140,27 @@ impl WayfernManager {
profile.id
);
}
let launch_identity = match result_file.as_deref() {
Some(result_file) => {
let account_known = (wayfern_token.is_some()
|| !crate::cloud_auth::CLOUD_AUTH
.is_entitled_to_wayfern_token()
.await)
&& crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
let mut document = identity_document
.as_ref()
.and_then(|document| document.as_object().cloned())
.unwrap_or_default();
document.extend(crate::wayfern_identity_storage::launch_document_keys(
profile,
config,
result_file,
account_known,
));
Some(serde_json::Value::Object(document))
}
None => identity_document.clone(),
};
let identity_file = match &launch_identity {
Some(document) => Some(
Self::write_launch_identity(profile_path, document)
@@ -3025,7 +3169,7 @@ impl WayfernManager {
None => None,
};
let identity_at_launch =
identity_file.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none());
identity_document.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none());
let pages = launch_pages(url, &profile.start_urls, kind);
let restore_session = match session_restore_verdict(
config,
@@ -3093,7 +3237,11 @@ impl WayfernManager {
&persona_seed,
config.persona.as_deref(),
));
args.extend(camera_switches(&profile.version, config));
args.extend(camera_switches(
&profile.version,
Path::new(profile_path),
config,
));
args.extend(widevine_switch(
&profile.version,
&crate::app_dirs::data_dir(),
@@ -3150,6 +3298,7 @@ impl WayfernManager {
}
kill_browser_process(pid);
}
Self::store_launch_result(profile, config, result_file.as_deref());
return Err(e);
}
};
@@ -3161,6 +3310,30 @@ impl WayfernManager {
page_targets.len()
);
let (launch_result, stored_profile) =
Self::store_launch_result(profile, config, result_file.as_deref());
// The browser names the identity it launched on; that is the one to check.
let verify_document = match launch_result
.as_ref()
.and_then(|result| result.identity_id.as_deref())
{
Some(identity_id) => {
let mut document = identity_document
.as_ref()
.and_then(|document| document.as_object().cloned())
.unwrap_or_default();
document.insert("identityId".to_string(), json!(identity_id));
if let Some(os) = launch_result
.as_ref()
.and_then(|result| result.operating_system.as_deref())
{
document.insert("operatingSystem".to_string(), json!(os));
}
Some(serde_json::Value::Object(document))
}
None => identity_document.clone(),
};
// An identity-backed profile: the id, the user's overrides and the exit's
// location are all the browser needs, and all the profile stores. The
// device comes back in the response and is deliberately NOT persisted.
@@ -3171,7 +3344,7 @@ impl WayfernManager {
&& !supports_wayfern_152(&profile.version)
&& config.identity_id.is_some()
&& config.fingerprint.is_some();
if let Some(document) = launch_identity.as_ref().filter(|_| identity_file.is_some()) {
if let Some(document) = verify_document.as_ref() {
// The identity travelled on the command line. The browser never fails
// its own launch over it (a refusal only logs), so the launcher checks,
// and a browser running on the wrong device is closed rather than
@@ -3496,6 +3669,7 @@ impl WayfernManager {
profilePath: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
cdp_port: Some(port),
stored_profile,
})
}
@@ -3722,6 +3896,7 @@ impl WayfernManager {
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
stored_profile: None,
});
} else {
log::info!(
@@ -3777,6 +3952,7 @@ impl WayfernManager {
profilePath: Some(found_profile_path),
url: None,
cdp_port,
stored_profile: None,
});
}
@@ -4903,6 +5079,9 @@ mod tests {
camera_crop: crop.map(str::to_string),
..Default::default()
};
let profile = tempfile::tempdir().unwrap();
let camera_switches =
|version: &str, config: &WayfernConfig| camera_switches(version, profile.path(), config);
assert!(camera_switches("151.0.7922.76", &config(Some(&path), None)).is_empty());
assert!(camera_switches("152.0.7977.64", &config(None, Some("0,0,10,10"))).is_empty());
assert_eq!(
@@ -4927,6 +5106,35 @@ mod tests {
assert!(valid_camera_crop(" 1, 2, 30, 40 "));
}
#[test]
fn the_camera_source_travels_with_the_profile() {
let chosen_on = tempfile::tempdir().unwrap();
let file = chosen_on.path().join("frame.png");
std::fs::write(&file, b"frame").unwrap();
let path = file.to_string_lossy().to_string();
let profile = tempfile::tempdir().unwrap();
assert_eq!(camera_source(profile.path(), &path), path);
let carried = profile.path().join(CARRIED_CAMERA_DIR).join("frame.png");
assert_eq!(std::fs::read(&carried).unwrap(), b"frame");
// Another machine has neither the folder nor the file, only the profile.
drop(chosen_on);
assert_eq!(
camera_source(profile.path(), &path),
carried.to_string_lossy().to_string()
);
assert_eq!(
camera_source(profile.path(), r"C:\Users\me\Pictures\frame.png"),
carried.to_string_lossy().to_string(),
"a path written on Windows names the same copy"
);
assert_eq!(
camera_source(profile.path(), "/nope/other.png"),
"/nope/other.png"
);
}
#[test]
fn widevine_travels_only_when_a_cdm_has_been_provisioned() {
let root = tempfile::tempdir().unwrap();