Skip to content

Commit 834a041

Browse files
authored
fix(ec2): DescribeVolumes MaxResults guard + change_state TOCTOU + empty-filter-value (bug-hunt) (#2297)
2 parents 369ae6c + 6865aec commit 834a041

3 files changed

Lines changed: 256 additions & 31 deletions

File tree

crates/fakecloud-ec2/src/service/instance.rs

Lines changed: 159 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
823858
async 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
}

crates/fakecloud-ec2/src/service/volume.rs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
77
use crate::service::Ec2Service;
88
use crate::service_helpers::{
99
filter_value_matches, gen_id, indexed_list, not_found, paginate, parse_filters, require,
10-
validate_enum, Filter,
10+
validate_enum, validate_max_results, Filter,
1111
};
1212
use crate::state::{Ec2State, Tag, Volume, VolumeAttachment};
1313

@@ -159,6 +159,7 @@ pub(crate) fn describe_volumes(
159159
svc: &Ec2Service,
160160
req: &AwsRequest,
161161
) -> Result<AwsResponse, AwsServiceError> {
162+
validate_max_results(&req.query_params, 5, 1000)?;
162163
let filters = parse_filters(&req.query_params);
163164
let wanted = indexed_list(&req.query_params, "VolumeId");
164165
let accounts = svc.state.read();
@@ -798,15 +799,43 @@ mod tests {
798799
#[test]
799800
fn describe_volumes_paginates() {
800801
let svc = Ec2Service::new();
801-
for i in 0..3 {
802+
// MaxResults must be within [5, 1000]; seed enough volumes to force a
803+
// second page at the minimum page size.
804+
for i in 0..6 {
802805
seed_volume(&svc, &format!("vol-{i}"), "available", false);
803806
}
804807
let body = body_of(
805-
describe_volumes(&svc, &req("DescribeVolumes", &[("MaxResults", "2")])).unwrap(),
808+
describe_volumes(&svc, &req("DescribeVolumes", &[("MaxResults", "5")])).unwrap(),
806809
);
807810
assert!(body.contains("<nextToken>"), "expected a NextToken: {body}");
808811
}
809812

813+
#[test]
814+
fn describe_volumes_rejects_max_results_zero() {
815+
// MaxResults=0 with >=1 volume previously produced a self-referential
816+
// NextToken=0 that looped forever (bug-hunt finding 1.1). Like its
817+
// sibling paginators, DescribeVolumes must reject out-of-range
818+
// MaxResults with InvalidParameterValue instead.
819+
let svc = Ec2Service::new();
820+
seed_volume(&svc, "vol-1", "available", false);
821+
let err = err_of(describe_volumes(
822+
&svc,
823+
&req("DescribeVolumes", &[("MaxResults", "0")]),
824+
));
825+
assert_eq!(err.code(), "InvalidParameterValue");
826+
}
827+
828+
#[test]
829+
fn describe_volumes_rejects_max_results_above_max() {
830+
let svc = Ec2Service::new();
831+
seed_volume(&svc, "vol-1", "available", false);
832+
let err = err_of(describe_volumes(
833+
&svc,
834+
&req("DescribeVolumes", &[("MaxResults", "1001")]),
835+
));
836+
assert_eq!(err.code(), "InvalidParameterValue");
837+
}
838+
810839
#[test]
811840
fn attach_volume_rejects_nonexistent() {
812841
let svc = Ec2Service::new();

crates/fakecloud-ec2/src/service_helpers.rs

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,11 @@ pub fn validate_length(
255255
/// Collect a 1-based indexed list, e.g. `ResourceId.1`, `ResourceId.2`, ….
256256
///
257257
/// EC2 list members are contiguous from index 1; collection stops at the first
258-
/// missing index. Empty values terminate the list too (matching how the SDKs
259-
/// never emit a gap).
258+
/// missing index. A present-but-empty value terminates the list too — for
259+
/// id-lists (`InstanceId.N`, `GroupId.N`, `VolumeId.N`, …) an empty member is
260+
/// meaningless, and treating it as a terminator matches how the SDKs never emit
261+
/// a gap. Filter *values* differ (an empty value is a legitimate member); use
262+
/// [`indexed_list_keep_empty`] there.
260263
pub fn indexed_list(params: &HashMap<String, String>, prefix: &str) -> Vec<String> {
261264
let mut out = Vec::new();
262265
let mut i = 1usize;
@@ -271,6 +274,25 @@ pub fn indexed_list(params: &HashMap<String, String>, prefix: &str) -> Vec<Strin
271274
out
272275
}
273276

277+
/// Like [`indexed_list`], but a present-but-empty value (`Filter.1.Value.1=`)
278+
/// is preserved as a legitimate empty-string member rather than terminating the
279+
/// list — the terminator is "next index absent", not "value empty". Used for
280+
/// filter values, where filtering for an absent/empty tag value is valid and an
281+
/// empty value must NOT truncate (or self-referentially loop) the list.
282+
pub fn indexed_list_keep_empty(params: &HashMap<String, String>, prefix: &str) -> Vec<String> {
283+
let mut out = Vec::new();
284+
let mut i = 1usize;
285+
loop {
286+
let key = format!("{prefix}.{i}");
287+
match params.get(&key) {
288+
Some(v) => out.push(v.clone()),
289+
None => break,
290+
}
291+
i += 1;
292+
}
293+
out
294+
}
295+
274296
/// Parse `Filter.N.Name` + `Filter.N.Value.M` into [`Filter`] entries.
275297
pub fn parse_filters(params: &HashMap<String, String>) -> Vec<Filter> {
276298
let mut out = Vec::new();
@@ -280,7 +302,7 @@ pub fn parse_filters(params: &HashMap<String, String>) -> Vec<Filter> {
280302
let Some(name) = params.get(&name_key).filter(|v| !v.is_empty()) else {
281303
break;
282304
};
283-
let values = indexed_list(params, &format!("Filter.{i}.Value"));
305+
let values = indexed_list_keep_empty(params, &format!("Filter.{i}.Value"));
284306
out.push(Filter {
285307
name: name.clone(),
286308
values,
@@ -339,6 +361,46 @@ mod tests {
339361
assert_eq!(indexed_list(&params, "ResourceId"), vec!["vpc-1"]);
340362
}
341363

364+
#[test]
365+
fn indexed_list_empty_value_terminates_for_id_lists() {
366+
// Plain id-lists keep the original semantics: a present-but-empty member
367+
// is meaningless and terminates (SDKs never emit an empty id member).
368+
let params = p(&[("InstanceId.1", ""), ("InstanceId.2", "i-2")]);
369+
assert_eq!(indexed_list(&params, "InstanceId"), Vec::<String>::new());
370+
}
371+
372+
#[test]
373+
fn indexed_list_keep_empty_preserves_present_but_empty_value() {
374+
// `Filter.1.Value.1=` (explicit empty string) is a legitimate member,
375+
// not a terminator: it must be kept, and a following contiguous index
376+
// must still be collected rather than truncated at the empty one.
377+
let params = p(&[("Value.1", ""), ("Value.2", "x")]);
378+
assert_eq!(indexed_list_keep_empty(&params, "Value"), vec!["", "x"]);
379+
}
380+
381+
#[test]
382+
fn indexed_list_keep_empty_then_absent_stops() {
383+
// A trailing empty value followed by an absent index still terminates
384+
// (no infinite loop on a genuinely-absent index).
385+
let params = p(&[("Value.1", "")]);
386+
assert_eq!(indexed_list_keep_empty(&params, "Value"), vec![""]);
387+
}
388+
389+
#[test]
390+
fn parse_filters_keeps_empty_filter_value() {
391+
// Filtering for an empty/absent tag value: `Filter.1.Value.1=` must be
392+
// preserved as a single empty-string value, not dropped.
393+
let params = p(&[("Filter.1.Name", "tag:env"), ("Filter.1.Value.1", "")]);
394+
let filters = parse_filters(&params);
395+
assert_eq!(
396+
filters,
397+
vec![Filter {
398+
name: "tag:env".into(),
399+
values: vec!["".into()]
400+
}]
401+
);
402+
}
403+
342404
#[test]
343405
fn parse_filters_groups_name_and_values() {
344406
let params = p(&[

0 commit comments

Comments
 (0)