11use alloy:: primitives:: U256 ;
22use alloy:: signers:: local:: PrivateKeySigner ;
33use anyhow:: { bail, Context , Result } ;
4+ use chrono:: NaiveTime ;
45use std:: { env, str:: FromStr } ;
56
67#[ cfg( test) ]
@@ -364,11 +365,14 @@ impl FaucetConfig {
364365 bail ! ( "faucet amount must be greater than 0" ) ;
365366 }
366367
367- let cooldown_minutes = args. cooldown_minutes . ok_or_else ( || {
368+ let cooldown_minutes = parse_optional_env ( args. cooldown_minutes . clone ( ) ) . ok_or_else ( || {
368369 anyhow:: anyhow!(
369370 "--atlas.faucet.cooldown-minutes (or FAUCET_COOLDOWN_MINUTES) must be set when faucet is enabled"
370371 )
371372 } ) ?;
373+ let cooldown_minutes = cooldown_minutes
374+ . parse :: < u64 > ( )
375+ . context ( "Invalid --atlas.faucet.cooldown-minutes / FAUCET_COOLDOWN_MINUTES" ) ?;
372376 if cooldown_minutes == 0 {
373377 bail ! ( "faucet cooldown must be greater than 0" ) ;
374378 }
@@ -385,6 +389,72 @@ impl FaucetConfig {
385389 }
386390}
387391
392+ #[ derive( Clone ) ]
393+ pub struct SnapshotConfig {
394+ pub enabled : bool ,
395+ pub time : NaiveTime ,
396+ pub retention : u32 ,
397+ pub dir : String ,
398+ pub database_url : String ,
399+ }
400+
401+ impl std:: fmt:: Debug for SnapshotConfig {
402+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
403+ f. debug_struct ( "SnapshotConfig" )
404+ . field ( "enabled" , & self . enabled )
405+ . field ( "time" , & self . time )
406+ . field ( "retention" , & self . retention )
407+ . field ( "dir" , & self . dir )
408+ . field ( "database_url" , & "[redacted]" )
409+ . finish ( )
410+ }
411+ }
412+
413+ impl SnapshotConfig {
414+ pub fn from_env ( database_url : & str ) -> Result < Self > {
415+ let enabled = env:: var ( "SNAPSHOT_ENABLED" )
416+ . unwrap_or_else ( |_| "false" . to_string ( ) )
417+ . parse :: < bool > ( )
418+ . context ( "Invalid SNAPSHOT_ENABLED" ) ?;
419+
420+ if !enabled {
421+ return Ok ( Self {
422+ enabled,
423+ time : NaiveTime :: from_hms_opt ( 3 , 0 , 0 ) . unwrap ( ) ,
424+ retention : 7 ,
425+ dir : "/snapshots" . to_string ( ) ,
426+ database_url : database_url. to_string ( ) ,
427+ } ) ;
428+ }
429+
430+ let time_str = env:: var ( "SNAPSHOT_TIME" ) . unwrap_or_else ( |_| "03:00" . to_string ( ) ) ;
431+ let time = NaiveTime :: parse_from_str ( & time_str, "%H:%M" )
432+ . context ( "Invalid SNAPSHOT_TIME (expected HH:MM)" ) ?;
433+
434+ let retention = env:: var ( "SNAPSHOT_RETENTION" )
435+ . unwrap_or_else ( |_| "7" . to_string ( ) )
436+ . parse :: < u32 > ( )
437+ . context ( "Invalid SNAPSHOT_RETENTION" ) ?;
438+ if retention == 0 {
439+ bail ! ( "SNAPSHOT_RETENTION must be greater than 0" ) ;
440+ }
441+
442+ let dir = env:: var ( "SNAPSHOT_DIR" ) . unwrap_or_else ( |_| "/snapshots" . to_string ( ) ) ;
443+ let dir = dir. trim ( ) . to_string ( ) ;
444+ if dir. is_empty ( ) {
445+ bail ! ( "SNAPSHOT_DIR must not be empty" ) ;
446+ }
447+
448+ Ok ( Self {
449+ enabled,
450+ time,
451+ retention,
452+ dir,
453+ database_url : database_url. to_string ( ) ,
454+ } )
455+ }
456+ }
457+
388458fn parse_optional_env ( val : Option < String > ) -> Option < String > {
389459 val. map ( |s| s. trim ( ) . to_string ( ) ) . filter ( |s| !s. is_empty ( ) )
390460}
@@ -605,6 +675,26 @@ mod tests_from_run_args {
605675 Some ( "/branding/dark.svg" )
606676 ) ;
607677 }
678+
679+ #[ test]
680+ fn faucet_blank_cooldown_is_treated_as_missing ( ) {
681+ let mut args = minimal_run_args ( ) ;
682+ args. faucet . enabled = true ;
683+ args. faucet . amount = Some ( "0.1" . to_string ( ) ) ;
684+ args. faucet . cooldown_minutes = Some ( " " . to_string ( ) ) ;
685+
686+ unsafe {
687+ env:: set_var (
688+ "FAUCET_PRIVATE_KEY" ,
689+ "0x59c6995e998f97a5a0044966f0945382dbd8c5df5440d8d6d0d0f66f6d7d6a0d" ,
690+ ) ;
691+ }
692+ let err = FaucetConfig :: from_faucet_args ( & args. faucet ) . unwrap_err ( ) ;
693+ assert ! ( err. to_string( ) . contains( "cooldown-minutes" ) ) ;
694+ unsafe {
695+ env:: remove_var ( "FAUCET_PRIVATE_KEY" ) ;
696+ }
697+ }
608698}
609699
610700#[ cfg( test) ]
@@ -895,6 +985,111 @@ mod tests {
895985 ) ;
896986 }
897987
988+ fn clear_snapshot_env ( ) {
989+ env:: remove_var ( "SNAPSHOT_ENABLED" ) ;
990+ env:: remove_var ( "SNAPSHOT_TIME" ) ;
991+ env:: remove_var ( "SNAPSHOT_RETENTION" ) ;
992+ env:: remove_var ( "SNAPSHOT_DIR" ) ;
993+ }
994+
995+ #[ test]
996+ fn snapshot_config_defaults_disabled ( ) {
997+ let _lock = ENV_LOCK . lock ( ) . unwrap ( ) ;
998+ clear_snapshot_env ( ) ;
999+
1000+ let config = SnapshotConfig :: from_env ( "postgres://test@localhost/test" ) . unwrap ( ) ;
1001+ assert ! ( !config. enabled) ;
1002+ assert_eq ! ( config. time, NaiveTime :: from_hms_opt( 3 , 0 , 0 ) . unwrap( ) ) ;
1003+ assert_eq ! ( config. retention, 7 ) ;
1004+ assert_eq ! ( config. dir, "/snapshots" ) ;
1005+ }
1006+
1007+ #[ test]
1008+ fn snapshot_config_parses_valid_time ( ) {
1009+ let _lock = ENV_LOCK . lock ( ) . unwrap ( ) ;
1010+ clear_snapshot_env ( ) ;
1011+ env:: set_var ( "SNAPSHOT_ENABLED" , "true" ) ;
1012+
1013+ for ( input, hour, minute) in [ ( "00:00" , 0 , 0 ) , ( "03:00" , 3 , 0 ) , ( "23:59" , 23 , 59 ) ] {
1014+ env:: set_var ( "SNAPSHOT_TIME" , input) ;
1015+ let config = SnapshotConfig :: from_env ( "postgres://test@localhost/test" ) . unwrap ( ) ;
1016+ assert_eq ! (
1017+ config. time,
1018+ NaiveTime :: from_hms_opt( hour, minute, 0 ) . unwrap( ) ,
1019+ "failed for input {input}"
1020+ ) ;
1021+ }
1022+ clear_snapshot_env ( ) ;
1023+ }
1024+
1025+ #[ test]
1026+ fn snapshot_config_rejects_invalid_time ( ) {
1027+ let _lock = ENV_LOCK . lock ( ) . unwrap ( ) ;
1028+ clear_snapshot_env ( ) ;
1029+ env:: set_var ( "SNAPSHOT_ENABLED" , "true" ) ;
1030+
1031+ for val in [ "25:00" , "abc" , "12:60" ] {
1032+ env:: set_var ( "SNAPSHOT_TIME" , val) ;
1033+ let err = SnapshotConfig :: from_env ( "postgres://test@localhost/test" ) . unwrap_err ( ) ;
1034+ assert ! (
1035+ err. to_string( ) . contains( "Invalid SNAPSHOT_TIME" ) ,
1036+ "expected error for {val}, got: {err}"
1037+ ) ;
1038+ }
1039+ clear_snapshot_env ( ) ;
1040+ }
1041+
1042+ #[ test]
1043+ fn snapshot_config_rejects_zero_retention ( ) {
1044+ let _lock = ENV_LOCK . lock ( ) . unwrap ( ) ;
1045+ clear_snapshot_env ( ) ;
1046+ env:: set_var ( "SNAPSHOT_ENABLED" , "true" ) ;
1047+ env:: set_var ( "SNAPSHOT_RETENTION" , "0" ) ;
1048+
1049+ let err = SnapshotConfig :: from_env ( "postgres://test@localhost/test" ) . unwrap_err ( ) ;
1050+ assert ! ( err. to_string( ) . contains( "must be greater than 0" ) ) ;
1051+ clear_snapshot_env ( ) ;
1052+ }
1053+
1054+ #[ test]
1055+ fn snapshot_config_custom_dir ( ) {
1056+ let _lock = ENV_LOCK . lock ( ) . unwrap ( ) ;
1057+ clear_snapshot_env ( ) ;
1058+ env:: set_var ( "SNAPSHOT_ENABLED" , "true" ) ;
1059+ env:: set_var ( "SNAPSHOT_DIR" , "/data/backups" ) ;
1060+
1061+ let config = SnapshotConfig :: from_env ( "postgres://test@localhost/test" ) . unwrap ( ) ;
1062+ assert_eq ! ( config. dir, "/data/backups" ) ;
1063+ clear_snapshot_env ( ) ;
1064+ }
1065+
1066+ #[ test]
1067+ fn snapshot_config_rejects_empty_dir ( ) {
1068+ let _lock = ENV_LOCK . lock ( ) . unwrap ( ) ;
1069+ clear_snapshot_env ( ) ;
1070+ env:: set_var ( "SNAPSHOT_ENABLED" , "true" ) ;
1071+ env:: set_var ( "SNAPSHOT_DIR" , " " ) ;
1072+
1073+ let err = SnapshotConfig :: from_env ( "postgres://test@localhost/test" ) . unwrap_err ( ) ;
1074+ assert ! ( err. to_string( ) . contains( "SNAPSHOT_DIR must not be empty" ) ) ;
1075+ clear_snapshot_env ( ) ;
1076+ }
1077+
1078+ #[ test]
1079+ fn snapshot_config_debug_redacts_database_url ( ) {
1080+ let config = SnapshotConfig {
1081+ enabled : true ,
1082+ time : NaiveTime :: from_hms_opt ( 3 , 0 , 0 ) . unwrap ( ) ,
1083+ retention : 7 ,
1084+ dir : "/snapshots" . to_string ( ) ,
1085+ database_url : "postgres://atlas:secret@db/atlas" . to_string ( ) ,
1086+ } ;
1087+
1088+ let debug = format ! ( "{config:?}" ) ;
1089+ assert ! ( debug. contains( "[redacted]" ) ) ;
1090+ assert ! ( !debug. contains( "secret" ) ) ;
1091+ }
1092+
8981093 #[ test]
8991094 fn faucet_config_rejects_bad_inputs ( ) {
9001095 let _lock = ENV_LOCK . lock ( ) . unwrap ( ) ;
0 commit comments