@@ -820,6 +820,41 @@ fn transition_allowed(current: i64, new_code: i64) -> bool {
820820 true
821821}
822822
823+ /// Validate a single instance's state transition + stop/termination protection,
824+ /// returning the exact error AWS returns when it is illegal. Shared by the
825+ /// pre-flight read check and the re-check under the write lock so both critical
826+ /// sections enforce identical rules (TOCTOU-safe: a concurrent Terminate/Stop
827+ /// landing between the read and the write must fail this call, not be silently
828+ /// clobbered — bug-hunt 2026-07 finding 4.1).
829+ fn check_transition ( inst : & Instance , id : & str , new_code : i64 ) -> Result < ( ) , AwsServiceError > {
830+ if !transition_allowed ( inst. state_code , new_code) {
831+ return Err ( crate :: service_helpers:: incorrect_instance_state (
832+ id,
833+ & inst. state_name ,
834+ ) ) ;
835+ }
836+ // Termination / stop protection.
837+ if new_code == 48 && inst. disable_api_termination {
838+ return Err ( AwsServiceError :: aws_error (
839+ http:: StatusCode :: BAD_REQUEST ,
840+ "OperationNotPermitted" ,
841+ format ! (
842+ "The instance '{id}' may not be terminated. Modify its 'disableApiTermination' instance attribute and try again."
843+ ) ,
844+ ) ) ;
845+ }
846+ if new_code == 80 && inst. disable_api_stop {
847+ return Err ( AwsServiceError :: aws_error (
848+ http:: StatusCode :: BAD_REQUEST ,
849+ "OperationNotPermitted" ,
850+ format ! (
851+ "The instance '{id}' may not be stopped. Modify its 'disableApiStop' instance attribute and try again."
852+ ) ,
853+ ) ) ;
854+ }
855+ Ok ( ( ) )
856+ }
857+
823858async fn change_state (
824859 svc : & Ec2Service ,
825860 req : & AwsRequest ,
@@ -841,31 +876,7 @@ async fn change_state(
841876 . instances
842877 . get ( id)
843878 . ok_or_else ( || crate :: service_helpers:: instance_not_found ( id) ) ?;
844- if !transition_allowed ( inst. state_code , new_code) {
845- return Err ( crate :: service_helpers:: incorrect_instance_state (
846- id,
847- & inst. state_name ,
848- ) ) ;
849- }
850- // Termination / stop protection.
851- if new_code == 48 && inst. disable_api_termination {
852- return Err ( AwsServiceError :: aws_error (
853- http:: StatusCode :: BAD_REQUEST ,
854- "OperationNotPermitted" ,
855- format ! (
856- "The instance '{id}' may not be terminated. Modify its 'disableApiTermination' instance attribute and try again."
857- ) ,
858- ) ) ;
859- }
860- if new_code == 80 && inst. disable_api_stop {
861- return Err ( AwsServiceError :: aws_error (
862- http:: StatusCode :: BAD_REQUEST ,
863- "OperationNotPermitted" ,
864- format ! (
865- "The instance '{id}' may not be stopped. Modify its 'disableApiStop' instance attribute and try again."
866- ) ,
867- ) ) ;
868- }
879+ check_transition ( inst, id, new_code) ?;
869880 }
870881 }
871882
@@ -882,6 +893,22 @@ async fn change_state(
882893 {
883894 let mut accounts = svc. state . write ( ) ;
884895 let state = accounts. get_or_create ( & req. account_id ) ;
896+ // Re-run existence + transition/protection checks against the freshly
897+ // re-read state INSIDE the write lock before mutating anything. The
898+ // read-phase check above ran under a different (dropped) lock, so a
899+ // concurrent Terminate/Stop could have landed in between; without this
900+ // re-check a StartInstances that passed the read check would overwrite
901+ // a terminated instance's state code, resurrecting it past the boot
902+ // task's terminal guard (bug-hunt 2026-07 finding 4.1). AWS applies the
903+ // whole call atomically, so any id now failing fails the entire call
904+ // with nothing mutated (the guard is still held, so no partial writes).
905+ for id in & ids {
906+ let inst = state
907+ . instances
908+ . get ( id)
909+ . ok_or_else ( || crate :: service_helpers:: instance_not_found ( id) ) ?;
910+ check_transition ( inst, id, new_code) ?;
911+ }
885912 for id in & ids {
886913 let ( prev_code, prev_name) = state
887914 . instances
@@ -2328,4 +2355,111 @@ mod modify_tests {
23282355 assert_eq ! ( inst. state_code, 16 ) ;
23292356 assert_eq ! ( inst. state_name, "running" ) ;
23302357 }
2358+
2359+ fn state_of ( svc : & Ec2Service , id : & str ) -> ( i64 , Option < String > ) {
2360+ let accounts = svc. state . read ( ) ;
2361+ let inst = & accounts. get ( "000000000000" ) . unwrap ( ) . instances [ id] ;
2362+ ( inst. state_code , inst. container_id . clone ( ) )
2363+ }
2364+
2365+ #[ test]
2366+ fn check_transition_enforces_terminal_and_protection ( ) {
2367+ // Direct unit test of the shared re-check helper the write-lock TOCTOU
2368+ // guard relies on (bug-hunt finding 4.1).
2369+ let svc = Ec2Service :: new ( ) ;
2370+ seed_instance ( & svc, "i-1" ) ;
2371+ let mut accounts = svc. state . write ( ) ;
2372+ let state = accounts. get_or_create ( "000000000000" ) ;
2373+ let inst = state. instances . get_mut ( "i-1" ) . unwrap ( ) ;
2374+
2375+ // Running -> start/stop/terminate all legal.
2376+ assert ! ( check_transition( inst, "i-1" , 16 ) . is_ok( ) ) ;
2377+ assert ! ( check_transition( inst, "i-1" , 80 ) . is_ok( ) ) ;
2378+ assert ! ( check_transition( inst, "i-1" , 48 ) . is_ok( ) ) ;
2379+
2380+ // Terminated is terminal: no Start (16) allowed, only re-terminate.
2381+ inst. state_code = 48 ;
2382+ inst. state_name = "terminated" . into ( ) ;
2383+ assert_eq ! (
2384+ check_transition( inst, "i-1" , 16 ) . unwrap_err( ) . code( ) ,
2385+ "IncorrectInstanceState"
2386+ ) ;
2387+ assert ! ( check_transition( inst, "i-1" , 48 ) . is_ok( ) ) ;
2388+
2389+ // Protection flags map to OperationNotPermitted.
2390+ inst. state_code = 16 ;
2391+ inst. state_name = "running" . into ( ) ;
2392+ inst. disable_api_termination = true ;
2393+ assert_eq ! (
2394+ check_transition( inst, "i-1" , 48 ) . unwrap_err( ) . code( ) ,
2395+ "OperationNotPermitted"
2396+ ) ;
2397+ inst. disable_api_termination = false ;
2398+ inst. disable_api_stop = true ;
2399+ assert_eq ! (
2400+ check_transition( inst, "i-1" , 80 ) . unwrap_err( ) . code( ) ,
2401+ "OperationNotPermitted"
2402+ ) ;
2403+ }
2404+
2405+ #[ tokio:: test]
2406+ async fn start_after_terminate_does_not_resurrect ( ) {
2407+ // End-to-end: once terminated, a StartInstances must be rejected rather
2408+ // than overwriting code 48 with pending (the resurrection the write-lock
2409+ // re-check closes). Metadata-only mode has no runtime, so the terminal
2410+ // state is fully determined by the control-plane path.
2411+ let svc = Ec2Service :: new ( ) ;
2412+ seed_instance ( & svc, "i-1" ) ;
2413+
2414+ terminate_instances ( & svc, & req ( "TerminateInstances" , & [ ( "InstanceId.1" , "i-1" ) ] ) )
2415+ . await
2416+ . unwrap ( ) ;
2417+ let ( code, container) = state_of ( & svc, "i-1" ) ;
2418+ assert_eq ! ( code, 48 , "terminate must set code 48" ) ;
2419+ assert_eq ! ( container, None , "terminate must drop the container handle" ) ;
2420+
2421+ let err = start_instances ( & svc, & req ( "StartInstances" , & [ ( "InstanceId.1" , "i-1" ) ] ) )
2422+ . await
2423+ . err ( )
2424+ . expect ( "starting a terminated instance must fail" ) ;
2425+ assert_eq ! ( err. code( ) , "IncorrectInstanceState" ) ;
2426+
2427+ // State is untouched: still terminated, no partial resurrection.
2428+ let ( code, _) = state_of ( & svc, "i-1" ) ;
2429+ assert_eq ! ( code, 48 , "instance must stay terminated" ) ;
2430+ }
2431+
2432+ #[ tokio:: test]
2433+ async fn change_state_is_atomic_on_mixed_ids ( ) {
2434+ // AWS applies the whole call or none: a batch where one id is illegal
2435+ // (terminated) must fail entirely and leave the other id untouched.
2436+ let svc = Ec2Service :: new ( ) ;
2437+ seed_instance ( & svc, "i-ok" ) ;
2438+ seed_instance ( & svc, "i-dead" ) ;
2439+ terminate_instances (
2440+ & svc,
2441+ & req ( "TerminateInstances" , & [ ( "InstanceId.1" , "i-dead" ) ] ) ,
2442+ )
2443+ . await
2444+ . unwrap ( ) ;
2445+
2446+ let err = start_instances (
2447+ & svc,
2448+ & req (
2449+ "StartInstances" ,
2450+ & [ ( "InstanceId.1" , "i-ok" ) , ( "InstanceId.2" , "i-dead" ) ] ,
2451+ ) ,
2452+ )
2453+ . await
2454+ . err ( )
2455+ . expect ( "batch with a terminated id must fail" ) ;
2456+ assert_eq ! ( err. code( ) , "IncorrectInstanceState" ) ;
2457+
2458+ // i-ok must NOT have been flipped to pending by a partial write.
2459+ let ( code, _) = state_of ( & svc, "i-ok" ) ;
2460+ assert_eq ! (
2461+ code, 16 ,
2462+ "healthy instance must be untouched on atomic fail"
2463+ ) ;
2464+ }
23312465}
0 commit comments