chore: add cross-platform webdriver tests

This commit is contained in:
zhom
2026-07-20 03:26:31 +04:00
parent 8fe38453d4
commit f7daf68b52
29 changed files with 4997 additions and 558 deletions
+120 -150
View File
@@ -492,6 +492,9 @@ impl SyncEngine {
return Ok(());
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile = &reconciled_profile;
// Derive encryption key if encrypted sync
let encryption_key = if profile.is_encrypted_sync() {
let password = encryption::load_e2e_password()
@@ -697,11 +700,6 @@ impl SyncEngine {
log::debug!("Deleted remote file: {}", path);
}
// Upload metadata.json (sanitized profile)
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// If this sync changed the local profile directory (downloaded files and/or
// deleted local files), the manifest generated at the START of the sync is
// now stale. Uploading it would advertise wrong hashes/mtimes for the files
@@ -747,37 +745,18 @@ impl SyncEngine {
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
}
// Download remote metadata and merge changes (name, tags, notes, etc.)
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
let mut updated_profile = profile.clone();
// Merge fields that can be changed on other devices
updated_profile.name = remote_meta.name;
updated_profile.tags = remote_meta.tags;
updated_profile.note = remote_meta.note;
updated_profile.proxy_id = remote_meta.proxy_id;
updated_profile.vpn_id = remote_meta.vpn_id;
updated_profile.group_id = remote_meta.group_id;
updated_profile.extension_group_id = remote_meta.extension_group_id;
updated_profile.window_color = remote_meta.window_color;
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
} else {
// Fallback: just update last_sync
let mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
}
let mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
profile_manager
.save_profile(&updated_profile)
.map_err(|e| {
SyncError::IoError(format!("Failed to save reconciled profile metadata: {e}"))
})?;
let _ = events::emit("profiles-changed", ());
let _ = events::emit(
@@ -881,6 +860,45 @@ impl SyncEngine {
Ok(profile)
}
async fn reconcile_profile_metadata(
&self,
profile: &BrowserProfile,
) -> SyncResult<BrowserProfile> {
let profile_id = profile.id.to_string();
let key_prefix = Self::get_team_key_prefix(profile).await;
let remote_key = format!("{key_prefix}profiles/{profile_id}/metadata.json");
let stat = self.client.stat(&remote_key).await?;
if !stat.exists {
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
return Ok(profile.clone());
}
let local_updated = profile.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if local_updated > remote_updated {
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
return Ok(profile.clone());
}
if remote_updated <= local_updated {
return Ok(profile.clone());
}
let mut remote = self.download_profile_metadata(&remote_key).await?;
// Process state is device-local and deliberately stripped from uploads.
remote.process_id = profile.process_id;
remote.last_launch = profile.last_launch;
remote.last_sync = profile.last_sync;
ProfileManager::instance()
.save_profile(&remote)
.map_err(|e| SyncError::IoError(format!("Failed to save remote profile metadata: {e}")))?;
Ok(remote)
}
/// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups).
/// No browser files are synced.
async fn sync_cross_os_metadata(
@@ -889,34 +907,8 @@ impl SyncEngine {
profile: &BrowserProfile,
) -> SyncResult<()> {
let profile_id = profile.id.to_string();
let key_prefix = Self::get_team_key_prefix(profile).await;
let profile_manager = ProfileManager::instance();
// Upload our metadata
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// Download remote metadata and merge if remote has changes
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
let mut updated = profile.clone();
updated.name = remote_meta.name;
updated.tags = remote_meta.tags;
updated.note = remote_meta.note;
updated.proxy_id = remote_meta.proxy_id;
updated.vpn_id = remote_meta.vpn_id;
updated.group_id = remote_meta.group_id;
updated.extension_group_id = remote_meta.extension_group_id;
updated.window_color = remote_meta.window_color;
updated.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated);
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile = &reconciled_profile;
// Sync associated entities
if let Some(proxy_id) = &profile.proxy_id {
@@ -954,18 +946,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&sanitized)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?;
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, sanitized.updated_at.unwrap_or(0))
.await?;
Ok(())
@@ -4023,28 +4006,55 @@ pub async fn rollover_encryption_for_all_entities(
) -> Result<(), String> {
let _ = events::emit("e2e-rollover-started", ());
let internal_error = |detail: String| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": detail } }).to_string()
};
let engine = SyncEngine::create_from_settings(&app_handle)
.await
.map_err(&internal_error)?;
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
.map_err(|e| internal_error(format!("Failed to list profiles: {e}")))?;
let synced_profiles: Vec<_> = profiles
.iter()
.filter(|p| p.sync_mode != SyncMode::Disabled)
.collect();
let total_profiles = synced_profiles.len();
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
std::collections::HashSet::new();
if synced_profiles
.iter()
.any(|profile| profile.process_id.is_some())
{
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
}
let total_profiles = synced_profiles.len();
for (i, profile) in synced_profiles.iter().enumerate() {
if profile.process_id.is_some() {
running_profile_ids.insert(profile.id);
}
let id_str = profile.id.to_string();
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
}
// The remote manifest may be encrypted with the previous password. Delete
// only that manifest so the normal sync path treats every local file as an
// upload and rewrites it with the current password. Existing remote files
// remain available until their replacements have uploaded.
let key_prefix = SyncEngine::get_team_key_prefix(profile).await;
engine
.upload_profile_metadata(&id_str, profile, &key_prefix)
.await
.map_err(|e| {
internal_error(format!(
"Failed to roll over profile metadata {id_str}: {e}"
))
})?;
let manifest_key = format!("{key_prefix}profiles/{id_str}/manifest.json");
engine
.client
.delete(&manifest_key, None)
.await
.map_err(|e| internal_error(format!("Failed to reset profile manifest: {e}")))?;
engine
.sync_profile(&app_handle, profile)
.await
.map_err(|e| internal_error(format!("Failed to roll over profile {id_str}: {e}")))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({
@@ -4055,37 +4065,14 @@ pub async fn rollover_encryption_for_all_entities(
);
}
// Determine which entity ids are referenced by running profiles, so we can
// defer their re-upload (changing their files mid-session would cause the
// running browser to see a different proxy/extension config than what it
// launched with).
let mut deferred_proxy_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_vpn_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_group_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for p in &profiles {
if running_profile_ids.contains(&p.id) {
if let Some(id) = &p.proxy_id {
deferred_proxy_ids.insert(id.clone());
}
if let Some(id) = &p.vpn_id {
deferred_vpn_ids.insert(id.clone());
}
if let Some(id) = &p.group_id {
deferred_group_ids.insert(id.clone());
}
}
}
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
let total_proxies = synced_proxies.len();
let mut deferred = Vec::new();
for (i, proxy) in synced_proxies.iter().enumerate() {
if deferred_proxy_ids.contains(&proxy.id) {
deferred.push(proxy.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_proxy_sync(proxy.id.clone()).await;
}
engine
.upload_proxy(proxy)
.await
.map_err(|e| internal_error(format!("Failed to roll over proxy {}: {e}", proxy.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
@@ -4095,17 +4082,15 @@ pub async fn rollover_encryption_for_all_entities(
let groups = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
gm.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))?
.map_err(|e| internal_error(format!("Failed to get groups: {e}")))?
};
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
let total_groups = synced_groups.len();
let mut deferred_groups = Vec::new();
for (i, group) in synced_groups.iter().enumerate() {
if deferred_group_ids.contains(&group.id) {
deferred_groups.push(group.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_group_sync(group.id.clone()).await;
}
engine
.upload_group(group)
.await
.map_err(|e| internal_error(format!("Failed to roll over group {}: {e}", group.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
@@ -4116,17 +4101,15 @@ pub async fn rollover_encryption_for_all_entities(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
.map_err(|e| internal_error(format!("Failed to list VPN configs: {e}")))?
};
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
let total_vpns = synced_vpns.len();
let mut deferred_vpns = Vec::new();
for (i, config) in synced_vpns.iter().enumerate() {
if deferred_vpn_ids.contains(&config.id) {
deferred_vpns.push(config.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_vpn_sync(config.id.clone()).await;
}
engine
.upload_vpn(config)
.await
.map_err(|e| internal_error(format!("Failed to roll over VPN {}: {e}", config.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
@@ -4136,14 +4119,15 @@ pub async fn rollover_encryption_for_all_entities(
let extensions = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_extensions()
.map_err(|e| format!("Failed to list extensions: {e}"))?
.map_err(|e| internal_error(format!("Failed to list extensions: {e}")))?
};
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
let total_exts = synced_exts.len();
for (i, ext) in synced_exts.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_sync(ext.id.clone()).await;
}
engine
.upload_extension(ext)
.await
.map_err(|e| internal_error(format!("Failed to roll over extension {}: {e}", ext.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
@@ -4153,37 +4137,23 @@ pub async fn rollover_encryption_for_all_entities(
let ext_groups = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_groups()
.map_err(|e| format!("Failed to list extension groups: {e}"))?
.map_err(|e| internal_error(format!("Failed to list extension groups: {e}")))?
};
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
let total_eg = synced_ext_groups.len();
for (i, group) in synced_ext_groups.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_group_sync(group.id.clone()).await;
}
engine.upload_extension_group(group).await.map_err(|e| {
internal_error(format!(
"Failed to roll over extension group {}: {e}",
group.id
))
})?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
);
}
if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() {
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(scheduler) = super::get_global_scheduler() {
for id in deferred {
scheduler.queue_proxy_sync(id).await;
}
for id in deferred_groups {
scheduler.queue_group_sync(id).await;
}
for id in deferred_vpns {
scheduler.queue_vpn_sync(id).await;
}
}
});
}
let _ = events::emit("e2e-rollover-completed", ());
Ok(())
}
+8 -137
View File
@@ -8,7 +8,6 @@ use std::path::Path;
use std::time::SystemTime;
use super::types::{SyncError, SyncResult};
use crate::profile::types::BrowserProfile;
/// Default exclude patterns for volatile browser profile files.
/// Patterns use `**/` prefix to match at any directory depth, since the sync
@@ -61,6 +60,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/DawnWebGPUCache/**",
"**/BrowserMetrics*",
"**/.DS_Store",
// Profile metadata is a separately reconciled LWW config object. Including
// it in the browser-file manifest creates two competing sync mechanisms and
// lets a stale in-memory profile overwrite a metadata download.
"metadata.json",
".donut-sync/**",
// Orphaned local-only marker from earlier rollover-based fingerprint
// regeneration. Keep excluding it so any markers left on disk from
@@ -224,39 +227,6 @@ fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
Ok(Some(hasher.finalize().to_hex().to_string()))
}
/// Compute blake3 hash of metadata.json after sanitizing volatile fields.
/// This prevents infinite sync loops where updating last_sync triggers a new sync.
fn hash_sanitized_metadata(path: &Path) -> Result<Option<String>, SyncError> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(SyncError::IoError(format!(
"Failed to read metadata at {}: {e}",
path.display()
)));
}
};
let mut profile: BrowserProfile = serde_json::from_str(&content).map_err(|e| {
SyncError::SerializationError(format!("Failed to parse metadata for hashing: {e}"))
})?;
// Sanitize volatile fields that should not trigger a re-sync
profile.last_sync = None;
profile.process_id = None;
profile.last_launch = None;
let sanitized_json = serde_json::to_string(&profile).map_err(|e| {
SyncError::SerializationError(format!("Failed to serialize sanitized metadata: {e}"))
})?;
let mut hasher = blake3::Hasher::new();
hasher.update(sanitized_json.as_bytes());
Ok(Some(hasher.finalize().to_hex().to_string()))
}
/// Get mtime as unix timestamp
/// Returns None if the file doesn't exist (was deleted)
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
@@ -372,19 +342,7 @@ pub fn generate_manifest(
*max_mtime = (*max_mtime).max(mtime);
// Check cache for existing hash
let hash = if relative_path == "metadata.json" {
// Special case: sanitize metadata.json before hashing to prevent sync loops
match hash_sanitized_metadata(&path)? {
Some(computed_hash) => computed_hash,
None => {
log::debug!(
"File disappeared during manifest generation, skipping: {}",
path.display()
);
continue;
}
}
} else if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
cached_hash.to_string()
} else {
match hash_file(&path)? {
@@ -651,21 +609,15 @@ mod tests {
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
// metadata.json at root
let profile = BrowserProfile::default();
fs::write(
profile_dir.join("metadata.json"),
serde_json::to_string(&profile).unwrap(),
)
.unwrap();
fs::write(profile_dir.join("metadata.json"), "{}").unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
assert!(
paths.contains(&"metadata.json"),
"metadata.json should be synced"
!paths.contains(&"metadata.json"),
"metadata.json is reconciled separately from browser files"
);
assert!(
paths.contains(&"profile/Default/Cookies"),
@@ -865,85 +817,4 @@ mod tests {
assert!(diff.files_to_delete_remote.is_empty());
assert!(diff.files_to_delete_local.is_empty());
}
#[test]
fn test_generate_manifest_sanitizes_metadata() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile");
fs::create_dir_all(&profile_dir).unwrap();
let profile_id = uuid::Uuid::new_v4();
let metadata_path = profile_dir.join("metadata.json");
let profile = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(100),
process_id: Some(1234),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile).unwrap()).unwrap();
let mut cache = HashCache::default();
let manifest1 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash1 = manifest1
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Update volatile fields
let profile2 = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(200),
process_id: Some(5678),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile2).unwrap()).unwrap();
let manifest2 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash2 = manifest2
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be identical because volatile fields are sanitized
assert_eq!(
hash1, hash2,
"Metadata hash should be stable across last_sync/process_id updates"
);
// Change a non-volatile field
let profile3 = BrowserProfile {
id: profile_id,
name: "changed-name".to_string(),
last_sync: Some(200),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile3).unwrap()).unwrap();
let manifest3 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash3 = manifest3
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be different because name changed
assert_ne!(
hash1, hash3,
"Metadata hash should change when non-volatile fields change"
);
}
}