Files
donutbrowser/src-tauri/src/sync/manifest.rs
T
2026-05-23 14:22:45 +04:00

950 lines
28 KiB
Rust

use chrono::{DateTime, Utc};
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufReader, Read};
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
/// engine scans from `profiles/{uuid}/` which contains `profile/Default/...`.
pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/Cache/**",
"**/Code Cache/**",
"**/GPUCache/**",
"**/GrShaderCache/**",
"**/ShaderCache/**",
"**/DawnCache/**",
"**/DawnGraphiteCache/**",
"**/Service Worker/CacheStorage/**",
"**/Service Worker/ScriptCache/**",
"**/Session Storage/**",
"**/blob_storage/**",
"**/Crashpad/**",
"**/Crash Reports/**",
"**/BrowserMetrics/**",
"**/optimization_guide_model_store/**",
"**/Safe Browsing/**",
"**/component_crx_cache/**",
"**/cache2/**",
"**/startupCache/**",
"**/safebrowsing/**",
"**/storage/temporary/**",
"**/storage/default/*/cache/**",
"**/datareporting/**",
"**/saved-telemetry-pings/**",
"**/sessionstore-backups/**",
"**/sessions/**",
"**/serviceworker.txt",
"**/AlternateServices.bin",
"**/SiteSecurityServiceState.bin",
"**/favicons.sqlite",
"**/favicons.sqlite-*",
"**/crashes/**",
"**/minidumps/**",
"*.tmp",
"**/LOG",
"**/LOG.old",
"**/LOCK",
"**/*-journal",
"**/*-wal",
"**/SingletonLock",
"**/SingletonSocket",
"**/SingletonCookie",
"**/Secure Preferences",
"**/GraphiteDawnCache/**",
"**/DawnWebGPUCache/**",
"**/BrowserMetrics*",
"**/.DS_Store",
".donut-sync/**",
// Local-only marker recording when Wayfern last refreshed this profile's
// fingerprint. Each device decides its own refresh cadence, so syncing
// this would cause one device's refresh to silence others.
".last-fp-refresh",
];
/// A single file entry in the manifest
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ManifestFileEntry {
pub path: String,
pub size: u64,
pub mtime: i64,
pub hash: String,
}
/// The sync manifest for a profile
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncManifest {
pub version: u32,
#[serde(rename = "profileId")]
pub profile_id: String,
#[serde(rename = "generatedAt")]
pub generated_at: String,
#[serde(rename = "updatedAt")]
pub updated_at: String,
#[serde(rename = "excludeGlobs")]
pub exclude_globs: Vec<String>,
pub files: Vec<ManifestFileEntry>,
#[serde(default)]
pub encrypted: bool,
}
impl SyncManifest {
pub fn new(profile_id: String, exclude_globs: Vec<String>) -> Self {
let now = Utc::now().to_rfc3339();
Self {
version: 1,
profile_id,
generated_at: now.clone(),
updated_at: now,
exclude_globs,
files: Vec::new(),
encrypted: false,
}
}
pub fn updated_at_datetime(&self) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&self.updated_at)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
}
/// Local hash cache to avoid re-hashing unchanged files
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HashCache {
pub entries: HashMap<String, HashCacheEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HashCacheEntry {
pub size: u64,
pub mtime: i64,
pub hash: String,
}
impl HashCache {
pub fn load(cache_path: &Path) -> Self {
if !cache_path.exists() {
return Self::default();
}
match fs::read_to_string(cache_path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self, cache_path: &Path) -> SyncResult<()> {
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
SyncError::IoError(format!(
"Failed to create cache directory {}: {e}",
parent.display()
))
})?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize hash cache: {e}")))?;
fs::write(cache_path, json).map_err(|e| {
SyncError::IoError(format!(
"Failed to write hash cache {}: {e}",
cache_path.display()
))
})?;
Ok(())
}
pub fn get(&self, path: &str, size: u64, mtime: i64) -> Option<&str> {
self.entries.get(path).and_then(|entry| {
if entry.size == size && entry.mtime == mtime {
Some(entry.hash.as_str())
} else {
None
}
})
}
pub fn insert(&mut self, path: String, size: u64, mtime: i64, hash: String) {
self
.entries
.insert(path, HashCacheEntry { size, mtime, hash });
}
}
/// Build a GlobSet from exclude patterns
fn build_exclude_globset(patterns: &[String]) -> SyncResult<GlobSet> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern)
.map_err(|e| SyncError::InvalidData(format!("Invalid exclude pattern '{}': {e}", pattern)))?;
builder.add(glob);
}
builder
.build()
.map_err(|e| SyncError::InvalidData(format!("Failed to build exclude globset: {e}")))
}
/// Compute blake3 hash of a file
/// Returns None if the file doesn't exist (was deleted)
fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(SyncError::IoError(format!(
"Failed to open {}: {e}",
path.display()
)));
}
};
let mut reader = BufReader::new(file);
let mut hasher = blake3::Hasher::new();
let mut buffer = [0u8; 65536]; // 64KB buffer
loop {
let bytes_read = reader
.read(&mut buffer)
.map_err(|e| SyncError::IoError(format!("Failed to read {}: {e}", path.display())))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
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> {
let metadata = match path.metadata() {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(SyncError::IoError(format!(
"Failed to get metadata for {}: {e}",
path.display()
)));
}
};
let mtime = metadata
.modified()
.map_err(|e| SyncError::IoError(format!("Failed to get mtime for {}: {e}", path.display())))?;
Ok(Some(
mtime
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0),
))
}
/// Generate a manifest for a profile directory
pub fn generate_manifest(
profile_id: &str,
profile_dir: &Path,
cache: &mut HashCache,
) -> SyncResult<SyncManifest> {
let exclude_patterns: Vec<String> = DEFAULT_EXCLUDE_PATTERNS
.iter()
.map(|s| s.to_string())
.collect();
let globset = build_exclude_globset(&exclude_patterns)?;
let mut manifest = SyncManifest::new(profile_id.to_string(), exclude_patterns);
let mut max_mtime: i64 = 0;
if !profile_dir.exists() {
log::debug!(
"Profile directory doesn't exist: {}, creating empty manifest",
profile_dir.display()
);
return Ok(manifest);
}
fn walk_dir(
dir: &Path,
base_dir: &Path,
globset: &GlobSet,
cache: &mut HashCache,
files: &mut Vec<ManifestFileEntry>,
max_mtime: &mut i64,
) -> SyncResult<()> {
let entries = fs::read_dir(dir).map_err(|e| {
SyncError::IoError(format!("Failed to read directory {}: {e}", dir.display()))
})?;
for entry in entries {
let entry = entry.map_err(|e| {
SyncError::IoError(format!("Failed to read entry in {}: {e}", dir.display()))
})?;
let path = entry.path();
let relative_path = path
.strip_prefix(base_dir)
.map_err(|_| SyncError::IoError("Failed to compute relative path".to_string()))?
.to_string_lossy()
.replace('\\', "/");
// Check if excluded
if globset.is_match(&relative_path) {
continue;
}
// Get metadata - skip if file was deleted between directory read and metadata access
let metadata = match path.metadata() {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::debug!(
"File disappeared during manifest generation, skipping: {}",
path.display()
);
continue;
}
Err(e) => {
return Err(SyncError::IoError(format!(
"Failed to get metadata for {}: {e}",
path.display()
)));
}
};
if metadata.is_dir() {
walk_dir(&path, base_dir, globset, cache, files, max_mtime)?;
} else if metadata.is_file() {
let size = metadata.len();
let mtime = match get_mtime(&path)? {
Some(m) => m,
None => {
// File was deleted, skip it
log::debug!(
"File disappeared during manifest generation, skipping: {}",
path.display()
);
continue;
}
};
*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) {
cached_hash.to_string()
} else {
match hash_file(&path)? {
Some(computed_hash) => {
cache.insert(relative_path.clone(), size, mtime, computed_hash.clone());
computed_hash
}
None => {
// File was deleted, skip it
log::debug!(
"File disappeared during manifest generation, skipping: {}",
path.display()
);
continue;
}
}
};
files.push(ManifestFileEntry {
path: relative_path,
size,
mtime,
hash,
});
}
}
Ok(())
}
walk_dir(
profile_dir,
profile_dir,
&globset,
cache,
&mut manifest.files,
&mut max_mtime,
)?;
// Sort files for deterministic manifest
manifest.files.sort_by(|a, b| a.path.cmp(&b.path));
// Update the updatedAt timestamp to max mtime
if max_mtime > 0 {
if let Some(dt) = DateTime::from_timestamp(max_mtime, 0) {
manifest.updated_at = dt.to_rfc3339();
}
}
Ok(manifest)
}
/// Compute the diff between local and remote manifests
#[derive(Debug, Default)]
pub struct ManifestDiff {
pub files_to_upload: Vec<ManifestFileEntry>,
pub files_to_download: Vec<ManifestFileEntry>,
pub files_to_delete_local: Vec<String>,
pub files_to_delete_remote: Vec<String>,
}
impl ManifestDiff {
pub fn is_empty(&self) -> bool {
self.files_to_upload.is_empty()
&& self.files_to_download.is_empty()
&& self.files_to_delete_local.is_empty()
&& self.files_to_delete_remote.is_empty()
}
}
/// Compute what needs to be synced between local and remote
pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff {
let mut diff = ManifestDiff::default();
let Some(remote) = remote else {
// No remote manifest - upload everything
diff.files_to_upload = local.files.clone();
return diff;
};
// Build hash maps for quick lookup
let local_files: HashMap<&str, &ManifestFileEntry> =
local.files.iter().map(|f| (f.path.as_str(), f)).collect();
let remote_files: HashMap<&str, &ManifestFileEntry> =
remote.files.iter().map(|f| (f.path.as_str(), f)).collect();
// Safety: if local is empty but remote has files, always download from remote.
// This prevents data loss when profile data files are deleted but metadata
// survives — the newly generated manifest would have updated_at=NOW, which
// would appear "newer" and cause all remote files to be deleted.
if local.files.is_empty() && !remote.files.is_empty() {
log::info!(
"Local manifest is empty but remote has {} files — downloading from remote to recover",
remote.files.len()
);
diff.files_to_download = remote.files.clone();
return diff;
}
// Compare timestamps to determine direction
let local_updated = local.updated_at_datetime();
let remote_updated = remote.updated_at_datetime();
let local_is_newer = match (local_updated, remote_updated) {
(Some(l), Some(r)) => l > r,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => true, // Default to uploading
};
if local_is_newer {
// Upload changed/new files, delete remote files that don't exist locally
for (path, local_entry) in &local_files {
match remote_files.get(path) {
Some(remote_entry) if remote_entry.hash != local_entry.hash => {
diff.files_to_upload.push((*local_entry).clone());
}
None => {
diff.files_to_upload.push((*local_entry).clone());
}
_ => {}
}
}
for path in remote_files.keys() {
if !local_files.contains_key(path) {
diff.files_to_delete_remote.push(path.to_string());
}
}
} else {
// Download changed/new files, delete local files that don't exist remotely
for (path, remote_entry) in &remote_files {
match local_files.get(path) {
Some(local_entry) if local_entry.hash != remote_entry.hash => {
diff.files_to_download.push((*remote_entry).clone());
}
None => {
diff.files_to_download.push((*remote_entry).clone());
}
_ => {}
}
}
for path in local_files.keys() {
if !remote_files.contains_key(path) {
diff.files_to_delete_local.push(path.to_string());
}
}
}
diff
}
/// Get the path to the hash cache file for a profile
pub fn get_cache_path(profile_dir: &Path) -> std::path::PathBuf {
profile_dir.join(".donut-sync").join("cache.json")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_hash_cache_operations() {
let cache_dir = TempDir::new().unwrap();
let cache_path = cache_dir.path().join("cache.json");
let mut cache = HashCache::default();
cache.insert(
"test.txt".to_string(),
100,
1234567890,
"abc123".to_string(),
);
assert_eq!(cache.get("test.txt", 100, 1234567890), Some("abc123"));
assert_eq!(cache.get("test.txt", 100, 999), None); // Different mtime
assert_eq!(cache.get("test.txt", 50, 1234567890), None); // Different size
cache.save(&cache_path).unwrap();
let loaded = HashCache::load(&cache_path);
assert_eq!(loaded.get("test.txt", 100, 1234567890), Some("abc123"));
}
#[test]
fn test_generate_manifest_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile");
fs::create_dir_all(&profile_dir).unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
assert_eq!(manifest.profile_id, "test-profile");
assert_eq!(manifest.version, 1);
assert!(manifest.files.is_empty());
}
#[test]
fn test_generate_manifest_with_files() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile");
fs::create_dir_all(&profile_dir).unwrap();
fs::write(profile_dir.join("file1.txt"), "hello").unwrap();
fs::write(profile_dir.join("file2.txt"), "world").unwrap();
fs::create_dir_all(profile_dir.join("subdir")).unwrap();
fs::write(profile_dir.join("subdir/file3.txt"), "nested").unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
assert_eq!(manifest.files.len(), 3);
assert!(manifest.files.iter().any(|f| f.path == "file1.txt"));
assert!(manifest.files.iter().any(|f| f.path == "file2.txt"));
assert!(manifest.files.iter().any(|f| f.path == "subdir/file3.txt"));
}
#[test]
fn test_generate_manifest_excludes_cache() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile");
fs::create_dir_all(&profile_dir).unwrap();
fs::write(profile_dir.join("file1.txt"), "keep").unwrap();
fs::create_dir_all(profile_dir.join("Cache")).unwrap();
fs::write(profile_dir.join("Cache/data"), "exclude").unwrap();
fs::create_dir_all(profile_dir.join("Code Cache")).unwrap();
fs::write(profile_dir.join("Code Cache/wasm"), "exclude").unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
assert_eq!(manifest.files.len(), 1);
assert_eq!(manifest.files[0].path, "file1.txt");
}
#[test]
fn test_generate_manifest_excludes_nested_caches() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile_root");
fs::create_dir_all(&profile_dir).unwrap();
// Simulate real Chromium structure: profile/Default/Cache/...
let default_dir = profile_dir.join("profile/Default");
fs::create_dir_all(&default_dir).unwrap();
fs::write(default_dir.join("Cookies"), "keep").unwrap();
fs::create_dir_all(default_dir.join("Cache")).unwrap();
fs::write(default_dir.join("Cache/data_0"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("Code Cache/js")).unwrap();
fs::write(default_dir.join("Code Cache/js/abc"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("GPUCache")).unwrap();
fs::write(default_dir.join("GPUCache/data_0"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("Session Storage")).unwrap();
fs::write(default_dir.join("Session Storage/000003.log"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("Local Storage/leveldb")).unwrap();
fs::write(default_dir.join("Local Storage/leveldb/000001.ldb"), "keep").unwrap();
// Caches at user-data-dir level
fs::create_dir_all(profile_dir.join("profile/ShaderCache")).unwrap();
fs::write(profile_dir.join("profile/ShaderCache/data"), "exclude").unwrap();
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();
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"
);
assert!(
paths.contains(&"profile/Default/Cookies"),
"Cookies should be synced"
);
assert!(
paths.contains(&"profile/Default/Local Storage/leveldb/000001.ldb"),
"Local Storage should be synced"
);
assert!(
!paths.iter().any(|p| p.contains("Cache")),
"Cache directories should be excluded: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.contains("Session Storage")),
"Session Storage should be excluded: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.contains("Crashpad")),
"Crashpad should be excluded: {paths:?}"
);
}
#[test]
fn test_compute_diff_upload_all_when_no_remote() {
let local = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: Utc::now().to_rfc3339(),
updated_at: Utc::now().to_rfc3339(),
exclude_globs: vec![],
files: vec![
ManifestFileEntry {
path: "file1.txt".to_string(),
size: 10,
mtime: 1000,
hash: "abc".to_string(),
},
ManifestFileEntry {
path: "file2.txt".to_string(),
size: 20,
mtime: 2000,
hash: "def".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, None);
assert_eq!(diff.files_to_upload.len(), 2);
assert!(diff.files_to_download.is_empty());
assert!(diff.files_to_delete_local.is_empty());
assert!(diff.files_to_delete_remote.is_empty());
}
#[test]
fn test_compute_diff_detect_changes() {
let old_time = "2024-01-01T00:00:00Z";
let new_time = "2024-01-02T00:00:00Z";
let local = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: new_time.to_string(),
updated_at: new_time.to_string(),
exclude_globs: vec![],
files: vec![
ManifestFileEntry {
path: "unchanged.txt".to_string(),
size: 10,
mtime: 1000,
hash: "same".to_string(),
},
ManifestFileEntry {
path: "changed.txt".to_string(),
size: 10,
mtime: 2000,
hash: "new_hash".to_string(),
},
ManifestFileEntry {
path: "new_file.txt".to_string(),
size: 5,
mtime: 3000,
hash: "new".to_string(),
},
],
encrypted: false,
};
let remote = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: old_time.to_string(),
updated_at: old_time.to_string(),
exclude_globs: vec![],
files: vec![
ManifestFileEntry {
path: "unchanged.txt".to_string(),
size: 10,
mtime: 1000,
hash: "same".to_string(),
},
ManifestFileEntry {
path: "changed.txt".to_string(),
size: 10,
mtime: 1000,
hash: "old_hash".to_string(),
},
ManifestFileEntry {
path: "deleted.txt".to_string(),
size: 8,
mtime: 500,
hash: "gone".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, Some(&remote));
// Local is newer, so we upload changed/new and delete remote-only
assert_eq!(diff.files_to_upload.len(), 2); // changed + new
assert!(diff.files_to_upload.iter().any(|f| f.path == "changed.txt"));
assert!(diff
.files_to_upload
.iter()
.any(|f| f.path == "new_file.txt"));
assert!(diff.files_to_download.is_empty());
assert!(diff.files_to_delete_local.is_empty());
assert_eq!(diff.files_to_delete_remote.len(), 1);
assert!(diff
.files_to_delete_remote
.contains(&"deleted.txt".to_string()));
}
#[test]
fn test_manifest_encrypted_flag_default() {
let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[]}"#;
let manifest: SyncManifest = serde_json::from_str(json).unwrap();
assert!(!manifest.encrypted);
}
#[test]
fn test_manifest_with_encrypted_flag() {
let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[],"encrypted":true}"#;
let manifest: SyncManifest = serde_json::from_str(json).unwrap();
assert!(manifest.encrypted);
let serialized = serde_json::to_string(&manifest).unwrap();
let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap();
assert!(deserialized.encrypted);
}
#[test]
fn test_compute_diff_empty_local_downloads_from_remote() {
// When local has no files but remote does, always download from remote.
// This prevents data loss when profile data is deleted but metadata survives.
let local = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: Utc::now().to_rfc3339(),
updated_at: Utc::now().to_rfc3339(), // NOW — appears newer than remote
exclude_globs: vec![],
files: vec![],
encrypted: false,
};
let remote = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
exclude_globs: vec![],
files: vec![
ManifestFileEntry {
path: "Cookies".to_string(),
size: 100,
mtime: 1000,
hash: "abc".to_string(),
},
ManifestFileEntry {
path: "Local State".to_string(),
size: 200,
mtime: 1000,
hash: "def".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, Some(&remote));
// Must download all remote files, NOT delete them
assert_eq!(diff.files_to_download.len(), 2);
assert!(diff.files_to_upload.is_empty());
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"
);
}
}