Skip to content

Commit 3a375ff

Browse files
committed
added metadata only fetching
1 parent 5725d24 commit 3a375ff

File tree

4 files changed

+468
-1
lines changed

4 files changed

+468
-1
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ cargo run --example sharing_demo
236236
# Metadata Privacy
237237
cargo run --example metadata_privacy
238238

239+
# Metadata fetch only
240+
cargo run --example file_manager_demo
241+
242+
239243
```
240244

241245
## Security

crates/fula-client/src/encryption.rs

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,276 @@ impl EncryptedClient {
423423
pub async fn delete_object_by_storage_key(&self, bucket: &str, storage_key: &str) -> Result<()> {
424424
self.inner.delete_object(bucket, storage_key).await
425425
}
426+
427+
// ═══════════════════════════════════════════════════════════════════════════
428+
// METADATA-ONLY OPERATIONS (No file content download required)
429+
// These methods are optimized for file managers and directory browsers
430+
// ═══════════════════════════════════════════════════════════════════════════
431+
432+
/// Get file metadata WITHOUT downloading the file content.
433+
///
434+
/// This is ideal for file managers that need to display file information
435+
/// (name, size, type, timestamps) without the bandwidth cost of downloading files.
436+
///
437+
/// Returns decrypted metadata including:
438+
/// - Original filename (not the obfuscated storage key)
439+
/// - Original file size (not ciphertext size)
440+
/// - Content type
441+
/// - Timestamps
442+
/// - User-defined metadata
443+
pub async fn head_object_decrypted(
444+
&self,
445+
bucket: &str,
446+
storage_key: &str,
447+
) -> Result<FileMetadata> {
448+
// HEAD request - only gets headers, NOT file content
449+
let head_result = self.inner.head_object(bucket, storage_key).await?;
450+
451+
// Check if encrypted
452+
let is_encrypted = head_result.metadata
453+
.get("encrypted")
454+
.map(|v| v == "true")
455+
.unwrap_or(false);
456+
457+
if !is_encrypted {
458+
return Ok(FileMetadata {
459+
storage_key: storage_key.to_string(),
460+
original_key: storage_key.to_string(),
461+
original_size: head_result.content_length,
462+
content_type: head_result.content_type,
463+
created_at: None,
464+
modified_at: None,
465+
user_metadata: HashMap::new(),
466+
is_encrypted: false,
467+
});
468+
}
469+
470+
// Parse encryption metadata from headers
471+
let enc_metadata_str = head_result.metadata
472+
.get("encryption")
473+
.ok_or_else(|| ClientError::Encryption(
474+
fula_crypto::CryptoError::Decryption("Missing encryption metadata".to_string())
475+
))?;
476+
477+
let enc_metadata: serde_json::Value = serde_json::from_str(enc_metadata_str)
478+
.map_err(|e| ClientError::Encryption(
479+
fula_crypto::CryptoError::Decryption(e.to_string())
480+
))?;
481+
482+
// Unwrap the DEK (needed to decrypt private metadata)
483+
let wrapped_key: EncryptedData = serde_json::from_value(
484+
enc_metadata["wrapped_key"].clone()
485+
).map_err(|e| ClientError::Encryption(
486+
fula_crypto::CryptoError::Decryption(e.to_string())
487+
))?;
488+
489+
let decryptor = Decryptor::new(self.encryption.key_manager.keypair());
490+
let dek = decryptor.decrypt_dek(&wrapped_key)
491+
.map_err(ClientError::Encryption)?;
492+
493+
// Decrypt private metadata if present (this is tiny - just a few hundred bytes)
494+
if let Some(private_meta_str) = enc_metadata["private_metadata"].as_str() {
495+
let encrypted_meta = EncryptedPrivateMetadata::from_json(private_meta_str)
496+
.map_err(ClientError::Encryption)?;
497+
let private_meta = encrypted_meta.decrypt(&dek)
498+
.map_err(ClientError::Encryption)?;
499+
500+
Ok(FileMetadata {
501+
storage_key: storage_key.to_string(),
502+
original_key: private_meta.original_key,
503+
original_size: private_meta.actual_size,
504+
content_type: private_meta.content_type,
505+
created_at: Some(private_meta.created_at),
506+
modified_at: Some(private_meta.modified_at),
507+
user_metadata: private_meta.user_metadata,
508+
is_encrypted: true,
509+
})
510+
} else {
511+
// No private metadata - use visible metadata
512+
Ok(FileMetadata {
513+
storage_key: storage_key.to_string(),
514+
original_key: storage_key.to_string(),
515+
original_size: head_result.content_length,
516+
content_type: head_result.content_type,
517+
created_at: None,
518+
modified_at: None,
519+
user_metadata: HashMap::new(),
520+
is_encrypted: true,
521+
})
522+
}
523+
}
524+
525+
/// List all objects in a bucket with decrypted metadata.
526+
///
527+
/// **This does NOT download any file content** - only metadata headers.
528+
/// Perfect for building file managers, directory browsers, or sync tools.
529+
///
530+
/// For each file, returns:
531+
/// - Original filename (decrypted)
532+
/// - Original size
533+
/// - Content type
534+
/// - Timestamps
535+
///
536+
/// Bandwidth: Only ~1-2KB per file (just headers), not the file content.
537+
pub async fn list_objects_decrypted(
538+
&self,
539+
bucket: &str,
540+
options: Option<ListObjectsOptions>,
541+
) -> Result<Vec<FileMetadata>> {
542+
// Get list of storage keys
543+
let list_result = self.inner.list_objects(bucket, options).await?;
544+
545+
let mut files = Vec::with_capacity(list_result.objects.len());
546+
547+
for obj in list_result.objects {
548+
// HEAD each object to get metadata without downloading content
549+
match self.head_object_decrypted(bucket, &obj.key).await {
550+
Ok(metadata) => files.push(metadata),
551+
Err(e) => {
552+
// Log error but continue with other files
553+
tracing::warn!("Failed to get metadata for {}: {:?}", obj.key, e);
554+
// Include with storage key as fallback
555+
files.push(FileMetadata {
556+
storage_key: obj.key.clone(),
557+
original_key: obj.key,
558+
original_size: obj.size,
559+
content_type: None,
560+
created_at: None,
561+
modified_at: None,
562+
user_metadata: HashMap::new(),
563+
is_encrypted: false,
564+
});
565+
}
566+
}
567+
}
568+
569+
Ok(files)
570+
}
571+
572+
/// List objects as a directory tree structure.
573+
///
574+
/// Groups files by their original directory paths for easy tree rendering.
575+
/// Does NOT download file content - only metadata.
576+
pub async fn list_directory(
577+
&self,
578+
bucket: &str,
579+
prefix: Option<&str>,
580+
) -> Result<DirectoryListing> {
581+
let options = prefix.map(|p| ListObjectsOptions {
582+
prefix: Some(p.to_string()),
583+
..Default::default()
584+
});
585+
586+
let files = self.list_objects_decrypted(bucket, options).await?;
587+
588+
let mut directories: HashMap<String, Vec<FileMetadata>> = HashMap::new();
589+
590+
for file in files {
591+
let dir = if let Some(last_slash) = file.original_key.rfind('/') {
592+
file.original_key[..last_slash].to_string()
593+
} else {
594+
"/".to_string()
595+
};
596+
597+
directories.entry(dir).or_default().push(file);
598+
}
599+
600+
Ok(DirectoryListing {
601+
bucket: bucket.to_string(),
602+
prefix: prefix.map(|s| s.to_string()),
603+
directories,
604+
})
605+
}
606+
}
607+
608+
/// File metadata (without file content) - optimized for file managers
609+
#[derive(Debug, Clone)]
610+
pub struct FileMetadata {
611+
/// The obfuscated storage key (what server sees)
612+
pub storage_key: String,
613+
/// Original file name/path (decrypted)
614+
pub original_key: String,
615+
/// Original file size in bytes (not ciphertext size)
616+
pub original_size: u64,
617+
/// Content type (MIME type)
618+
pub content_type: Option<String>,
619+
/// Creation timestamp (Unix seconds)
620+
pub created_at: Option<i64>,
621+
/// Last modified timestamp (Unix seconds)
622+
pub modified_at: Option<i64>,
623+
/// User-defined metadata
624+
pub user_metadata: HashMap<String, String>,
625+
/// Whether file is encrypted
626+
pub is_encrypted: bool,
627+
}
628+
629+
impl FileMetadata {
630+
/// Get the filename (last component of path)
631+
pub fn filename(&self) -> &str {
632+
self.original_key.rsplit('/').next().unwrap_or(&self.original_key)
633+
}
634+
635+
/// Get the directory path (without filename)
636+
pub fn directory(&self) -> &str {
637+
if let Some(last_slash) = self.original_key.rfind('/') {
638+
&self.original_key[..last_slash]
639+
} else {
640+
""
641+
}
642+
}
643+
644+
/// Get human-readable size
645+
pub fn size_human(&self) -> String {
646+
const KB: u64 = 1024;
647+
const MB: u64 = KB * 1024;
648+
const GB: u64 = MB * 1024;
649+
650+
if self.original_size >= GB {
651+
format!("{:.1} GB", self.original_size as f64 / GB as f64)
652+
} else if self.original_size >= MB {
653+
format!("{:.1} MB", self.original_size as f64 / MB as f64)
654+
} else if self.original_size >= KB {
655+
format!("{:.1} KB", self.original_size as f64 / KB as f64)
656+
} else {
657+
format!("{} B", self.original_size)
658+
}
659+
}
660+
}
661+
662+
/// Directory listing result
663+
#[derive(Debug, Clone)]
664+
pub struct DirectoryListing {
665+
/// Bucket name
666+
pub bucket: String,
667+
/// Prefix filter (if any)
668+
pub prefix: Option<String>,
669+
/// Files grouped by directory path
670+
pub directories: HashMap<String, Vec<FileMetadata>>,
671+
}
672+
673+
impl DirectoryListing {
674+
/// Get all unique directory paths
675+
pub fn get_directories(&self) -> Vec<&str> {
676+
self.directories.keys().map(|s| s.as_str()).collect()
677+
}
678+
679+
/// Get files in a specific directory
680+
pub fn get_files(&self, directory: &str) -> Option<&Vec<FileMetadata>> {
681+
self.directories.get(directory)
682+
}
683+
684+
/// Get total file count
685+
pub fn file_count(&self) -> usize {
686+
self.directories.values().map(|v| v.len()).sum()
687+
}
688+
689+
/// Get total size of all files
690+
pub fn total_size(&self) -> u64 {
691+
self.directories.values()
692+
.flat_map(|v| v.iter())
693+
.map(|f| f.original_size)
694+
.sum()
695+
}
426696
}
427697

428698
/// Decrypted object information including private metadata

crates/fula-client/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ mod types;
4646

4747
pub use client::FulaClient;
4848
pub use config::Config;
49-
pub use encryption::{EncryptedClient, EncryptionConfig, DecryptedObjectInfo};
49+
pub use encryption::{EncryptedClient, EncryptionConfig, DecryptedObjectInfo, FileMetadata, DirectoryListing};
5050
pub use error::{ClientError, Result};
5151
pub use multipart::{MultipartUpload, UploadProgress, ProgressCallback, upload_large_file};
5252
pub use types::*;

0 commit comments

Comments
 (0)