mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 06:51:24 +02:00
refactor: cleanup
This commit is contained in:
1 parent
598d3bd513
commit
dd42d46753
249 files changed
+67417
-6659
No files matched your search
@@ -2,8 +2,42 @@ use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Key,
|
||||
};
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use argon2::Argon2;
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as SALT_B64},
|
||||
Engine,
|
||||
};
|
||||
|
||||
/// Derive a 32-byte AES key from a password and a raw salt with Argon2id at
|
||||
/// the crate's default parameters (m=19456 KiB, t=2, p=1, 32-byte output).
|
||||
///
|
||||
/// ONE function for every vault in the app, so the parameters can never drift
|
||||
/// between the sync, settings and cloud-auth stores. Byte-compatible with the
|
||||
/// PHC-string path used before argon2 0.6: that path hashed the DECODED salt
|
||||
/// with the same defaults and the key was its 32-byte output, which is exactly
|
||||
/// what `hash_password_into` produces here. A different parameter set would
|
||||
/// silently lock every user out of their encrypted data, so the defaults are
|
||||
/// pinned by the test below rather than trusted.
|
||||
pub fn derive_vault_key(password: &[u8], salt: &[u8]) -> Result<[u8; 32], String> {
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(password, salt, &mut key)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// The on-disk salt encoding: PHC "B64", the standard alphabet with no
|
||||
/// padding, exactly what the retired `SaltString` wrote, so files written by
|
||||
/// earlier builds decode unchanged.
|
||||
pub fn encode_salt(salt: &[u8]) -> String {
|
||||
SALT_B64.encode(salt)
|
||||
}
|
||||
|
||||
pub fn decode_salt(salt: &str) -> Result<Vec<u8>, String> {
|
||||
SALT_B64
|
||||
.decode(salt)
|
||||
.map_err(|e| format!("Invalid salt: {e}"))
|
||||
}
|
||||
use rand::RngExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
@@ -57,18 +91,8 @@ pub fn store_e2e_password(password: &str) -> Result<(), String> {
|
||||
|
||||
let vault_password = get_vault_password();
|
||||
let salt_bytes: [u8; 16] = rand::rng().random();
|
||||
let salt =
|
||||
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
|
||||
let key_bytes: [u8; 32] = hash_bytes[..32]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid key length")?;
|
||||
let salt = encode_salt(&salt_bytes);
|
||||
let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce_bytes: [u8; 12] = rand::rng().random();
|
||||
@@ -133,7 +157,7 @@ pub fn load_e2e_password() -> Result<Option<String>, String> {
|
||||
.map_err(|_| "Invalid salt encoding")?;
|
||||
offset += salt_len;
|
||||
|
||||
let salt = SaltString::from_b64(salt_str).map_err(|e| format!("Invalid salt: {e}"))?;
|
||||
let salt_bytes = decode_salt(salt_str)?;
|
||||
|
||||
if offset + 12 > file_data.len() {
|
||||
return Ok(None);
|
||||
@@ -157,16 +181,7 @@ pub fn load_e2e_password() -> Result<Option<String>, String> {
|
||||
let ciphertext = &file_data[offset..offset + ciphertext_len];
|
||||
|
||||
let vault_password = get_vault_password();
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
|
||||
let key_bytes: [u8; 32] = hash_bytes[..32]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid key length")?;
|
||||
let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
|
||||
@@ -212,18 +227,7 @@ pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8
|
||||
.decode(profile_salt)
|
||||
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
|
||||
|
||||
let salt = SaltString::encode_b64(&salt_bytes)
|
||||
.map_err(|e| format!("Failed to create salt string: {e}"))?;
|
||||
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(user_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash_bytes[..32]);
|
||||
let key = derive_vault_key(user_password.as_bytes(), &salt_bytes)?;
|
||||
|
||||
if let Ok(mut cache) = KEY_CACHE.lock() {
|
||||
cache.insert(cache_key, key);
|
||||
@@ -364,12 +368,12 @@ pub async fn delete_e2e_password() -> Result<(), String> {
|
||||
remove_e2e_password()
|
||||
}
|
||||
|
||||
/// On Team plans, only the team owner is allowed to flip the E2E password
|
||||
/// state — otherwise members could lock each other out by changing the key.
|
||||
/// Only the team owner may flip the E2E password state — otherwise members
|
||||
/// could lock each other out by changing the key.
|
||||
async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
if let Some(state) = CLOUD_AUTH.get_user().await {
|
||||
if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") {
|
||||
if state.user.effective_plan() == "team" && state.user.team_role.as_deref() != Some("owner") {
|
||||
return Err("TEAM_OWNER_ONLY".to_string());
|
||||
}
|
||||
}
|
||||
@@ -485,3 +489,30 @@ mod tests {
|
||||
assert!(decrypt_bytes(&key, &[0u8; 5]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vault_key_tests {
|
||||
use super::{decode_salt, derive_vault_key, encode_salt};
|
||||
|
||||
/// A stored vault is only readable while this vector holds. It pins the
|
||||
/// Argon2id parameters and the salt encoding together: a dependency bump
|
||||
/// that changed either would fail here instead of at the user's data.
|
||||
#[test]
|
||||
fn vault_key_derivation_is_pinned() {
|
||||
let key = derive_vault_key(b"correct horse battery staple", &[7u8; 16]).unwrap();
|
||||
let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(
|
||||
hex,
|
||||
"799f12b9e17710824482d829835acb69f5a9355bf774c4f07342823b11b90928"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salt_encoding_round_trips_without_padding() {
|
||||
let salt = [0u8, 1, 2, 3, 250, 251, 252, 253, 254, 255, 9, 8, 7, 6, 5, 4];
|
||||
let encoded = encode_salt(&salt);
|
||||
assert!(!encoded.contains('='), "PHC B64 carries no padding");
|
||||
assert_eq!(decode_salt(&encoded).unwrap(), salt);
|
||||
assert!(decode_salt("not*valid").is_err());
|
||||
}
|
||||
}
|
||||
+248
-62
@@ -192,6 +192,31 @@ fn is_safe_manifest_path(path: &str) -> bool {
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||
}
|
||||
|
||||
/// Parse an S3 `lastModified` (RFC3339) into unix seconds.
|
||||
fn rfc3339_secs(value: &str) -> Option<u64> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.and_then(|dt| u64::try_from(dt.timestamp()).ok())
|
||||
}
|
||||
|
||||
/// Whether a config entity's tombstone must stop this reconcile, given when the
|
||||
/// tombstone was written and the local entity's own last edit (0 once the local
|
||||
/// copy is gone).
|
||||
///
|
||||
/// A tombstone at least as new as the local edit wins. That keeps last-write-wins
|
||||
/// intact: an edit made strictly after the delete still uploads. A tombstone
|
||||
/// with no readable write time is treated as newer, because a skipped sync is
|
||||
/// retried and a resurrection is not self-correcting.
|
||||
fn tombstone_outranks_local(exists: bool, written_at: Option<u64>, local_updated_at: u64) -> bool {
|
||||
if !exists {
|
||||
return false;
|
||||
}
|
||||
match written_at {
|
||||
Some(secs) => secs >= local_updated_at,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkpoint all SQLite WAL files in a profile directory.
|
||||
///
|
||||
/// When a browser crashes or is killed, SQLite WAL files may contain
|
||||
@@ -395,9 +420,9 @@ impl SyncProgressTracker {
|
||||
|
||||
/// Check if sync is configured (cloud or self-hosted)
|
||||
pub fn is_sync_configured() -> bool {
|
||||
// Cloud backup is a plan capability. Every paid plan (incl. the future
|
||||
// "solo" tier) grants it, but gating on the capability — not just "is paid"
|
||||
// — keeps this correct if a plan without cloud backup is ever added.
|
||||
// Cloud backup is a plan capability. Gating on the capability — not just
|
||||
// "is paid" — keeps this correct if a plan without cloud backup is ever
|
||||
// added.
|
||||
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
|
||||
return true;
|
||||
}
|
||||
@@ -528,6 +553,46 @@ impl SyncEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a remote tombstone forbids syncing this config entity right now.
|
||||
///
|
||||
/// Every `sync_X` reconciles local against remote by presence alone, so
|
||||
/// without this a device that has not yet drained its tombstone queue
|
||||
/// re-uploads an entity another device just deleted, then downloads its own
|
||||
/// resurrection back once the drain removes the local copy. The delete never
|
||||
/// sticks on either side.
|
||||
///
|
||||
/// A failed stat is propagated, not swallowed, so the pass is reported as
|
||||
/// failed rather than as a silent success. A caller that cannot propagate
|
||||
/// treats the error as blocking: a skipped pass is retried, a resurrection is
|
||||
/// not self-correcting.
|
||||
async fn tombstone_blocks(
|
||||
&self,
|
||||
kind: &str,
|
||||
id: &str,
|
||||
local_updated_at: u64,
|
||||
) -> SyncResult<bool> {
|
||||
let tombstone_key = format!("tombstones/{}/{}.json", kind, id);
|
||||
let stat = match self.client.stat(&tombstone_key).await {
|
||||
Ok(stat) => stat,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Could not check {} before syncing {} {}; skipping this pass: {}",
|
||||
tombstone_key,
|
||||
kind,
|
||||
id,
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let written_at = stat.last_modified.as_deref().and_then(rfc3339_secs);
|
||||
let blocked = tombstone_outranks_local(stat.exists, written_at, local_updated_at);
|
||||
if blocked {
|
||||
log::info!("Skipping sync of {} {}: deleted remotely", kind, id);
|
||||
}
|
||||
Ok(blocked)
|
||||
}
|
||||
|
||||
pub async fn sync_profile(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -551,6 +616,18 @@ impl SyncEngine {
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
bias: DiffBias,
|
||||
) -> SyncResult<ProfileSyncOutcome> {
|
||||
self
|
||||
.sync_profile_inner(app_handle, profile, bias, false)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn sync_profile_inner(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
bias: DiffBias,
|
||||
reencrypt: bool,
|
||||
) -> SyncResult<ProfileSyncOutcome> {
|
||||
if profile.is_cross_os() {
|
||||
log::info!(
|
||||
@@ -694,14 +771,21 @@ impl SyncEngine {
|
||||
|
||||
// Try to download remote manifest
|
||||
let remote_manifest_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id);
|
||||
let remote_manifest = self
|
||||
.download_manifest(&remote_manifest_key, encryption_key.as_ref())
|
||||
.await?;
|
||||
let remote_manifest = if reencrypt {
|
||||
// Compare against an empty manifest locally so every file is rewritten.
|
||||
// Deleting it remotely would let a device with the previous password
|
||||
// race this upload and recreate a manifest we can no longer decrypt.
|
||||
None
|
||||
} else {
|
||||
self
|
||||
.download_manifest(&remote_manifest_key, encryption_key.as_ref())
|
||||
.await?
|
||||
};
|
||||
|
||||
// Compute diff
|
||||
let diff = compute_diff_with_bias(&local_manifest, remote_manifest.as_ref(), bias);
|
||||
|
||||
if diff.is_empty() {
|
||||
if diff.is_empty() && !reencrypt {
|
||||
log::info!("Profile {} is already in sync", profile_id);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
@@ -1641,18 +1725,25 @@ impl SyncEngine {
|
||||
let proxies = proxy_manager.get_stored_proxies();
|
||||
let local_proxy = proxies.iter().find(|p| p.id == proxy_id).cloned();
|
||||
|
||||
let local_updated_at = local_proxy.as_ref().and_then(|p| p.updated_at).unwrap_or(0);
|
||||
if self
|
||||
.tombstone_blocks("proxies", proxy_id, local_updated_at)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remote_key = format!("proxies/{}.json", proxy_id);
|
||||
let stat = self.client.stat(&remote_key).await?;
|
||||
|
||||
match (local_proxy, stat.exists) {
|
||||
(Some(proxy), true) => {
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = proxy.updated_at.unwrap_or(0);
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_updated > local_updated {
|
||||
if remote_updated > local_updated_at {
|
||||
self.download_proxy(proxy_id, app_handle).await?;
|
||||
} else if local_updated > remote_updated {
|
||||
} else if local_updated_at > remote_updated {
|
||||
self.upload_proxy(&proxy).await?;
|
||||
}
|
||||
}
|
||||
@@ -1780,18 +1871,25 @@ impl SyncEngine {
|
||||
groups.into_iter().find(|g| g.id == group_id)
|
||||
};
|
||||
|
||||
let local_updated_at = local_group.as_ref().and_then(|g| g.updated_at).unwrap_or(0);
|
||||
if self
|
||||
.tombstone_blocks("groups", group_id, local_updated_at)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remote_key = format!("groups/{}.json", group_id);
|
||||
let stat = self.client.stat(&remote_key).await?;
|
||||
|
||||
match (local_group, stat.exists) {
|
||||
(Some(group), true) => {
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = group.updated_at.unwrap_or(0);
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_updated > local_updated {
|
||||
if remote_updated > local_updated_at {
|
||||
self.download_group(group_id, app_handle).await?;
|
||||
} else if local_updated > remote_updated {
|
||||
} else if local_updated_at > remote_updated {
|
||||
self.upload_group(&group).await?;
|
||||
}
|
||||
}
|
||||
@@ -1980,18 +2078,25 @@ impl SyncEngine {
|
||||
storage.load_config(vpn_id).ok()
|
||||
};
|
||||
|
||||
let local_updated_at = local_vpn.as_ref().and_then(|v| v.updated_at).unwrap_or(0);
|
||||
if self
|
||||
.tombstone_blocks("vpns", vpn_id, local_updated_at)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remote_key = format!("vpns/{}.json", vpn_id);
|
||||
let stat = self.client.stat(&remote_key).await?;
|
||||
|
||||
match (local_vpn, stat.exists) {
|
||||
(Some(vpn), true) => {
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = vpn.updated_at.unwrap_or(0);
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_updated > local_updated {
|
||||
if remote_updated > local_updated_at {
|
||||
self.download_vpn(vpn_id, app_handle).await?;
|
||||
} else if local_updated > remote_updated {
|
||||
} else if local_updated_at > remote_updated {
|
||||
self.upload_vpn(&vpn).await?;
|
||||
}
|
||||
}
|
||||
@@ -2053,6 +2158,12 @@ impl SyncEngine {
|
||||
let mut vpn: crate::vpn::VpnConfig = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse VPN JSON: {e}")))?;
|
||||
|
||||
// Sync is not a second way in for a config the import path refuses: a
|
||||
// multi-peer file resolves to whichever peer is listed last at connect
|
||||
// time, which is not the tunnel its author described.
|
||||
crate::vpn::VpnStorage::ensure_single_peer(vpn.vpn_type, &vpn.config_data)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Rejected synced VPN {vpn_id}: {e}")))?;
|
||||
|
||||
vpn.last_sync = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -2125,18 +2236,25 @@ impl SyncEngine {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let local_updated_at = local_ext.as_ref().map_or(0, |e| e.updated_at);
|
||||
if self
|
||||
.tombstone_blocks("extensions", ext_id, local_updated_at)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remote_key = format!("extensions/{}.json", ext_id);
|
||||
let stat = self.client.stat(&remote_key).await?;
|
||||
|
||||
match (local_ext, stat.exists) {
|
||||
(Some(ext), true) => {
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = ext.updated_at;
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_updated > local_updated {
|
||||
if remote_updated > local_updated_at {
|
||||
self.download_extension(ext_id, app_handle).await?;
|
||||
} else if local_updated > remote_updated {
|
||||
} else if local_updated_at > remote_updated {
|
||||
self.upload_extension(&ext).await?;
|
||||
}
|
||||
}
|
||||
@@ -2317,18 +2435,25 @@ impl SyncEngine {
|
||||
manager.get_group(group_id).ok()
|
||||
};
|
||||
|
||||
let local_updated_at = local_group.as_ref().map_or(0, |g| g.updated_at);
|
||||
if self
|
||||
.tombstone_blocks("extension_groups", group_id, local_updated_at)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remote_key = format!("extension_groups/{}.json", group_id);
|
||||
let stat = self.client.stat(&remote_key).await?;
|
||||
|
||||
match (local_group, stat.exists) {
|
||||
(Some(group), true) => {
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = group.updated_at;
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_updated > local_updated {
|
||||
if remote_updated > local_updated_at {
|
||||
self.download_extension_group(group_id, app_handle).await?;
|
||||
} else if local_updated > remote_updated {
|
||||
} else if local_updated_at > remote_updated {
|
||||
self.upload_extension_group(&group).await?;
|
||||
}
|
||||
}
|
||||
@@ -2980,11 +3105,12 @@ impl SyncEngine {
|
||||
.iter()
|
||||
.any(|p| p.id == proxy_id);
|
||||
if !exists_locally {
|
||||
let tombstone_key = format!("tombstones/proxies/{}.json", proxy_id);
|
||||
if let Ok(stat) = self.client.stat(&tombstone_key).await {
|
||||
if stat.exists {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.tombstone_blocks("proxies", proxy_id, 0)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
log::info!(
|
||||
"Proxy {} exists remotely but not locally, downloading...",
|
||||
@@ -3014,11 +3140,12 @@ impl SyncEngine {
|
||||
.any(|g| g.id == group_id)
|
||||
};
|
||||
if !exists_locally {
|
||||
let tombstone_key = format!("tombstones/groups/{}.json", group_id);
|
||||
if let Ok(stat) = self.client.stat(&tombstone_key).await {
|
||||
if stat.exists {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.tombstone_blocks("groups", group_id, 0)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
log::info!(
|
||||
"Group {} exists remotely but not locally, downloading...",
|
||||
@@ -3044,11 +3171,12 @@ impl SyncEngine {
|
||||
storage.load_config(vpn_id).is_ok()
|
||||
};
|
||||
if !exists_locally {
|
||||
let tombstone_key = format!("tombstones/vpns/{}.json", vpn_id);
|
||||
if let Ok(stat) = self.client.stat(&tombstone_key).await {
|
||||
if stat.exists {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.tombstone_blocks("vpns", vpn_id, 0)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
log::info!(
|
||||
"VPN {} exists remotely but not locally, downloading...",
|
||||
@@ -3081,11 +3209,12 @@ impl SyncEngine {
|
||||
.any(|e| e.id == ext_id)
|
||||
};
|
||||
if !exists_locally {
|
||||
let tombstone_key = format!("tombstones/extensions/{}.json", ext_id);
|
||||
if let Ok(stat) = self.client.stat(&tombstone_key).await {
|
||||
if stat.exists {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.tombstone_blocks("extensions", ext_id, 0)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
log::info!(
|
||||
"Extension {} exists remotely but not locally, downloading...",
|
||||
@@ -3116,11 +3245,12 @@ impl SyncEngine {
|
||||
.any(|g| g.id == group_id)
|
||||
};
|
||||
if !exists_locally {
|
||||
let tombstone_key = format!("tombstones/extension_groups/{}.json", group_id);
|
||||
if let Ok(stat) = self.client.stat(&tombstone_key).await {
|
||||
if stat.exists {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.tombstone_blocks("extension_groups", group_id, 0)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
log::info!(
|
||||
"Extension group {} exists remotely but not locally, downloading...",
|
||||
@@ -3438,10 +3568,11 @@ pub async fn set_profile_sync_mode(
|
||||
.save_profile(&profile)
|
||||
.map_err(|e| format!("Failed to save profile: {e}"))?;
|
||||
|
||||
// The bot materialises the profile from donut-sync, so switching sync off (or
|
||||
// to Encrypted, which the host cannot decrypt) is a refusal reason. The server
|
||||
// holds only the copy this machine declared; without this, an enrolment keeps
|
||||
// claiming a syncable profile every night after the user turned sync off.
|
||||
// A remote run obtains the profile through sync, so a local-only profile has
|
||||
// nothing there — and switching to Encrypted leaves a copy that cannot be
|
||||
// decrypted remotely. Either is a refusal reason. The server holds only the
|
||||
// copy this machine declared; without this, an enrolment keeps claiming a
|
||||
// syncable profile every night after the user turned sync off.
|
||||
crate::cookie_bot::report_profile_state(&profile);
|
||||
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
@@ -3697,6 +3828,22 @@ pub async fn pull_profile_after_remote_session(
|
||||
.map_err(|e| format!("Sync failed: {e}"))
|
||||
}
|
||||
|
||||
/// Drop a stale tombstone for a config entity that is being (re-)enabled for
|
||||
/// sync.
|
||||
///
|
||||
/// `sync_X` refuses to touch an entity whose tombstone is newer than its last
|
||||
/// edit, so an id that was ever deleted could otherwise never be uploaded
|
||||
/// again, which matters when an import restores a previously-deleted id. The
|
||||
/// profile path clears its own tombstone on re-enable for the same reason.
|
||||
async fn clear_config_tombstone(app_handle: &tauri::AppHandle, kind: &str, id: &str) {
|
||||
if let Ok(engine) = SyncEngine::create_from_settings(app_handle).await {
|
||||
let tombstone_key = format!("tombstones/{}/{}.json", kind, id);
|
||||
if let Err(e) = engine.client.delete(&tombstone_key, None).await {
|
||||
log::warn!("Failed to clear tombstone {}: {}", tombstone_key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_proxy_sync_enabled(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -3735,6 +3882,8 @@ pub async fn set_proxy_sync_enabled(
|
||||
let _ = events::emit("stored-proxies-changed", ());
|
||||
|
||||
if enabled {
|
||||
clear_config_tombstone(&app_handle, "proxies", &proxy_id).await;
|
||||
|
||||
let _ = events::emit(
|
||||
"proxy-sync-status",
|
||||
serde_json::json!({
|
||||
@@ -3805,6 +3954,8 @@ pub async fn set_group_sync_enabled(
|
||||
let _ = events::emit("groups-changed", ());
|
||||
|
||||
if enabled {
|
||||
clear_config_tombstone(&app_handle, "groups", &group_id).await;
|
||||
|
||||
let _ = events::emit(
|
||||
"group-sync-status",
|
||||
serde_json::json!({
|
||||
@@ -3877,6 +4028,8 @@ pub async fn set_vpn_sync_enabled(
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
|
||||
if enabled {
|
||||
clear_config_tombstone(&app_handle, "vpns", &vpn_id).await;
|
||||
|
||||
let _ = events::emit(
|
||||
"vpn-sync-status",
|
||||
serde_json::json!({
|
||||
@@ -4101,6 +4254,8 @@ pub async fn set_extension_sync_enabled(
|
||||
let _ = events::emit("extensions-changed", ());
|
||||
|
||||
if enabled {
|
||||
clear_config_tombstone(&app_handle, "extensions", &extension_id).await;
|
||||
|
||||
if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_extension_sync(extension_id).await;
|
||||
}
|
||||
@@ -4143,6 +4298,8 @@ pub async fn set_extension_group_sync_enabled(
|
||||
let _ = events::emit("extensions-changed", ());
|
||||
|
||||
if enabled {
|
||||
clear_config_tombstone(&app_handle, "extension_groups", &extension_group_id).await;
|
||||
|
||||
if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler
|
||||
.queue_extension_group_sync(extension_group_id)
|
||||
@@ -4196,10 +4353,9 @@ pub async fn rollover_encryption_for_all_entities(
|
||||
let total_profiles = synced_profiles.len();
|
||||
for (i, profile) in synced_profiles.iter().enumerate() {
|
||||
let id_str = profile.id.to_string();
|
||||
// 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.
|
||||
// Keep the old manifest present until the re-encrypted files are uploaded.
|
||||
// Other devices must never interpret a missing manifest as an empty remote
|
||||
// profile and repopulate it with files encrypted by the previous password.
|
||||
let key_prefix = SyncEngine::get_team_key_prefix(profile).await;
|
||||
engine
|
||||
.upload_profile_metadata(&id_str, profile, &key_prefix)
|
||||
@@ -4209,14 +4365,8 @@ pub async fn rollover_encryption_for_all_entities(
|
||||
"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)
|
||||
.sync_profile_inner(&app_handle, profile, DiffBias::Auto, true)
|
||||
.await
|
||||
.map_err(|e| internal_error(format!("Failed to roll over profile {id_str}: {e}")))?;
|
||||
let _ = events::emit(
|
||||
@@ -4497,6 +4647,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tombstone_outranks_local() {
|
||||
// No tombstone: the reconcile runs as before.
|
||||
assert!(!tombstone_outranks_local(false, Some(500), 100));
|
||||
|
||||
// The delete happened after the local edit, so it wins and the entity is
|
||||
// never re-uploaded. This is the resurrection loop's entry point.
|
||||
assert!(tombstone_outranks_local(true, Some(500), 100));
|
||||
|
||||
// Local copy already gone (updated_at 0): nothing may be downloaded back.
|
||||
assert!(tombstone_outranks_local(true, Some(500), 0));
|
||||
|
||||
// Same second resolves in favour of the delete.
|
||||
assert!(tombstone_outranks_local(true, Some(500), 500));
|
||||
|
||||
// A local edit made strictly after the delete still wins (last-write-wins).
|
||||
assert!(!tombstone_outranks_local(true, Some(500), 501));
|
||||
|
||||
// An unreadable write time fails closed.
|
||||
assert!(tombstone_outranks_local(true, None, 501));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rfc3339_secs() {
|
||||
assert_eq!(rfc3339_secs("1970-01-01T00:00:00Z"), Some(0));
|
||||
assert_eq!(rfc3339_secs("2024-01-01T00:00:00Z"), Some(1_704_067_200));
|
||||
// S3 returns sub-second precision and offsets other than Z.
|
||||
assert_eq!(
|
||||
rfc3339_secs("2024-01-01T01:00:00.500+01:00"),
|
||||
Some(1_704_067_200)
|
||||
);
|
||||
assert_eq!(rfc3339_secs("not a date"), None);
|
||||
// Pre-epoch cannot be a tombstone write time; treat it as unreadable.
|
||||
assert_eq!(rfc3339_secs("1969-12-31T23:59:59Z"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_safe_manifest_path() {
|
||||
// Legitimate profile-relative paths are accepted.
|
||||
|
||||
@@ -52,7 +52,12 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"**/LOCK",
|
||||
"**/*-journal",
|
||||
"**/*-wal",
|
||||
"**/*-shm",
|
||||
"**/SingletonLock",
|
||||
// Rewritten by donut before every launch from the profile metadata that
|
||||
// already syncs; uploading it would only duplicate that state.
|
||||
"**/wayfern-identity.json",
|
||||
"**/wayfern-persona.json",
|
||||
"**/SingletonSocket",
|
||||
"**/SingletonCookie",
|
||||
"**/Secure Preferences",
|
||||
@@ -423,7 +428,7 @@ pub enum DiffBias {
|
||||
/// Remote wins regardless of timestamps.
|
||||
///
|
||||
/// Used for exactly one thing: the pull that follows a remote session. A
|
||||
/// leased host has just written the authoritative copy of this profile, and
|
||||
/// remote host has just written the authoritative copy of this profile, and
|
||||
/// the local directory is whatever it was before the session started. If the
|
||||
/// user launched locally in between, local mtimes are NEWER than the host's
|
||||
/// push, so `Auto` would upload the stale copy and put every file the host
|
||||
@@ -674,6 +679,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_manifest_excludes_sqlite_shm_sidecars() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let profile_dir = temp_dir.path().join("profile_root");
|
||||
let default_dir = profile_dir.join("profile/Default");
|
||||
fs::create_dir_all(&default_dir).unwrap();
|
||||
|
||||
fs::write(profile_dir.join("Cookies-shm"), "scratch").unwrap();
|
||||
fs::write(default_dir.join("History-shm"), "scratch").unwrap();
|
||||
fs::write(default_dir.join("History-wal"), "scratch").unwrap();
|
||||
fs::write(default_dir.join("History"), "keep").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.iter().any(|p| p.ends_with("-shm")),
|
||||
"SQLite -shm sidecars are scratch state and must not sync: {paths:?}"
|
||||
);
|
||||
assert!(
|
||||
!paths.iter().any(|p| p.ends_with("-wal")),
|
||||
"-wal sidecars stay excluded: {paths:?}"
|
||||
);
|
||||
assert!(
|
||||
paths.contains(&"profile/Default/History"),
|
||||
"the database itself must still sync: {paths:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_diff_upload_all_when_no_remote() {
|
||||
let local = SyncManifest {
|
||||
|
||||
@@ -34,7 +34,7 @@ pub struct SyncServerCheck {
|
||||
/// one.
|
||||
pub storage_ready: Option<bool>,
|
||||
/// The host the server signs into presigned URLs, when it discloses one.
|
||||
/// Withheld by cloud deployments on purpose.
|
||||
/// Optional: a deployment need not publish it.
|
||||
pub storage_endpoint: Option<String>,
|
||||
/// Whether that host answered *this device*. `None` when there was nothing
|
||||
/// to probe.
|
||||
|
||||
@@ -458,13 +458,15 @@ impl SyncScheduler {
|
||||
}
|
||||
|
||||
async fn process_pending(&self, app_handle: &tauri::AppHandle) {
|
||||
// Deletions first. A queued sync for an entity another device deleted would
|
||||
// otherwise re-upload it from the local copy this tick is about to remove.
|
||||
self.process_pending_tombstones(app_handle).await;
|
||||
self.process_pending_profiles(app_handle).await;
|
||||
self.process_pending_proxies(app_handle).await;
|
||||
self.process_pending_groups(app_handle).await;
|
||||
self.process_pending_vpns(app_handle).await;
|
||||
self.process_pending_extensions(app_handle).await;
|
||||
self.process_pending_extension_groups(app_handle).await;
|
||||
self.process_pending_tombstones(app_handle).await;
|
||||
}
|
||||
|
||||
async fn process_pending_profiles(&self, app_handle: &tauri::AppHandle) {
|
||||
@@ -830,6 +832,29 @@ impl SyncScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a queued config sync for an entity whose deletion is being applied
|
||||
/// this tick, so the drain that follows cannot re-upload it.
|
||||
async fn drop_pending_config_sync(&self, entity_type: &str, entity_id: &str) {
|
||||
match entity_type {
|
||||
"proxy" => {
|
||||
self.pending_proxies.lock().await.remove(entity_id);
|
||||
}
|
||||
"group" => {
|
||||
self.pending_groups.lock().await.remove(entity_id);
|
||||
}
|
||||
"vpn" => {
|
||||
self.pending_vpns.lock().await.remove(entity_id);
|
||||
}
|
||||
"extension" => {
|
||||
self.pending_extensions.lock().await.remove(entity_id);
|
||||
}
|
||||
"extension_group" => {
|
||||
self.pending_extension_groups.lock().await.remove(entity_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) {
|
||||
let tombstones: Vec<(String, String)> = {
|
||||
let mut pending = self.pending_tombstones.lock().await;
|
||||
@@ -840,6 +865,10 @@ impl SyncScheduler {
|
||||
return;
|
||||
}
|
||||
|
||||
for (entity_type, entity_id) in &tombstones {
|
||||
self.drop_pending_config_sync(entity_type, entity_id).await;
|
||||
}
|
||||
|
||||
for (entity_type, entity_id) in tombstones {
|
||||
log::info!("Processing tombstone for {} {}", entity_type, entity_id);
|
||||
match entity_type.as_str() {
|
||||
@@ -990,4 +1019,22 @@ mod tests {
|
||||
// retired instance coming back to life could only ever be a duplicate.
|
||||
assert_eq!(scheduler.claim_start_slot(), StartDecision::Retired);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drop_pending_config_sync_removes_only_the_deleted_entity() {
|
||||
let scheduler = SyncScheduler::new();
|
||||
scheduler.queue_proxy_sync("proxy-1".to_string()).await;
|
||||
scheduler.queue_group_sync("group-1".to_string()).await;
|
||||
assert!(scheduler.is_sync_in_progress().await);
|
||||
|
||||
// A tombstone drops that entity's queued sync, otherwise the drain that
|
||||
// follows re-uploads the copy this tick is about to delete. Every other
|
||||
// queued entity is left alone.
|
||||
scheduler.drop_pending_config_sync("proxy", "proxy-1").await;
|
||||
assert!(scheduler.pending_proxies.lock().await.is_empty());
|
||||
assert!(scheduler.pending_groups.lock().await.contains("group-1"));
|
||||
|
||||
scheduler.drop_pending_config_sync("group", "group-1").await;
|
||||
assert!(!scheduler.is_sync_in_progress().await);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user