@@ -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
0 commit comments