@@ -11,7 +11,7 @@ use aws_sigv4::http_request::{
1111use aws_sigv4:: sign:: v4;
1212use rc_core:: admin:: {
1313 AdminApi , BucketQuota , ClusterInfo , CreateServiceAccountRequest , Group , GroupStatus ,
14- HealStartRequest , HealStatus , Policy , PolicyEntity , PolicyInfo , ServiceAccount ,
14+ HealScanMode , HealStartRequest , HealStatus , Policy , PolicyEntity , PolicyInfo , ServiceAccount ,
1515 ServiceAccountCreateResponse , UpdateGroupMembersRequest , User , UserStatus ,
1616} ;
1717use rc_core:: { Alias , Error , Result } ;
@@ -296,7 +296,7 @@ impl AdminClient {
296296 StatusCode :: NOT_FOUND => Error :: NotFound ( body. to_string ( ) ) ,
297297 StatusCode :: FORBIDDEN | StatusCode :: UNAUTHORIZED => Error :: Auth ( body. to_string ( ) ) ,
298298 StatusCode :: CONFLICT => Error :: Conflict ( body. to_string ( ) ) ,
299- StatusCode :: BAD_REQUEST => Error :: InvalidPath ( body . to_string ( ) ) ,
299+ StatusCode :: BAD_REQUEST => Error :: General ( format ! ( "Bad request: {body}" ) ) ,
300300 _ => Error :: Network ( format ! ( "HTTP {}: {}" , status. as_u16( ) , body) ) ,
301301 }
302302 }
@@ -361,6 +361,87 @@ struct ServiceAccountInfo {
361361 implied_policy : Option < bool > ,
362362}
363363
364+ #[ derive( Debug , Deserialize ) ]
365+ #[ serde( rename_all = "camelCase" ) ]
366+ struct BackgroundHealStatusResponse {
367+ #[ serde( default ) ]
368+ bitrot_start_time : Option < String > ,
369+ }
370+
371+ impl From < BackgroundHealStatusResponse > for HealStatus {
372+ fn from ( response : BackgroundHealStatusResponse ) -> Self {
373+ Self {
374+ healing : response. bitrot_start_time . is_some ( ) ,
375+ started : response. bitrot_start_time ,
376+ ..Default :: default ( )
377+ }
378+ }
379+ }
380+
381+ #[ derive( Debug , Serialize ) ]
382+ struct RustfsHealOptions {
383+ recursive : bool ,
384+ #[ serde( rename = "dryRun" ) ]
385+ dry_run : bool ,
386+ remove : bool ,
387+ recreate : bool ,
388+ #[ serde( rename = "scanMode" ) ]
389+ scan_mode : u8 ,
390+ #[ serde( rename = "updateParity" ) ]
391+ update_parity : bool ,
392+ #[ serde( rename = "nolock" ) ]
393+ no_lock : bool ,
394+ }
395+
396+ impl From < & HealStartRequest > for RustfsHealOptions {
397+ fn from ( request : & HealStartRequest ) -> Self {
398+ Self {
399+ recursive : false ,
400+ dry_run : request. dry_run ,
401+ remove : request. remove ,
402+ recreate : request. recreate ,
403+ scan_mode : rustfs_heal_scan_mode ( request. scan_mode ) ,
404+ update_parity : false ,
405+ no_lock : false ,
406+ }
407+ }
408+ }
409+
410+ fn rustfs_heal_scan_mode ( scan_mode : HealScanMode ) -> u8 {
411+ match scan_mode {
412+ HealScanMode :: Normal => 1 ,
413+ HealScanMode :: Deep => 2 ,
414+ }
415+ }
416+
417+ fn rustfs_heal_path ( request : & HealStartRequest ) -> Result < String > {
418+ let bucket = request
419+ . bucket
420+ . as_deref ( )
421+ . filter ( |bucket| !bucket. is_empty ( ) ) ;
422+ let prefix = request
423+ . prefix
424+ . as_deref ( )
425+ . filter ( |prefix| !prefix. is_empty ( ) ) ;
426+
427+ match ( bucket, prefix) {
428+ ( None , None ) => Ok ( "/heal/" . to_string ( ) ) ,
429+ ( Some ( bucket) , None ) => Ok ( format ! ( "/heal/{}" , urlencoding:: encode( bucket) ) ) ,
430+ ( Some ( bucket) , Some ( prefix) ) => Ok ( format ! (
431+ "/heal/{}/{}" ,
432+ urlencoding:: encode( bucket) ,
433+ urlencoding:: encode( prefix)
434+ ) ) ,
435+ ( None , Some ( _) ) => Err ( Error :: InvalidPath (
436+ "heal prefix requires a bucket target" . to_string ( ) ,
437+ ) ) ,
438+ }
439+ }
440+
441+ fn rustfs_heal_body ( request : & HealStartRequest ) -> Result < Vec < u8 > > {
442+ serde_json:: to_vec ( & RustfsHealOptions :: from ( request) ) . map_err ( Error :: Json )
443+ }
444+
364445/// Request body for setting bucket quota
365446#[ derive( Debug , Serialize ) ]
366447#[ serde( rename_all = "camelCase" ) ]
@@ -378,18 +459,29 @@ impl AdminApi for AdminClient {
378459 }
379460
380461 async fn heal_status ( & self ) -> Result < HealStatus > {
381- self . request ( Method :: GET , "/heal/status" , None , None ) . await
462+ let response: BackgroundHealStatusResponse = self
463+ . request ( Method :: POST , "/background-heal/status" , None , None )
464+ . await ?;
465+ Ok ( response. into ( ) )
382466 }
383467
384468 async fn heal_start ( & self , request : HealStartRequest ) -> Result < HealStatus > {
385- let body = serde_json:: to_vec ( & request) . map_err ( Error :: Json ) ?;
386- self . request ( Method :: POST , "/heal/start" , None , Some ( & body) )
387- . await
469+ let path = rustfs_heal_path ( & request) ?;
470+ let body = rustfs_heal_body ( & request) ?;
471+ self . request_no_response ( Method :: POST , & path, None , Some ( & body) )
472+ . await ?;
473+ Ok ( HealStatus :: default ( ) )
388474 }
389475
390476 async fn heal_stop ( & self ) -> Result < ( ) > {
391- self . request_no_response ( Method :: POST , "/heal/stop" , None , None )
392- . await
477+ let body = rustfs_heal_body ( & HealStartRequest :: default ( ) ) ?;
478+ self . request_no_response (
479+ Method :: POST ,
480+ "/heal/" ,
481+ Some ( & [ ( "forceStop" , "true" ) ] ) ,
482+ Some ( & body) ,
483+ )
484+ . await
393485 }
394486
395487 // ==================== User Operations ====================
@@ -846,6 +938,93 @@ mod tests {
846938 ) ;
847939 }
848940
941+ #[ test]
942+ fn test_rustfs_heal_path_matches_admin_routes ( ) {
943+ assert_eq ! (
944+ rustfs_heal_path( & HealStartRequest :: default ( ) ) . expect( "root path" ) ,
945+ "/heal/"
946+ ) ;
947+
948+ let bucket_request = HealStartRequest {
949+ bucket : Some ( "photos" . to_string ( ) ) ,
950+ ..Default :: default ( )
951+ } ;
952+ assert_eq ! (
953+ rustfs_heal_path( & bucket_request) . expect( "bucket path" ) ,
954+ "/heal/photos"
955+ ) ;
956+
957+ let prefix_request = HealStartRequest {
958+ bucket : Some ( "photos" . to_string ( ) ) ,
959+ prefix : Some ( "2026/raw" . to_string ( ) ) ,
960+ ..Default :: default ( )
961+ } ;
962+ assert_eq ! (
963+ rustfs_heal_path( & prefix_request) . expect( "prefix path" ) ,
964+ "/heal/photos/2026%2Fraw"
965+ ) ;
966+
967+ let invalid_request = HealStartRequest {
968+ prefix : Some ( "2026/raw" . to_string ( ) ) ,
969+ ..Default :: default ( )
970+ } ;
971+ assert ! ( matches!(
972+ rustfs_heal_path( & invalid_request) ,
973+ Err ( Error :: InvalidPath ( _) )
974+ ) ) ;
975+ }
976+
977+ #[ test]
978+ fn test_rustfs_heal_body_matches_server_heal_options ( ) {
979+ let request = HealStartRequest {
980+ scan_mode : HealScanMode :: Deep ,
981+ remove : true ,
982+ recreate : true ,
983+ dry_run : true ,
984+ ..Default :: default ( )
985+ } ;
986+
987+ let body = rustfs_heal_body ( & request) . expect ( "heal options should serialize" ) ;
988+ let value: serde_json:: Value =
989+ serde_json:: from_slice ( & body) . expect ( "heal options body should be JSON" ) ;
990+
991+ assert_eq ! ( value[ "recursive" ] , false ) ;
992+ assert_eq ! ( value[ "dryRun" ] , true ) ;
993+ assert_eq ! ( value[ "remove" ] , true ) ;
994+ assert_eq ! ( value[ "recreate" ] , true ) ;
995+ assert_eq ! ( value[ "scanMode" ] , 2 ) ;
996+ assert_eq ! ( value[ "updateParity" ] , false ) ;
997+ assert_eq ! ( value[ "nolock" ] , false ) ;
998+ assert ! ( value. get( "bucket" ) . is_none( ) ) ;
999+ assert ! ( value. get( "prefix" ) . is_none( ) ) ;
1000+ }
1001+
1002+ #[ test]
1003+ fn test_background_heal_status_response_maps_to_heal_status ( ) {
1004+ let status = HealStatus :: from ( BackgroundHealStatusResponse {
1005+ bitrot_start_time : Some ( "2026-04-19T10:00:00Z" . to_string ( ) ) ,
1006+ } ) ;
1007+
1008+ assert ! ( status. healing) ;
1009+ assert_eq ! ( status. started. as_deref( ) , Some ( "2026-04-19T10:00:00Z" ) ) ;
1010+
1011+ let idle = HealStatus :: from ( BackgroundHealStatusResponse {
1012+ bitrot_start_time : None ,
1013+ } ) ;
1014+ assert ! ( !idle. healing) ;
1015+ assert ! ( idle. started. is_none( ) ) ;
1016+ }
1017+
1018+ #[ test]
1019+ fn test_bad_request_maps_to_general_admin_error ( ) {
1020+ let alias = Alias :: new ( "test" , "http://localhost:9000" , "access" , "secret" ) ;
1021+ let client = AdminClient :: new ( & alias) . expect ( "admin client should build" ) ;
1022+
1023+ let error = client. map_error ( StatusCode :: BAD_REQUEST , "err request body parse" ) ;
1024+ assert ! ( matches!( error, Error :: General ( _) ) ) ;
1025+ assert_eq ! ( error. to_string( ) , "Bad request: err request body parse" ) ;
1026+ }
1027+
8491028 #[ test]
8501029 fn test_admin_client_invalid_ca_bundle_path_surfaces_error ( ) {
8511030 let mut alias = Alias :: new ( "test" , "https://localhost:9000" , "access" , "secret" ) ;
0 commit comments