From bb8c5caa8d53d88801f981dd9faaef9a01f201f2 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 26 Aug 2025 21:00:11 +0530 Subject: [PATCH] feat(rust): Handle renamed files using public magic metadata Check both public magic metadata (for edited names) and regular metadata when determining file names during export and sync, matching Go CLI behavior Co-Authored-By: Claude --- rust/src/commands/export.rs | 78 ++++++++++++++++++++++++++++++++----- rust/src/commands/sync.rs | 75 +++++++++++++++++++++++++++++------ rust/src/models/file.rs | 10 +++++ rust/src/storage/sync.rs | 2 + rust/src/sync/engine.rs | 8 ++++ 5 files changed, 151 insertions(+), 22 deletions(-) diff --git a/rust/src/commands/export.rs b/rust/src/commands/export.rs index 3150fd83d8..1c15508345 100644 --- a/rust/src/commands/export.rs +++ b/rust/src/commands/export.rs @@ -195,9 +195,34 @@ async fn export_account(storage: &Storage, account: &Account, filter: &ExportFil } }; + // Decrypt public magic metadata to check for edited name + let pub_magic_metadata = if file.pub_magic_metadata.is_some() { + match decrypt_magic_metadata( + file.pub_magic_metadata.as_ref().unwrap(), + &file_key, + ) { + Ok(meta) => meta, + Err(e) => { + log::debug!( + "Failed to decrypt public magic metadata for file {}: {}", + file.id, + e + ); + None + } + } + } else { + None + }; + // Generate export path with original filename from metadata - let file_path = - generate_export_path(export_path, &file, Some(collection), metadata.as_ref())?; + let file_path = generate_export_path( + export_path, + &file, + Some(collection), + metadata.as_ref(), + pub_magic_metadata.as_ref(), + )?; // Skip if file already exists if file_path.exists() { @@ -285,6 +310,7 @@ fn generate_export_path( file: &crate::api::models::File, collection: Option<&crate::api::models::Collection>, metadata: Option<&FileMetadata>, + pub_magic_metadata: Option<&serde_json::Value>, ) -> Result { use chrono::{TimeZone, Utc}; @@ -324,16 +350,25 @@ fn generate_export_path( path.push(safe_name); } - // Use original filename from metadata if available - let filename = if let Some(meta) = metadata { - if let Some(title) = meta.get_title() { - // Sanitize filename for filesystem - sanitize_filename(title) + // Use filename from public magic metadata (edited name) or regular metadata + let filename = { + // First check for edited name in public magic metadata + if let Some(pub_meta) = pub_magic_metadata + && let Some(edited_name) = pub_meta.get("editedName") + && let Some(name_str) = edited_name.as_str() + && !name_str.is_empty() + { + sanitize_filename(name_str) + } else if let Some(meta) = metadata { + // Fall back to original title from metadata + if let Some(title) = meta.get_title() { + sanitize_filename(title) + } else { + generate_fallback_filename(file, metadata) + } } else { - generate_fallback_filename(file, metadata) + generate_fallback_filename(file, None) } - } else { - generate_fallback_filename(file, None) }; path.push(filename); @@ -425,3 +460,26 @@ fn decrypt_file_metadata( let metadata: FileMetadata = serde_json::from_slice(&decrypted)?; Ok(Some(metadata)) } + +/// Decrypt magic metadata (public or private) +fn decrypt_magic_metadata( + magic_metadata: &crate::api::models::MagicMetadata, + file_key: &[u8], +) -> Result> { + use base64::engine::general_purpose::STANDARD as BASE64; + + // Check if data exists + if magic_metadata.data.is_empty() || magic_metadata.header.is_empty() { + return Ok(None); + } + + let encrypted_bytes = BASE64.decode(&magic_metadata.data)?; + let header_bytes = BASE64.decode(&magic_metadata.header)?; + + // Decrypt the metadata using streaming XChaCha20-Poly1305 + let decrypted = decrypt_stream(&encrypted_bytes, &header_bytes, file_key)?; + + // Parse as generic JSON since magic metadata structure can vary + let metadata: serde_json::Value = serde_json::from_slice(&decrypted)?; + Ok(Some(metadata)) +} diff --git a/rust/src/commands/sync.rs b/rust/src/commands/sync.rs index d5a8170368..e8a709707e 100644 --- a/rust/src/commands/sync.rs +++ b/rust/src/commands/sync.rs @@ -268,6 +268,20 @@ fn decrypt_collection_keys( Ok(keys) } +/// Sanitize a filename for the filesystem +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + '\0' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect::() + .trim() + .to_string() +} + /// Prepare download tasks with proper file paths async fn prepare_download_tasks( files: &[crate::models::file::RemoteFile], @@ -290,7 +304,7 @@ async fn prepare_download_tasks( let collection = collection_map.get(&file.collection_id); // Try to decrypt metadata to get original filename - let metadata = if let Some(col_key) = + let (metadata, pub_magic_metadata) = if let Some(col_key) = download_manager.collection_keys.get(&file.collection_id) { // Decrypt file key first @@ -300,9 +314,8 @@ async fn prepare_download_tasks( secret_box_open(&key_bytes, &nonce, col_key)? }; - // Then decrypt metadata if available - // Note: file.metadata is a crate::models::file::MetadataInfo, not FileAttributes - if !file.metadata.encrypted_data.is_empty() { + // Decrypt regular metadata + let regular_meta = if !file.metadata.encrypted_data.is_empty() { if !file.metadata.decryption_header.is_empty() { let encrypted_bytes = BASE64.decode(&file.metadata.encrypted_data)?; let header_bytes = BASE64.decode(&file.metadata.decryption_header)?; @@ -319,9 +332,37 @@ async fn prepare_download_tasks( } } else { None - } + }; + + // Decrypt public magic metadata if available + let pub_meta = if let Some(ref magic) = file.pub_magic_metadata { + if !magic.data.is_empty() && !magic.header.is_empty() { + let encrypted_bytes = BASE64.decode(&magic.data)?; + let header_bytes = BASE64.decode(&magic.header)?; + + match decrypt_stream(&encrypted_bytes, &header_bytes, &file_key) { + Ok(decrypted) => { + serde_json::from_slice::(&decrypted).ok() + } + Err(e) => { + log::debug!( + "Failed to decrypt public magic metadata for file {}: {}", + file.id, + e + ); + None + } + } + } else { + None + } + } else { + None + }; + + (regular_meta, pub_meta) } else { - None + (None, None) }; // Generate export path @@ -356,15 +397,25 @@ async fn prepare_download_tasks( path.push(safe_name.trim()); } - // Use original filename from metadata or generate fallback - let filename = if let Some(ref meta) = metadata { - if let Some(title) = meta.get_title() { - title.to_string() + // Use filename from public magic metadata (edited name) or regular metadata + let filename = { + // First check for edited name in public magic metadata + if let Some(ref pub_meta) = pub_magic_metadata + && let Some(edited_name) = pub_meta.get("editedName") + && let Some(name_str) = edited_name.as_str() + && !name_str.is_empty() + { + sanitize_filename(name_str) + } else if let Some(ref meta) = metadata { + // Fall back to original title from metadata + if let Some(title) = meta.get_title() { + sanitize_filename(title) + } else { + format!("file_{}.jpg", file.id) + } } else { format!("file_{}.jpg", file.id) } - } else { - format!("file_{}.jpg", file.id) }; path.push(filename); diff --git a/rust/src/models/file.rs b/rust/src/models/file.rs index f85b40c3e6..1f52c01198 100644 --- a/rust/src/models/file.rs +++ b/rust/src/models/file.rs @@ -20,6 +20,16 @@ pub struct RemoteFile { pub is_deleted: bool, #[serde(rename = "updatedAt")] pub updated_at: i64, + #[serde(rename = "pubMagicMetadata")] + pub pub_magic_metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MagicMetadata { + pub version: i32, + pub count: i32, + pub data: String, + pub header: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/rust/src/storage/sync.rs b/rust/src/storage/sync.rs index fcc65f6c19..9c601b0429 100644 --- a/rust/src/storage/sync.rs +++ b/rust/src/storage/sync.rs @@ -141,6 +141,7 @@ impl<'a> SyncStore<'a> { metadata: metadata_obj, is_deleted: row.get::<_, i32>(6)? != 0, updated_at: row.get(7)?, + pub_magic_metadata: None, // TODO: Store and retrieve from database if needed }) })? .collect::, _>>()?; @@ -275,6 +276,7 @@ impl<'a> SyncStore<'a> { metadata: metadata_obj, is_deleted: row.get::<_, i32>(6)? != 0, updated_at: row.get(7)?, + pub_magic_metadata: None, // TODO: Store and retrieve from database if needed }) })? .collect::, _>>()?; diff --git a/rust/src/sync/engine.rs b/rust/src/sync/engine.rs index ef3d04ad45..69a8920a38 100644 --- a/rust/src/sync/engine.rs +++ b/rust/src/sync/engine.rs @@ -196,6 +196,14 @@ impl SyncEngine { }, is_deleted: file.is_deleted, updated_at: file.updation_time, + pub_magic_metadata: file.pub_magic_metadata.as_ref().map(|m| { + crate::models::file::MagicMetadata { + version: m.version, + count: m.count, + data: m.data.clone(), + header: m.header.clone(), + } + }), }; // Upsert file (insert or update)