Skip to main content

fakecloud_ec2/
defaults.rs

1//! Default-VPC bootstrap.
2//!
3//! Every real AWS account has, per region, a *default VPC* (`172.31.0.0/16`)
4//! with an attached internet gateway, a main route table that sends
5//! `0.0.0.0/0` at the gateway, one default subnet per Availability Zone, a
6//! `default` security group, and a default network ACL. Callers that never
7//! touch the VPC APIs (the common case — `RunInstances` with no `SubnetId`)
8//! still expect their instances to land in that default VPC and come back from
9//! `DescribeInstances` with a real `vpc-…` / `subnet-…`.
10//!
11//! fakecloud builds the same fixtures the first time an account's EC2 state is
12//! constructed ([`Ec2State::new`](crate::state::Ec2State::new)). The resource
13//! ids are **deterministic** functions of the account id and a role string
14//! (region-independent — see [`deterministic_id`]), so the throwaway empty
15//! states that the read paths synthesize as a "not found" fallback report the
16//! *same* ids as the persisted account state regardless of the caller's region.
17//!
18//! Per-VPC packet isolation (issue #1745 phase 2+) keys off this topology: a
19//! subnet whose route table has a `0.0.0.0/0 -> igw-…` route is public and gets
20//! a routable backing network; a subnet without one is private (`internal`).
21
22use crate::state::{
23    Ec2State, Image, InternetGateway, NetworkAcl, NetworkAclAssoc, NetworkAclEntry, Route,
24    RouteTable, RouteTableAssociation, SecurityGroup, SecurityGroupRule, Subnet, Vpc,
25};
26
27/// CIDR of the default VPC, matching AWS.
28const DEFAULT_VPC_CIDR: &str = "172.31.0.0/16";
29
30/// The Availability Zone suffixes that receive a default subnet. AWS creates a
31/// default subnet in every AZ; three covers the cardinality every realistic
32/// test exercises (and keeps the deterministic CIDR layout simple).
33const DEFAULT_AZ_SUFFIXES: [&str; 3] = ["a", "b", "c"];
34
35/// Deterministic EC2 resource id: `<prefix>-<17 hex>` derived from the account
36/// and a per-resource `role`. Deliberately **region-independent**: a
37/// `MultiAccountState` partitions by account and pins a single region per
38/// server, but read handlers build a throwaway `Ec2State::new(account,
39/// req.region)` for accounts that don't exist yet — where `req.region` is the
40/// caller's SigV4 scope, not the server's region. Seeding the id on the region
41/// made those throwaway-derived ids disagree with the persisted account's ids
42/// whenever the client region differed from the server's, so a no-subnet launch
43/// stamped the instance with a subnet/VPC id that didn't exist in its own
44/// account (bug-hunt 2026-06-18 finding 1.1). Dropping region from the seed
45/// makes both paths agree; the AZ/CIDR cosmetics below still use the region.
46pub(crate) fn deterministic_id(prefix: &str, account: &str, role: &str) -> String {
47    let seed = format!("{account}/{role}");
48    let h1 = fnv1a64(seed.as_bytes());
49    let h2 = fnv1a64(format!("{seed}/salt").as_bytes());
50    // 16 hex from the first hash + 1 nibble from the second = the 17 hex chars
51    // a modern EC2 long-id carries.
52    format!("{prefix}-{:016x}{:01x}", h1, h2 & 0xf)
53}
54
55/// FNV-1a 64-bit. A tiny, dependency-free, stable hash — we only need
56/// determinism, not cryptographic strength.
57fn fnv1a64(bytes: &[u8]) -> u64 {
58    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
59    for b in bytes {
60        h ^= u64::from(*b);
61        h = h.wrapping_mul(0x0000_0100_0000_01b3);
62    }
63    h
64}
65
66/// AWS-style AZ-id prefix for a region: `us-east-1 -> use1`. Falls back to the
67/// region with dashes stripped for non-`a-b-N` shapes.
68fn az_id_prefix(region: &str) -> String {
69    let parts: Vec<&str> = region.split('-').collect();
70    if parts.len() == 3 && !parts[1].is_empty() {
71        format!(
72            "{}{}{}",
73            parts[0],
74            parts[1].chars().next().unwrap_or('x'),
75            parts[2]
76        )
77    } else {
78        region.replace('-', "")
79    }
80}
81
82/// The default VPC id for an account (also exposed so request handlers can
83/// resolve the implicit default without re-deriving the seed by hand).
84pub(crate) fn default_vpc_id(account: &str) -> String {
85    deterministic_id("vpc", account, "default-vpc")
86}
87
88/// The default security-group id for an account.
89pub(crate) fn default_security_group_id(account: &str) -> String {
90    deterministic_id("sg", account, "default-sg")
91}
92
93/// Populate `state` with the default VPC topology. Called once at state
94/// construction; idempotent in practice because the deterministic ids collide
95/// on re-entry.
96/// Seed a small catalogue of public AMIs the way every real AWS account sees
97/// them, so Terraform's `aws_ami` / `aws_ami_ids` data sources — which resolve
98/// an image via `owners = ["amazon"|"099720109477"|…]` + a `name` wildcard +
99/// `most_recent = true` — return a result instead of empty. Without this,
100/// `DescribeImages` only ever returned user-registered AMIs, so the common
101/// `data "aws_ami" "al2" { most_recent = true; owners = ["amazon"]; filter { … } }`
102/// pattern (and everything that chains off it, e.g. an `aws_instance` or an
103/// ELBv2 target-group attachment) could not be planned.
104///
105/// Ids and `creationDate`s are deterministic and version-stable; the distinct
106/// dates make `most_recent` ordering well-defined. These are public, read-only
107/// fixtures owned by Amazon/Canonical — not user data — and share the
108/// deterministic-id property of the default network so the throwaway empty
109/// states the read paths build report the same catalogue.
110/// One seeded public-AMI row: `(image_id, name, description, architecture,
111/// owner_id, owner_alias, creation_date, root_device_name, platform)`.
112type AmiSeed = (
113    &'static str,
114    &'static str,
115    &'static str,
116    &'static str,
117    &'static str,
118    Option<&'static str>,
119    &'static str,
120    &'static str,
121    Option<&'static str>,
122);
123
124pub(crate) fn seed_public_images(state: &mut Ec2State) {
125    const AMAZON: &str = "137112412989";
126    const CANONICAL: &str = "099720109477";
127    const AMAZON_WINDOWS: &str = "801119661308";
128    let seeds: &[AmiSeed] = &[
129        // Amazon Linux 2 (x86_64 + arm64). The `amzn2-ami-minimal-hvm-*` name +
130        // `root-device-type = ebs` shape is exactly what the standard
131        // terraform-provider-aws acctest helper
132        // `ConfigLatestAmazonLinux2HVMEBSX8664AMI()` / `…ARM64AMI()` filters on,
133        // so every acceptance test that launches an instance via that helper
134        // (aws_instance, the ELBv2 target-group attachment, autoscaling, …)
135        // resolves a real AMI from this catalogue.
136        (
137            "ami-0a1b2c3d4e5f60001",
138            "amzn2-ami-minimal-hvm-2.0.20240306.2-x86_64-ebs",
139            "Amazon Linux 2 AMI 2.0.20240306.2 x86_64 Minimal HVM ebs",
140            "x86_64",
141            AMAZON,
142            Some("amazon"),
143            "2024-03-06T12:00:00.000Z",
144            "/dev/xvda",
145            None,
146        ),
147        (
148            "ami-0a1b2c3d4e5f60007",
149            "amzn2-ami-minimal-hvm-2.0.20240306.2-arm64-ebs",
150            "Amazon Linux 2 AMI 2.0.20240306.2 arm64 Minimal HVM ebs",
151            "arm64",
152            AMAZON,
153            Some("amazon"),
154            "2024-03-06T12:00:00.000Z",
155            "/dev/xvda",
156            None,
157        ),
158        (
159            "ami-0a1b2c3d4e5f60002",
160            "al2023-ami-2023.4.20240319.1-kernel-6.1-x86_64",
161            "Amazon Linux 2023 AMI 2023.4.20240319.1 x86_64 HVM kernel-6.1",
162            "x86_64",
163            AMAZON,
164            Some("amazon"),
165            "2024-03-19T12:00:00.000Z",
166            "/dev/xvda",
167            None,
168        ),
169        (
170            "ami-0a1b2c3d4e5f60003",
171            "al2023-ami-2023.4.20240319.1-kernel-6.1-arm64",
172            "Amazon Linux 2023 AMI 2023.4.20240319.1 arm64 HVM kernel-6.1",
173            "arm64",
174            AMAZON,
175            Some("amazon"),
176            "2024-03-19T12:00:00.000Z",
177            "/dev/xvda",
178            None,
179        ),
180        (
181            "ami-0a1b2c3d4e5f60004",
182            "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-20240319",
183            "Canonical, Ubuntu, 22.04 LTS, amd64 jammy image build on 2024-03-19",
184            "x86_64",
185            CANONICAL,
186            None,
187            "2024-03-19T06:00:00.000Z",
188            "/dev/sda1",
189            None,
190        ),
191        (
192            "ami-0a1b2c3d4e5f60005",
193            "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-20240423",
194            "Canonical, Ubuntu, 24.04 LTS, amd64 noble image build on 2024-04-23",
195            "x86_64",
196            CANONICAL,
197            None,
198            "2024-04-23T06:00:00.000Z",
199            "/dev/sda1",
200            None,
201        ),
202        // Canonical instance-store Ubuntu (the `ubuntu/images/hvm-instance/*`
203        // shape the `aws_ami_ids` acctest filters on, distinct from the EBS
204        // `hvm-ssd*` images above).
205        (
206            "ami-0a1b2c3d4e5f60008",
207            "ubuntu/images/hvm-instance/ubuntu-jammy-22.04-amd64-server-20240319",
208            "Canonical, Ubuntu, 22.04 LTS, amd64 jammy instance-store image build on 2024-03-19",
209            "x86_64",
210            CANONICAL,
211            None,
212            "2024-03-19T05:00:00.000Z",
213            "/dev/sda1",
214            None,
215        ),
216        (
217            "ami-0a1b2c3d4e5f60006",
218            "Windows_Server-2022-English-Full-Base-2024.03.13",
219            "Microsoft Windows Server 2022 Full Locale English AMI provided by Amazon",
220            "x86_64",
221            AMAZON_WINDOWS,
222            Some("amazon"),
223            "2024-03-13T12:00:00.000Z",
224            "/dev/sda1",
225            Some("windows"),
226        ),
227    ];
228    for (id, name, desc, arch, owner, alias, created, root_dev, platform) in seeds {
229        state.images.insert(
230            (*id).to_string(),
231            Image {
232                image_id: (*id).to_string(),
233                name: (*name).to_string(),
234                description: (*desc).to_string(),
235                state: "available".to_string(),
236                architecture: (*arch).to_string(),
237                public: true,
238                source_instance_id: None,
239                in_recycle_bin: false,
240                deprecation_time: None,
241                deregistration_protection: false,
242                launch_permission_users: Vec::new(),
243                launch_permission_groups: vec!["all".to_string()],
244                boot_mode: None,
245                owner_id: Some((*owner).to_string()),
246                owner_alias: alias.map(str::to_string),
247                creation_date: Some((*created).to_string()),
248                root_device_name: Some((*root_dev).to_string()),
249                platform: platform.map(str::to_string),
250            },
251        );
252    }
253}
254
255pub(crate) fn bootstrap_default_network(state: &mut Ec2State) {
256    let account = state.account_id.clone();
257    let region = if state.region.is_empty() {
258        "us-east-1".to_string()
259    } else {
260        state.region.clone()
261    };
262
263    let vpc_id = default_vpc_id(&account);
264    let igw_id = deterministic_id("igw", &account, "default-igw");
265    let rtb_id = deterministic_id("rtb", &account, "default-rtb");
266    let acl_id = deterministic_id("acl", &account, "default-acl");
267    let sg_id = default_security_group_id(&account);
268
269    // --- default VPC ---
270    state.vpcs.insert(
271        vpc_id.clone(),
272        Vpc {
273            vpc_id: vpc_id.clone(),
274            cidr_block: DEFAULT_VPC_CIDR.to_string(),
275            state: "available".to_string(),
276            dhcp_options_id: "default".to_string(),
277            instance_tenancy: "default".to_string(),
278            is_default: true,
279            enable_dns_support: true,
280            enable_dns_hostnames: true,
281            cidr_associations: Vec::new(),
282            ipv6_cidr_block: None,
283        },
284    );
285
286    // --- internet gateway, attached to the default VPC ---
287    state.internet_gateways.insert(
288        igw_id.clone(),
289        InternetGateway {
290            internet_gateway_id: igw_id.clone(),
291            attachments: vec![(vpc_id.clone(), "available".to_string())],
292        },
293    );
294
295    // --- default subnets, one per AZ ---
296    let az_prefix = az_id_prefix(&region);
297    let mut subnet_ids = Vec::new();
298    for (idx, suffix) in DEFAULT_AZ_SUFFIXES.iter().enumerate() {
299        let subnet_id = deterministic_id("subnet", &account, &format!("default-subnet-{suffix}"));
300        let az = format!("{region}{suffix}");
301        state.subnets.insert(
302            subnet_id.clone(),
303            Subnet {
304                subnet_id: subnet_id.clone(),
305                vpc_id: vpc_id.clone(),
306                // /20 blocks carved from 172.31.0.0/16: .0, .16, .32 …
307                cidr_block: format!("172.31.{}.0/20", idx * 16),
308                availability_zone: az,
309                availability_zone_id: format!("{az_prefix}-az{}", idx + 1),
310                state: "available".to_string(),
311                available_ip_address_count: 4091,
312                default_for_az: true,
313                // Default subnets auto-assign public IPs, matching AWS.
314                map_public_ip_on_launch: true,
315                assign_ipv6_address_on_creation: false,
316                map_customer_owned_ip_on_launch: false,
317                enable_dns64: false,
318                private_dns_hostname_type: "ip-name".to_string(),
319                ipv6_cidr_block: None,
320            },
321        );
322        subnet_ids.push(subnet_id);
323    }
324
325    // --- main route table: local + default route at the IGW (public) ---
326    let mut associations = vec![RouteTableAssociation {
327        association_id: deterministic_id("rtbassoc", &account, "default-rtb-main"),
328        route_table_id: rtb_id.clone(),
329        subnet_id: None,
330        gateway_id: None,
331        main: true,
332    }];
333    for sid in &subnet_ids {
334        associations.push(RouteTableAssociation {
335            association_id: deterministic_id("rtbassoc", &account, &format!("default-rtb-{sid}")),
336            route_table_id: rtb_id.clone(),
337            subnet_id: Some(sid.clone()),
338            gateway_id: None,
339            main: false,
340        });
341    }
342    state.route_tables.insert(
343        rtb_id.clone(),
344        RouteTable {
345            route_table_id: rtb_id.clone(),
346            vpc_id: vpc_id.clone(),
347            routes: vec![
348                Route {
349                    destination_cidr_block: Some(DEFAULT_VPC_CIDR.to_string()),
350                    gateway_id: Some("local".to_string()),
351                    ..Default::default()
352                },
353                Route {
354                    destination_cidr_block: Some("0.0.0.0/0".to_string()),
355                    gateway_id: Some(igw_id.clone()),
356                    ..Default::default()
357                },
358            ],
359            associations,
360        },
361    );
362
363    // --- default security group: allow all from self, allow all egress ---
364    state.security_groups.insert(
365        sg_id.clone(),
366        SecurityGroup {
367            group_id: sg_id.clone(),
368            group_name: "default".to_string(),
369            description: "default VPC security group".to_string(),
370            vpc_id: vpc_id.clone(),
371            rules: vec![
372                SecurityGroupRule {
373                    rule_id: deterministic_id("sgr", &account, "default-sg-ingress"),
374                    group_id: sg_id.clone(),
375                    is_egress: false,
376                    ip_protocol: "-1".to_string(),
377                    from_port: -1,
378                    to_port: -1,
379                    cidr_ipv4: None,
380                    cidr_ipv6: None,
381                    prefix_list_id: None,
382                    referenced_group_id: Some(sg_id.clone()),
383                    description: String::new(),
384                },
385                SecurityGroupRule {
386                    rule_id: deterministic_id("sgr", &account, "default-sg-egress"),
387                    group_id: sg_id.clone(),
388                    is_egress: true,
389                    ip_protocol: "-1".to_string(),
390                    from_port: -1,
391                    to_port: -1,
392                    cidr_ipv4: Some("0.0.0.0/0".to_string()),
393                    cidr_ipv6: None,
394                    prefix_list_id: None,
395                    referenced_group_id: None,
396                    description: String::new(),
397                },
398            ],
399        },
400    );
401
402    // --- default network ACL: allow-all, associated with every default subnet ---
403    let nacl_associations = subnet_ids
404        .iter()
405        .map(|sid| NetworkAclAssoc {
406            association_id: deterministic_id("aclassoc", &account, &format!("default-acl-{sid}")),
407            subnet_id: sid.clone(),
408        })
409        .collect();
410    state.network_acls.insert(
411        acl_id.clone(),
412        NetworkAcl {
413            network_acl_id: acl_id.clone(),
414            vpc_id: vpc_id.clone(),
415            is_default: true,
416            entries: vec![
417                allow_all_entry(false),
418                deny_all_entry(false),
419                allow_all_entry(true),
420                deny_all_entry(true),
421            ],
422            associations: nacl_associations,
423        },
424    );
425}
426
427/// Create the implicit resources AWS provisions for every newly-created VPC: a
428/// `default` security group, a default network ACL, and a main route table
429/// (with the `local` route). The `aws_vpc` resource reads back
430/// `default_security_group_id`, `default_network_acl_id`,
431/// `default_route_table_id`, and `main_route_table_id`, all derived from these.
432/// Ids are deterministic functions of the VPC id so read-path fallbacks agree.
433pub(crate) fn create_vpc_default_resources(state: &mut Ec2State, vpc_id: &str, cidr: &str) {
434    let account = state.account_id.clone();
435    let rtb_id = deterministic_id("rtb", &account, &format!("{vpc_id}-main-rtb"));
436    let acl_id = deterministic_id("acl", &account, &format!("{vpc_id}-default-acl"));
437    let sg_id = deterministic_id("sg", &account, &format!("{vpc_id}-default-sg"));
438
439    state
440        .route_tables
441        .entry(rtb_id.clone())
442        .or_insert_with(|| RouteTable {
443            route_table_id: rtb_id.clone(),
444            vpc_id: vpc_id.to_string(),
445            routes: vec![Route {
446                destination_cidr_block: Some(cidr.to_string()),
447                gateway_id: Some("local".to_string()),
448                ..Default::default()
449            }],
450            associations: vec![RouteTableAssociation {
451                association_id: deterministic_id("rtbassoc", &account, &format!("{vpc_id}-main")),
452                route_table_id: rtb_id.clone(),
453                subnet_id: None,
454                gateway_id: None,
455                main: true,
456            }],
457        });
458
459    state
460        .security_groups
461        .entry(sg_id.clone())
462        .or_insert_with(|| SecurityGroup {
463            group_id: sg_id.clone(),
464            group_name: "default".to_string(),
465            description: "default VPC security group".to_string(),
466            vpc_id: vpc_id.to_string(),
467            rules: vec![
468                SecurityGroupRule {
469                    rule_id: deterministic_id("sgr", &account, &format!("{vpc_id}-sg-ingress")),
470                    group_id: sg_id.clone(),
471                    is_egress: false,
472                    ip_protocol: "-1".to_string(),
473                    from_port: -1,
474                    to_port: -1,
475                    cidr_ipv4: None,
476                    cidr_ipv6: None,
477                    prefix_list_id: None,
478                    referenced_group_id: Some(sg_id.clone()),
479                    description: String::new(),
480                },
481                SecurityGroupRule {
482                    rule_id: deterministic_id("sgr", &account, &format!("{vpc_id}-sg-egress")),
483                    group_id: sg_id.clone(),
484                    is_egress: true,
485                    ip_protocol: "-1".to_string(),
486                    from_port: -1,
487                    to_port: -1,
488                    cidr_ipv4: Some("0.0.0.0/0".to_string()),
489                    cidr_ipv6: None,
490                    prefix_list_id: None,
491                    referenced_group_id: None,
492                    description: String::new(),
493                },
494            ],
495        });
496
497    state
498        .network_acls
499        .entry(acl_id.clone())
500        .or_insert_with(|| NetworkAcl {
501            network_acl_id: acl_id.clone(),
502            vpc_id: vpc_id.to_string(),
503            is_default: true,
504            entries: vec![
505                allow_all_entry(false),
506                deny_all_entry(false),
507                allow_all_entry(true),
508                deny_all_entry(true),
509            ],
510            associations: Vec::new(),
511        });
512}
513
514fn allow_all_entry(egress: bool) -> NetworkAclEntry {
515    NetworkAclEntry {
516        rule_number: 100,
517        protocol: "-1".to_string(),
518        rule_action: "allow".to_string(),
519        egress,
520        cidr_block: Some("0.0.0.0/0".to_string()),
521        ipv6_cidr_block: None,
522        port_range: None,
523        icmp_type_code: None,
524    }
525}
526
527fn deny_all_entry(egress: bool) -> NetworkAclEntry {
528    NetworkAclEntry {
529        rule_number: 32767,
530        protocol: "-1".to_string(),
531        rule_action: "deny".to_string(),
532        egress,
533        cidr_block: Some("0.0.0.0/0".to_string()),
534        ipv6_cidr_block: None,
535        port_range: None,
536        icmp_type_code: None,
537    }
538}
539
540/// True when `subnet_id` resolves to a subnet whose route table carries a
541/// `0.0.0.0/0` route at an internet gateway — i.e. a *public* subnet. A subnet
542/// without such a route is private and (phase 2) backs onto an `internal`
543/// network. Subnets default to their VPC's main route table when not
544/// explicitly associated.
545// Drives per-subnet networking (chooses `internal` vs routable backing
546// networks) from `service/mod.rs` and `instance.rs`.
547pub(crate) fn subnet_is_public(state: &Ec2State, subnet_id: &str) -> bool {
548    let Some(subnet) = state.subnets.get(subnet_id) else {
549        return false;
550    };
551    // An explicit association wins; otherwise fall back to the VPC's main table.
552    let explicit = state.route_tables.values().find(|rt| {
553        rt.associations
554            .iter()
555            .any(|a| a.subnet_id.as_deref() == Some(subnet_id))
556    });
557    let main = state
558        .route_tables
559        .values()
560        .find(|rt| rt.vpc_id == subnet.vpc_id && rt.associations.iter().any(|a| a.main));
561    let rt = explicit.or(main);
562    rt.map(route_table_has_igw_default).unwrap_or(false)
563}
564
565fn route_table_has_igw_default(rt: &RouteTable) -> bool {
566    rt.routes.iter().any(|r| {
567        r.destination_cidr_block.as_deref() == Some("0.0.0.0/0")
568            && r.gateway_id
569                .as_deref()
570                .map(|g| g.starts_with("igw-"))
571                .unwrap_or(false)
572    })
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use crate::state::Ec2State;
579
580    #[test]
581    fn deterministic_id_is_stable_and_shaped() {
582        let a = deterministic_id("vpc", "123456789012", "default-vpc");
583        let b = deterministic_id("vpc", "123456789012", "default-vpc");
584        assert_eq!(a, b);
585        assert!(a.starts_with("vpc-"));
586        // 17 hex chars after the prefix, matching EC2 long-ids.
587        let hex = a.strip_prefix("vpc-").unwrap();
588        assert_eq!(hex.len(), 17);
589        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
590    }
591
592    #[test]
593    fn deterministic_id_varies_by_account_and_role() {
594        let base = deterministic_id("vpc", "111111111111", "default-vpc");
595        assert_ne!(base, deterministic_id("vpc", "222222222222", "default-vpc"));
596        assert_ne!(base, deterministic_id("vpc", "111111111111", "default-igw"));
597    }
598
599    #[test]
600    fn deterministic_id_is_region_independent() {
601        // The id seed deliberately excludes region so read-path (req.region)
602        // and persisted (server region) states agree (finding 1.1). Region is
603        // not a parameter anymore — this documents the contract by asserting
604        // default_vpc_id depends only on the account.
605        assert_eq!(
606            default_vpc_id("111111111111"),
607            deterministic_id("vpc", "111111111111", "default-vpc")
608        );
609    }
610
611    #[test]
612    fn az_id_prefix_matches_aws_shape() {
613        assert_eq!(az_id_prefix("us-east-1"), "use1");
614        assert_eq!(az_id_prefix("eu-west-2"), "euw2");
615        assert_eq!(az_id_prefix("ap-southeast-1"), "aps1");
616    }
617
618    #[test]
619    fn bootstrap_creates_full_default_topology() {
620        let state = Ec2State::new("123456789012", "us-east-1");
621        // exactly one VPC, marked default
622        assert_eq!(state.vpcs.len(), 1);
623        let vpc = state.vpcs.values().next().unwrap();
624        assert!(vpc.is_default);
625        assert_eq!(vpc.cidr_block, "172.31.0.0/16");
626        // one subnet per AZ suffix, all default_for_az + public
627        assert_eq!(state.subnets.len(), DEFAULT_AZ_SUFFIXES.len());
628        assert!(state.subnets.values().all(|s| s.default_for_az));
629        assert!(state.subnets.values().all(|s| s.map_public_ip_on_launch));
630        // IGW attached to the default VPC
631        assert_eq!(state.internet_gateways.len(), 1);
632        let igw = state.internet_gateways.values().next().unwrap();
633        assert_eq!(igw.attachments[0].0, vpc.vpc_id);
634        // default SG + default NACL
635        let sg = state.security_groups.values().next().unwrap();
636        assert_eq!(sg.group_name, "default");
637        assert!(state.network_acls.values().next().unwrap().is_default);
638    }
639
640    #[test]
641    fn default_subnets_are_public() {
642        let state = Ec2State::new("123456789012", "us-east-1");
643        for sid in state.subnets.keys() {
644            assert!(
645                subnet_is_public(&state, sid),
646                "subnet {sid} should be public"
647            );
648        }
649    }
650
651    #[test]
652    fn ids_match_across_fresh_states() {
653        // The throwaway "empty" states read paths build must agree with the
654        // persisted account state on the default VPC id.
655        let a = Ec2State::new("123456789012", "us-east-1");
656        let b = Ec2State::new("123456789012", "us-east-1");
657        let a_vpc: Vec<_> = a.vpcs.keys().collect();
658        let b_vpc: Vec<_> = b.vpcs.keys().collect();
659        assert_eq!(a_vpc, b_vpc);
660        assert_eq!(a_vpc[0], &default_vpc_id("123456789012"));
661    }
662
663    #[test]
664    fn default_vpc_id_agrees_across_regions() {
665        // The crux of finding 1.1: a read-path empty built with the caller's
666        // region must derive the SAME default VPC id as the persisted account
667        // state built with the server's region.
668        let read_path = Ec2State::new("123456789012", "eu-west-1");
669        let persisted = Ec2State::new("123456789012", "us-east-1");
670        let read_vpc = read_path.vpcs.keys().next().unwrap();
671        let persisted_vpc = persisted.vpcs.keys().next().unwrap();
672        assert_eq!(read_vpc, persisted_vpc);
673        // subnets too (so a no-subnet launch resolves a subnet that exists in
674        // the persisted account regardless of the caller's region).
675        let read_subnets: std::collections::BTreeSet<_> = read_path.subnets.keys().collect();
676        let persisted_subnets: std::collections::BTreeSet<_> = persisted.subnets.keys().collect();
677        assert_eq!(read_subnets, persisted_subnets);
678    }
679}