mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-26 10:08:04 +02:00
950 lines
28 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|