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 <noreply@anthropic.com>
This commit is contained in:
Manav Rathi
2025-08-26 21:00:11 +05:30
parent 0384819c01
commit bb8c5caa8d
5 changed files with 151 additions and 22 deletions

View File

@@ -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<PathBuf> {
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<Option<serde_json::Value>> {
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))
}

View File

@@ -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::<String>()
.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::<serde_json::Value>(&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);

View File

@@ -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<MagicMetadata>,
}
#[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)]

View File

@@ -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::<std::result::Result<Vec<_>, _>>()?;
@@ -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::<std::result::Result<Vec<_>, _>>()?;

View File

@@ -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)