Skip to main content

fakecloud_efs/
service.rs

1//! Amazon EFS (`elasticfilesystem`) restJson1 dispatch + operation handlers.
2//!
3//! The full 31-operation EFS control plane. Requests are routed to an operation
4//! by HTTP method + `@http` URI path; the single path label each route carries
5//! is captured positionally and query parameters are read from the raw query
6//! string so repeated multi-value keys survive. State is account-partitioned
7//! and persisted; each file system / mount target / access point is stored as
8//! its already-output-valid `Description` object so `Describe` echoes exactly
9//! what `Create`/`Put`/`Update` persisted.
10
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use http::{Method, StatusCode};
15use parking_lot::RwLockWriteGuard;
16use percent_encoding::percent_decode_str;
17use serde_json::{json, Map, Value};
18use tokio::sync::Mutex as AsyncMutex;
19use uuid::Uuid;
20
21use fakecloud_core::multi_account::MultiAccountState;
22use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
23use fakecloud_persistence::SnapshotStore;
24
25use crate::persistence::save_snapshot;
26use crate::state::{EfsData, SharedEfsState};
27
28/// Every operation name in the EFS Smithy model (31 operations).
29pub const EFS_ACTIONS: &[&str] = &[
30    "CreateAccessPoint",
31    "CreateFileSystem",
32    "CreateMountTarget",
33    "CreateReplicationConfiguration",
34    "CreateTags",
35    "DeleteAccessPoint",
36    "DeleteFileSystem",
37    "DeleteFileSystemPolicy",
38    "DeleteMountTarget",
39    "DeleteReplicationConfiguration",
40    "DeleteTags",
41    "DescribeAccessPoints",
42    "DescribeAccountPreferences",
43    "DescribeBackupPolicy",
44    "DescribeFileSystemPolicy",
45    "DescribeFileSystems",
46    "DescribeLifecycleConfiguration",
47    "DescribeMountTargetSecurityGroups",
48    "DescribeMountTargets",
49    "DescribeReplicationConfigurations",
50    "DescribeTags",
51    "ListTagsForResource",
52    "ModifyMountTargetSecurityGroups",
53    "PutAccountPreferences",
54    "PutBackupPolicy",
55    "PutFileSystemPolicy",
56    "PutLifecycleConfiguration",
57    "TagResource",
58    "UntagResource",
59    "UpdateFileSystem",
60    "UpdateFileSystemProtection",
61];
62
63/// Operations that mutate persisted state on success (so a snapshot is taken).
64const MUTATING: &[&str] = &[
65    "CreateAccessPoint",
66    "CreateFileSystem",
67    "CreateMountTarget",
68    "CreateReplicationConfiguration",
69    "CreateTags",
70    "DeleteAccessPoint",
71    "DeleteFileSystem",
72    "DeleteFileSystemPolicy",
73    "DeleteMountTarget",
74    "DeleteReplicationConfiguration",
75    "DeleteTags",
76    "ModifyMountTargetSecurityGroups",
77    "PutAccountPreferences",
78    "PutBackupPolicy",
79    "PutFileSystemPolicy",
80    "PutLifecycleConfiguration",
81    "TagResource",
82    "UntagResource",
83    "UpdateFileSystem",
84    "UpdateFileSystemProtection",
85];
86
87pub struct EfsService {
88    state: SharedEfsState,
89    snapshot_store: Option<Arc<dyn SnapshotStore>>,
90    snapshot_lock: Arc<AsyncMutex<()>>,
91    /// Optional handle to EC2 state so a mount target's Availability Zone, VPC,
92    /// and IP address resolve from the real subnet it references. `None` in
93    /// memory-only contexts (unit tests), where the values are synthesized
94    /// deterministically from the subnet id instead.
95    ec2_state: Option<fakecloud_ec2::SharedEc2State>,
96}
97
98impl EfsService {
99    pub fn new(state: SharedEfsState) -> Self {
100        Self {
101            state,
102            snapshot_store: None,
103            snapshot_lock: Arc::new(AsyncMutex::new(())),
104            ec2_state: None,
105        }
106    }
107
108    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
109        self.snapshot_store = Some(store);
110        self
111    }
112
113    pub fn with_ec2_state(mut self, ec2_state: fakecloud_ec2::SharedEc2State) -> Self {
114        self.ec2_state = Some(ec2_state);
115        self
116    }
117
118    /// A whole-state persist hook the CloudFormation service invokes after a
119    /// stack op that touched EFS, so a CFN-created (or CFN-deleted) file system /
120    /// mount target / access point is written through to disk the same way a
121    /// direct mutating API call would (#1766 phantom-resource class). `None`
122    /// when no snapshot store is configured (memory mode).
123    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
124        let store = self.snapshot_store.clone()?;
125        let state = self.state.clone();
126        let lock = self.snapshot_lock.clone();
127        Some(Arc::new(move || {
128            let state = state.clone();
129            let store = store.clone();
130            let lock = lock.clone();
131            Box::pin(async move {
132                save_snapshot(&state, Some(store), &lock).await;
133            })
134        }))
135    }
136
137    /// Resolve a subnet's `(availability_zone, availability_zone_id, vpc_id)`
138    /// from EC2 state when the subnet is real. Returns `None` when EC2 state is
139    /// not wired or the subnet does not exist, so the caller falls back to
140    /// deterministic synthesis.
141    fn resolve_subnet(&self, account: &str, subnet_id: &str) -> Option<(String, String, String)> {
142        let ec2 = self.ec2_state.as_ref()?;
143        let guard = ec2.read();
144        let subnet = guard.get(account)?.subnets.get(subnet_id)?;
145        Some((
146            subnet.availability_zone.clone(),
147            subnet.availability_zone_id.clone(),
148            subnet.vpc_id.clone(),
149        ))
150    }
151
152    async fn save(&self) {
153        save_snapshot(
154            &self.state,
155            self.snapshot_store.clone(),
156            &self.snapshot_lock,
157        )
158        .await;
159    }
160
161    /// Route a request to an operation name + captured path label by HTTP
162    /// method + `@http` URI path. Returns `None` when no route matches.
163    fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Option<String>)> {
164        let raw = req.raw_path.split('?').next().unwrap_or(&req.raw_path);
165        let trimmed = raw.strip_prefix('/').unwrap_or(raw);
166        // A trailing empty segment is preserved on purpose: EFS's `FileSystemId`
167        // / `ResourceId` / `AccessPointId` path labels have a length minimum of
168        // 0, so an empty label (`.../file-systems/`) is a well-formed request
169        // that must still route to its handler (which then returns the
170        // operation's declared not-found), not a routing miss.
171        let segs: Vec<String> = if trimmed.is_empty() {
172            Vec::new()
173        } else {
174            trimmed
175                .split('/')
176                .map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
177                .collect()
178        };
179        let s: Vec<&str> = segs.iter().map(String::as_str).collect();
180        let m = &req.method;
181        let get = m == Method::GET;
182        let post = m == Method::POST;
183        let put = m == Method::PUT;
184        let del = m == Method::DELETE;
185        const V: &str = "2015-02-01";
186        let (action, label): (&'static str, Option<String>) = match s.as_slice() {
187            // access points
188            [v, "access-points"] if *v == V && post => ("CreateAccessPoint", None),
189            [v, "access-points"] if *v == V && get => ("DescribeAccessPoints", None),
190            [v, "access-points", id] if *v == V && del => {
191                ("DeleteAccessPoint", Some((*id).to_string()))
192            }
193            // mount targets
194            [v, "mount-targets"] if *v == V && post => ("CreateMountTarget", None),
195            [v, "mount-targets"] if *v == V && get => ("DescribeMountTargets", None),
196            [v, "mount-targets", id] if *v == V && del => {
197                ("DeleteMountTarget", Some((*id).to_string()))
198            }
199            [v, "mount-targets", id, "security-groups"] if *v == V && get => {
200                ("DescribeMountTargetSecurityGroups", Some((*id).to_string()))
201            }
202            [v, "mount-targets", id, "security-groups"] if *v == V && put => {
203                ("ModifyMountTargetSecurityGroups", Some((*id).to_string()))
204            }
205            // account preferences
206            [v, "account-preferences"] if *v == V && get => ("DescribeAccountPreferences", None),
207            [v, "account-preferences"] if *v == V && put => ("PutAccountPreferences", None),
208            // file systems: fixed sub-paths before the {FileSystemId} catch-all
209            [v, "file-systems"] if *v == V && post => ("CreateFileSystem", None),
210            [v, "file-systems"] if *v == V && get => ("DescribeFileSystems", None),
211            [v, "file-systems", "replication-configurations"] if *v == V && get => {
212                ("DescribeReplicationConfigurations", None)
213            }
214            [v, "file-systems", id, "replication-configuration"] if *v == V && post => {
215                ("CreateReplicationConfiguration", Some((*id).to_string()))
216            }
217            [v, "file-systems", id, "replication-configuration"] if *v == V && del => {
218                ("DeleteReplicationConfiguration", Some((*id).to_string()))
219            }
220            [v, "file-systems", id, "policy"] if *v == V && get => {
221                ("DescribeFileSystemPolicy", Some((*id).to_string()))
222            }
223            [v, "file-systems", id, "policy"] if *v == V && put => {
224                ("PutFileSystemPolicy", Some((*id).to_string()))
225            }
226            [v, "file-systems", id, "policy"] if *v == V && del => {
227                ("DeleteFileSystemPolicy", Some((*id).to_string()))
228            }
229            [v, "file-systems", id, "backup-policy"] if *v == V && get => {
230                ("DescribeBackupPolicy", Some((*id).to_string()))
231            }
232            [v, "file-systems", id, "backup-policy"] if *v == V && put => {
233                ("PutBackupPolicy", Some((*id).to_string()))
234            }
235            [v, "file-systems", id, "lifecycle-configuration"] if *v == V && get => {
236                ("DescribeLifecycleConfiguration", Some((*id).to_string()))
237            }
238            [v, "file-systems", id, "lifecycle-configuration"] if *v == V && put => {
239                ("PutLifecycleConfiguration", Some((*id).to_string()))
240            }
241            [v, "file-systems", id, "protection"] if *v == V && put => {
242                ("UpdateFileSystemProtection", Some((*id).to_string()))
243            }
244            [v, "file-systems", id] if *v == V && del => {
245                ("DeleteFileSystem", Some((*id).to_string()))
246            }
247            [v, "file-systems", id] if *v == V && put => {
248                ("UpdateFileSystem", Some((*id).to_string()))
249            }
250            // tags (deprecated per-file-system API)
251            [v, "create-tags", id] if *v == V && post => ("CreateTags", Some((*id).to_string())),
252            [v, "delete-tags", id] if *v == V && post => ("DeleteTags", Some((*id).to_string())),
253            [v, "tags", id] if *v == V && get => ("DescribeTags", Some((*id).to_string())),
254            // resource tagging API
255            [v, "resource-tags", id] if *v == V && get => {
256                ("ListTagsForResource", Some((*id).to_string()))
257            }
258            [v, "resource-tags", id] if *v == V && post => ("TagResource", Some((*id).to_string())),
259            [v, "resource-tags", id] if *v == V && del => {
260                ("UntagResource", Some((*id).to_string()))
261            }
262            _ => return None,
263        };
264        Some((action, label))
265    }
266}
267
268#[async_trait]
269impl AwsService for EfsService {
270    fn service_name(&self) -> &str {
271        "elasticfilesystem"
272    }
273
274    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
275        let Some((action, label)) = Self::resolve_action(&req) else {
276            return Err(AwsServiceError::aws_error(
277                StatusCode::NOT_FOUND,
278                "UnknownOperationException",
279                format!("Unknown operation: {} {}", req.method, req.raw_path),
280            ));
281        };
282        let result = self.dispatch(action, label, &req);
283        if MUTATING.contains(&action)
284            && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
285        {
286            self.save().await;
287        }
288        result
289    }
290
291    fn supported_actions(&self) -> &[&str] {
292        EFS_ACTIONS
293    }
294}
295
296/// Per-request account + region context.
297struct Ctx {
298    account: String,
299    region: String,
300}
301
302impl EfsService {
303    #[allow(clippy::too_many_lines)]
304    fn dispatch(
305        &self,
306        action: &str,
307        label: Option<String>,
308        req: &AwsRequest,
309    ) -> Result<AwsResponse, AwsServiceError> {
310        let body = parse_body(req)?;
311        crate::validate::validate_input(action, &body)?;
312        let ctx = Ctx {
313            account: req.account_id.clone(),
314            region: req.region.clone(),
315        };
316        let q = parse_query(&req.raw_query);
317        crate::validate::validate_query(&q)?;
318        let label = label.unwrap_or_default();
319        match action {
320            "CreateFileSystem" => self.create_file_system(&ctx, &body),
321            "DescribeFileSystems" => self.describe_file_systems(&ctx, &q),
322            "DeleteFileSystem" => self.delete_file_system(&ctx, &label),
323            "UpdateFileSystem" => self.update_file_system(&ctx, &label, &body),
324            "UpdateFileSystemProtection" => self.update_file_system_protection(&ctx, &label, &body),
325            "CreateMountTarget" => self.create_mount_target(&ctx, &body),
326            "DescribeMountTargets" => self.describe_mount_targets(&ctx, &q),
327            "DeleteMountTarget" => self.delete_mount_target(&ctx, &label),
328            "DescribeMountTargetSecurityGroups" => self.describe_mt_security_groups(&ctx, &label),
329            "ModifyMountTargetSecurityGroups" => {
330                self.modify_mt_security_groups(&ctx, &label, &body)
331            }
332            "CreateAccessPoint" => self.create_access_point(&ctx, &body),
333            "DescribeAccessPoints" => self.describe_access_points(&ctx, &q),
334            "DeleteAccessPoint" => self.delete_access_point(&ctx, &label),
335            "PutLifecycleConfiguration" => self.put_lifecycle_configuration(&ctx, &label, &body),
336            "DescribeLifecycleConfiguration" => self.describe_lifecycle_configuration(&ctx, &label),
337            "PutBackupPolicy" => self.put_backup_policy(&ctx, &label, &body),
338            "DescribeBackupPolicy" => self.describe_backup_policy(&ctx, &label),
339            "PutFileSystemPolicy" => self.put_file_system_policy(&ctx, &label, &body),
340            "DescribeFileSystemPolicy" => self.describe_file_system_policy(&ctx, &label),
341            "DeleteFileSystemPolicy" => self.delete_file_system_policy(&ctx, &label),
342            "CreateReplicationConfiguration" => {
343                self.create_replication_configuration(&ctx, &label, &body)
344            }
345            "DescribeReplicationConfigurations" => {
346                self.describe_replication_configurations(&ctx, &q)
347            }
348            "DeleteReplicationConfiguration" => self.delete_replication_configuration(&ctx, &label),
349            "CreateTags" => self.create_tags(&ctx, &label, &body),
350            "DeleteTags" => self.delete_tags(&ctx, &label, &body),
351            "DescribeTags" => self.describe_tags(&ctx, &label, &q),
352            "TagResource" => self.tag_resource(&ctx, &label, &body),
353            "UntagResource" => self.untag_resource(&ctx, &label, &q),
354            "ListTagsForResource" => self.list_tags_for_resource(&ctx, &label, &q),
355            "DescribeAccountPreferences" => self.describe_account_preferences(&ctx),
356            "PutAccountPreferences" => self.put_account_preferences(&ctx, &body),
357            _ => Err(AwsServiceError::action_not_implemented(
358                "elasticfilesystem",
359                action,
360            )),
361        }
362    }
363}
364
365// ===================== helpers =====================
366
367fn ok(status: StatusCode, v: Value) -> Result<AwsResponse, AwsServiceError> {
368    Ok(AwsResponse::json_value(status, v))
369}
370
371fn empty(status: StatusCode) -> Result<AwsResponse, AwsServiceError> {
372    Ok(AwsResponse::json_value(status, json!({})))
373}
374
375fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
376    if req.body.is_empty() {
377        return Ok(json!({}));
378    }
379    serde_json::from_slice(&req.body)
380        .map_err(|e| bad_request(&format!("Request body is malformed: {e}")))
381}
382
383fn bad_request(msg: &str) -> AwsServiceError {
384    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequest", msg)
385}
386
387fn fs_not_found(id: &str) -> AwsServiceError {
388    AwsServiceError::aws_error(
389        StatusCode::NOT_FOUND,
390        "FileSystemNotFound",
391        format!("File system '{id}' does not exist."),
392    )
393}
394
395fn mt_not_found(id: &str) -> AwsServiceError {
396    AwsServiceError::aws_error(
397        StatusCode::NOT_FOUND,
398        "MountTargetNotFound",
399        format!("Mount target '{id}' does not exist."),
400    )
401}
402
403fn ap_not_found(id: &str) -> AwsServiceError {
404    AwsServiceError::aws_error(
405        StatusCode::NOT_FOUND,
406        "AccessPointNotFound",
407        format!("Access point '{id}' does not exist."),
408    )
409}
410
411fn subnet_not_found(id: &str) -> AwsServiceError {
412    AwsServiceError::aws_error(
413        StatusCode::BAD_REQUEST,
414        "SubnetNotFound",
415        format!("The subnet ID '{id}' is invalid or does not exist."),
416    )
417}
418
419fn replication_not_found(id: &str) -> AwsServiceError {
420    AwsServiceError::aws_error(
421        StatusCode::NOT_FOUND,
422        "ReplicationNotFound",
423        format!("Replication configuration for file system '{id}' does not exist."),
424    )
425}
426
427fn incorrect_fs_lifecycle_state(id: &str, state: &str) -> AwsServiceError {
428    AwsServiceError::aws_error(
429        StatusCode::CONFLICT,
430        "IncorrectFileSystemLifeCycleState",
431        format!(
432            "File system '{id}' is in life cycle state '{state}' but must be 'available' for this operation."
433        ),
434    )
435}
436
437fn conflict(code: &str, msg: &str) -> AwsServiceError {
438    AwsServiceError::aws_error(StatusCode::CONFLICT, code, msg)
439}
440
441/// 17 lowercase hex characters, matching EFS's resource-id suffix form.
442fn hex17() -> String {
443    Uuid::new_v4()
444        .simple()
445        .to_string()
446        .chars()
447        .filter(char::is_ascii_hexdigit)
448        .take(17)
449        .collect()
450}
451
452fn now_ts() -> f64 {
453    chrono::Utc::now().timestamp() as f64
454}
455
456fn fs_arn(ctx: &Ctx, fsid: &str) -> String {
457    format!(
458        "arn:aws:elasticfilesystem:{}:{}:file-system/{}",
459        ctx.region, ctx.account, fsid
460    )
461}
462
463/// Build the FileSystem record for a replication destination EFS provisions on
464/// the caller's behalf, so DescribeFileSystems on the destination id resolves.
465/// Destinations are always encrypted and have replication-overwrite protection
466/// DISABLED (which is how a destination file system is distinguished).
467fn destination_file_system(ctx: &Ctx, fsid: &str, region: &str, az: Option<&str>) -> Value {
468    let mut fs = Map::new();
469    fs.insert("OwnerId".into(), json!(ctx.account));
470    fs.insert("FileSystemId".into(), json!(fsid));
471    fs.insert(
472        "FileSystemArn".into(),
473        json!(format!(
474            "arn:aws:elasticfilesystem:{}:{}:file-system/{}",
475            region, ctx.account, fsid
476        )),
477    );
478    fs.insert("CreationTime".into(), json!(now_ts()));
479    // Transient state; reconcile_lifecycle settles it to `available` on describe.
480    fs.insert("LifeCycleState".into(), json!("creating"));
481    fs.insert("NumberOfMountTargets".into(), json!(0));
482    fs.insert(
483        "SizeInBytes".into(),
484        json!({
485            "Value": 6144,
486            "Timestamp": now_ts(),
487            "ValueInIA": 0,
488            "ValueInStandard": 6144,
489            "ValueInArchive": 0
490        }),
491    );
492    fs.insert("PerformanceMode".into(), json!("generalPurpose"));
493    fs.insert("ThroughputMode".into(), json!("bursting"));
494    fs.insert("Encrypted".into(), json!(true));
495    fs.insert("Tags".into(), json!([]));
496    fs.insert(
497        "FileSystemProtection".into(),
498        json!({ "ReplicationOverwriteProtection": "DISABLED" }),
499    );
500    if let Some(az) = az {
501        fs.insert("AvailabilityZoneName".into(), json!(az));
502        fs.insert("AvailabilityZoneId".into(), json!(format!("{region}-az1")));
503    }
504    Value::Object(fs)
505}
506
507fn ap_arn(ctx: &Ctx, apid: &str) -> String {
508    format!(
509        "arn:aws:elasticfilesystem:{}:{}:access-point/{}",
510        ctx.region, ctx.account, apid
511    )
512}
513
514/// Normalize a `FileSystemId` label/field that may arrive as a bare `fs-...`
515/// id or as a full ARN (`.../file-system/fs-...`) into the bare id.
516fn normalize_fs_id(raw: &str) -> String {
517    raw.rsplit('/').next().unwrap_or(raw).to_string()
518}
519
520/// FNV-1a hash for deterministic synthesis of AZ/VPC/IP from a subnet id.
521fn hash_str(s: &str) -> u64 {
522    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
523    for b in s.as_bytes() {
524        h ^= *b as u64;
525        h = h.wrapping_mul(0x0000_0100_0000_01b3);
526    }
527    h
528}
529
530fn parse_query(raw: &str) -> Vec<(String, String)> {
531    raw.split('&')
532        .filter(|p| !p.is_empty())
533        .map(|pair| {
534            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
535            (
536                percent_decode_str(k).decode_utf8_lossy().into_owned(),
537                percent_decode_str(v).decode_utf8_lossy().into_owned(),
538            )
539        })
540        .collect()
541}
542
543fn query_one<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
544    q.iter()
545        .find(|(k, _)| k == key)
546        .map(|(_, v)| v.as_str())
547        .filter(|v| !v.is_empty())
548}
549
550fn query_all(q: &[(String, String)], key: &str) -> Vec<String> {
551    q.iter()
552        .filter(|(k, _)| k == key)
553        .map(|(_, v)| v.clone())
554        .collect()
555}
556
557/// Collect a resource's `Tags` input list into a flat key/value map.
558fn tags_to_map(b: &Value) -> Map<String, Value> {
559    let mut out = Map::new();
560    if let Some(arr) = b.get("Tags").and_then(Value::as_array) {
561        for t in arr {
562            if let (Some(k), Some(v)) = (
563                t.get("Key").and_then(Value::as_str),
564                t.get("Value").and_then(Value::as_str),
565            ) {
566                out.insert(k.to_string(), json!(v));
567            }
568        }
569    }
570    out
571}
572
573/// Render a resource's stored tag map into the `[{Key,Value}]` list shape.
574fn tags_list(data: &EfsData, resource_id: &str) -> Value {
575    let arr: Vec<Value> = data
576        .tags
577        .get(resource_id)
578        .map(|m| {
579            m.iter()
580                .map(|(k, v)| json!({ "Key": k, "Value": v }))
581                .collect()
582        })
583        .unwrap_or_default();
584    Value::Array(arr)
585}
586
587impl EfsService {
588    fn account<'g>(
589        &self,
590        guard: &'g mut RwLockWriteGuard<'_, MultiAccountState<EfsData>>,
591        ctx: &Ctx,
592    ) -> &'g mut EfsData {
593        guard.get_or_create(&ctx.account)
594    }
595}
596
597// ===================== file systems =====================
598
599impl EfsService {
600    fn create_file_system(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
601        let creation_token = b
602            .get("CreationToken")
603            .and_then(Value::as_str)
604            .unwrap_or_default()
605            .to_string();
606        let mut guard = self.state.write();
607        let data = guard.get_or_create(&ctx.account);
608        // CreationToken is EFS's idempotency token: a second create with the
609        // same token collides.
610        if let Some((existing_id, _)) = data.file_systems.iter().find(|(_, fs)| {
611            fs.get("CreationToken").and_then(Value::as_str) == Some(creation_token.as_str())
612        }) {
613            let existing_id = existing_id.clone();
614            return Err(AwsServiceError::aws_error_with_fields(
615                StatusCode::CONFLICT,
616                "FileSystemAlreadyExists",
617                format!("File system already exists with creation token {creation_token}"),
618                vec![("FileSystemId".to_string(), existing_id)],
619            ));
620        }
621
622        let fsid = format!("fs-{}", hex17());
623        let performance_mode = b
624            .get("PerformanceMode")
625            .and_then(Value::as_str)
626            .unwrap_or("generalPurpose");
627        let throughput_mode = b
628            .get("ThroughputMode")
629            .and_then(Value::as_str)
630            .unwrap_or("bursting");
631        // ThroughputMode=provisioned requires a ProvisionedThroughputInMibps
632        // (min 1 MiBps); every other mode must not carry one. Real EFS rejects
633        // both cases with BadRequest.
634        if throughput_mode == "provisioned" {
635            match b
636                .get("ProvisionedThroughputInMibps")
637                .and_then(Value::as_f64)
638            {
639                None => {
640                    return Err(bad_request(
641                        "ProvisionedThroughputInMibps is required when ThroughputMode is set to provisioned.",
642                    ));
643                }
644                Some(v) if v < 1.0 => {
645                    return Err(bad_request(
646                        "Value at 'ProvisionedThroughputInMibps' failed to satisfy constraint: Member must have value greater than or equal to 1",
647                    ));
648                }
649                Some(_) => {}
650            }
651        } else if b.get("ProvisionedThroughputInMibps").is_some() {
652            return Err(bad_request(
653                "ProvisionedThroughputInMibps is only applicable when ThroughputMode is set to provisioned.",
654            ));
655        }
656        let encrypted = b.get("Encrypted").and_then(Value::as_bool).unwrap_or(false);
657
658        let mut fs = Map::new();
659        fs.insert("OwnerId".into(), json!(ctx.account));
660        fs.insert("CreationToken".into(), json!(creation_token));
661        fs.insert("FileSystemId".into(), json!(fsid));
662        fs.insert("FileSystemArn".into(), json!(fs_arn(ctx, &fsid)));
663        fs.insert("CreationTime".into(), json!(now_ts()));
664        // Return the transient state; settles to `available` on the next
665        // describe (and on restart).
666        fs.insert("LifeCycleState".into(), json!("creating"));
667        fs.insert("NumberOfMountTargets".into(), json!(0));
668        fs.insert(
669            "SizeInBytes".into(),
670            json!({
671                "Value": 6144,
672                "Timestamp": now_ts(),
673                "ValueInIA": 0,
674                "ValueInStandard": 6144,
675                "ValueInArchive": 0
676            }),
677        );
678        fs.insert("PerformanceMode".into(), json!(performance_mode));
679        fs.insert("ThroughputMode".into(), json!(throughput_mode));
680        fs.insert("Encrypted".into(), json!(encrypted));
681        if encrypted {
682            let kms = b
683                .get("KmsKeyId")
684                .and_then(Value::as_str)
685                .map(str::to_string)
686                .unwrap_or_else(|| {
687                    format!(
688                        "arn:aws:kms:{}:{}:key/{}-{}-{}-{}-{}",
689                        ctx.region,
690                        ctx.account,
691                        &hex17()[..8],
692                        &hex17()[..4],
693                        &hex17()[..4],
694                        &hex17()[..4],
695                        &hex17()[..12.min(hex17().len())]
696                    )
697                });
698            fs.insert("KmsKeyId".into(), json!(kms));
699        }
700        if throughput_mode == "provisioned" {
701            if let Some(p) = b.get("ProvisionedThroughputInMibps") {
702                fs.insert("ProvisionedThroughputInMibps".into(), p.clone());
703            }
704        }
705        if let Some(az) = b.get("AvailabilityZoneName").and_then(Value::as_str) {
706            fs.insert("AvailabilityZoneName".into(), json!(az));
707            fs.insert(
708                "AvailabilityZoneId".into(),
709                json!(format!("{}-az1", ctx.region)),
710            );
711        }
712        // Tags: store, and surface Name from a `Name` tag.
713        let tag_map = tags_to_map(b);
714        if let Some(name) = tag_map.get("Name").and_then(Value::as_str) {
715            fs.insert("Name".into(), json!(name));
716        }
717        fs.insert("Tags".into(), tags_list_from_map(&tag_map));
718        fs.insert(
719            "FileSystemProtection".into(),
720            json!({ "ReplicationOverwriteProtection": "ENABLED" }),
721        );
722
723        if !tag_map.is_empty() {
724            let entry = data.tags.entry(fsid.clone()).or_default();
725            for (k, v) in &tag_map {
726                if let Some(vs) = v.as_str() {
727                    entry.insert(k.clone(), vs.to_string());
728                }
729            }
730        }
731        data.file_systems
732            .insert(fsid.clone(), Value::Object(fs.clone()));
733        ok(StatusCode::CREATED, Value::Object(fs))
734    }
735
736    fn describe_file_systems(
737        &self,
738        ctx: &Ctx,
739        q: &[(String, String)],
740    ) -> Result<AwsResponse, AwsServiceError> {
741        let filter_id = query_one(q, "FileSystemId").map(normalize_fs_id);
742        let filter_token = query_one(q, "CreationToken");
743        let mut guard = self.state.write();
744        let data = self.account(&mut guard, ctx);
745        data.reconcile_lifecycle();
746
747        if let Some(id) = &filter_id {
748            if !data.file_systems.contains_key(id) {
749                return Err(fs_not_found(id));
750            }
751        }
752        let rows: Vec<Value> = data
753            .file_systems
754            .values()
755            .filter(|fs| {
756                filter_id
757                    .as_ref()
758                    .map(|id| fs.get("FileSystemId").and_then(Value::as_str) == Some(id.as_str()))
759                    .unwrap_or(true)
760            })
761            .filter(|fs| {
762                filter_token
763                    .map(|t| fs.get("CreationToken").and_then(Value::as_str) == Some(t))
764                    .unwrap_or(true)
765            })
766            .map(|fs| with_live_mount_count(fs, data))
767            .collect();
768
769        // DescribeFileSystems: MaxItems default is 100.
770        let (page, next) =
771            paginate_marker(rows, query_one(q, "Marker"), query_one(q, "MaxItems"), 100);
772        let mut out = Map::new();
773        out.insert("FileSystems".into(), Value::Array(page));
774        if let Some(n) = next {
775            out.insert("NextMarker".into(), json!(n));
776        }
777        ok(StatusCode::OK, Value::Object(out))
778    }
779
780    fn require_fs<'a>(
781        &self,
782        data: &'a mut EfsData,
783        fsid: &str,
784    ) -> Result<&'a mut Value, AwsServiceError> {
785        data.file_systems
786            .get_mut(fsid)
787            .ok_or_else(|| fs_not_found(fsid))
788    }
789
790    fn delete_file_system(&self, ctx: &Ctx, label: &str) -> Result<AwsResponse, AwsServiceError> {
791        let fsid = normalize_fs_id(label);
792        let mut guard = self.state.write();
793        let data = self.account(&mut guard, ctx);
794        if !data.file_systems.contains_key(&fsid) {
795            return Err(fs_not_found(&fsid));
796        }
797        // Real EFS returns FileSystemInUse while the file system still has any
798        // mount target OR any access point; it can only be deleted once both
799        // are gone.
800        let has_mt = data
801            .mount_targets
802            .values()
803            .any(|mt| mt.get("FileSystemId").and_then(Value::as_str) == Some(fsid.as_str()));
804        let has_ap = data.access_points.values().any(|ap| {
805            normalize_fs_id(ap.get("FileSystemId").and_then(Value::as_str).unwrap_or("")) == fsid
806        });
807        if has_mt || has_ap {
808            return Err(conflict(
809                "FileSystemInUse",
810                &format!("File system '{fsid}' is in use and cannot be deleted."),
811            ));
812        }
813        data.file_systems.remove(&fsid);
814        data.tags.remove(&fsid);
815        data.lifecycle_configs.remove(&fsid);
816        data.backup_policies.remove(&fsid);
817        data.file_system_policies.remove(&fsid);
818        data.replications.remove(&fsid);
819        empty(StatusCode::NO_CONTENT)
820    }
821
822    fn update_file_system(
823        &self,
824        ctx: &Ctx,
825        label: &str,
826        b: &Value,
827    ) -> Result<AwsResponse, AwsServiceError> {
828        let fsid = normalize_fs_id(label);
829        let mut guard = self.state.write();
830        let data = self.account(&mut guard, ctx);
831        let fs = self.require_fs(data, &fsid)?;
832        let obj = fs.as_object_mut().unwrap();
833        if obj.get("LifeCycleState").and_then(Value::as_str) == Some("creating") {
834            obj.insert("LifeCycleState".into(), json!("available"));
835        }
836        if let Some(tm) = b.get("ThroughputMode") {
837            obj.insert("ThroughputMode".into(), tm.clone());
838        }
839        if let Some(p) = b.get("ProvisionedThroughputInMibps") {
840            obj.insert("ProvisionedThroughputInMibps".into(), p.clone());
841        }
842        // `provisioned` -> non-provisioned drops the throughput figure.
843        if obj.get("ThroughputMode").and_then(Value::as_str) != Some("provisioned") {
844            obj.remove("ProvisionedThroughputInMibps");
845        }
846        let fs_snapshot = fs.clone();
847        let updated = with_live_mount_count(&fs_snapshot, data);
848        ok(StatusCode::ACCEPTED, updated)
849    }
850
851    fn update_file_system_protection(
852        &self,
853        ctx: &Ctx,
854        label: &str,
855        b: &Value,
856    ) -> Result<AwsResponse, AwsServiceError> {
857        let fsid = normalize_fs_id(label);
858        let mut guard = self.state.write();
859        let data = self.account(&mut guard, ctx);
860        let fs = self.require_fs(data, &fsid)?;
861        let protection = b
862            .get("ReplicationOverwriteProtection")
863            .and_then(Value::as_str)
864            .unwrap_or("ENABLED");
865        fs.as_object_mut().unwrap().insert(
866            "FileSystemProtection".into(),
867            json!({ "ReplicationOverwriteProtection": protection }),
868        );
869        ok(
870            StatusCode::OK,
871            json!({ "ReplicationOverwriteProtection": protection }),
872        )
873    }
874}
875
876fn tags_list_from_map(m: &Map<String, Value>) -> Value {
877    Value::Array(
878        m.iter()
879            .map(|(k, v)| json!({ "Key": k, "Value": v }))
880            .collect(),
881    )
882}
883
884/// Clone a file-system description with `NumberOfMountTargets` recomputed from
885/// the live mount-target set.
886fn with_live_mount_count(fs: &Value, data: &EfsData) -> Value {
887    let mut obj = fs.as_object().cloned().unwrap_or_default();
888    let fsid = obj
889        .get("FileSystemId")
890        .and_then(Value::as_str)
891        .unwrap_or("");
892    let count = data
893        .mount_targets
894        .values()
895        .filter(|mt| mt.get("FileSystemId").and_then(Value::as_str) == Some(fsid))
896        .count();
897    obj.insert("NumberOfMountTargets".into(), json!(count));
898    Value::Object(obj)
899}
900
901/// Opaque start-index pagination over EFS's `Marker` / `MaxItems` window.
902/// `default_max` is the AWS-documented page size for the calling operation
903/// (100 for `DescribeFileSystems`/`DescribeTags`, 10 for `DescribeMountTargets`)
904/// used when the caller omits `MaxItems`.
905fn paginate_marker(
906    rows: Vec<Value>,
907    marker: Option<&str>,
908    max: Option<&str>,
909    default_max: usize,
910) -> (Vec<Value>, Option<String>) {
911    let start = marker.and_then(|m| m.parse::<usize>().ok()).unwrap_or(0);
912    let max = max
913        .and_then(|m| m.parse::<usize>().ok())
914        .map(|m| m.max(1))
915        .unwrap_or(default_max);
916    let end = start.saturating_add(max).min(rows.len());
917    let page = rows.get(start..end).unwrap_or(&[]).to_vec();
918    let next = if end < rows.len() {
919        Some(end.to_string())
920    } else {
921        None
922    };
923    (page, next)
924}
925
926// ===================== mount targets =====================
927
928impl EfsService {
929    fn create_mount_target(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
930        let fsid = normalize_fs_id(b.get("FileSystemId").and_then(Value::as_str).unwrap_or(""));
931        let subnet_id = b
932            .get("SubnetId")
933            .and_then(Value::as_str)
934            .unwrap_or("")
935            .to_string();
936        // Resolve the subnet's real Availability Zone / VPC from EC2 state when
937        // available (so a mount target on a Terraform-created subnet reports the
938        // true AZ and the one-per-AZ rule is exact); otherwise synthesize
939        // deterministically from the subnet id.
940        let h = hash_str(&subnet_id);
941        let (az_name, az_id, vpc_id) = match self.resolve_subnet(&ctx.account, &subnet_id) {
942            Some((az, azid, vpc)) => (az, azid, vpc),
943            None => {
944                // EC2 state wired but the subnet did not resolve -> the subnet
945                // genuinely does not exist, which real EFS rejects with
946                // `SubnetNotFound`. Only synthesize an AZ/VPC/IP when there is
947                // no EC2 subsystem to validate the subnet against at all.
948                if self.ec2_state.is_some() {
949                    return Err(subnet_not_found(&subnet_id));
950                }
951                let az_index = (h % 3) as u8;
952                (
953                    format!("{}{}", ctx.region, (b'a' + az_index) as char),
954                    format!("{}-az{}", ctx.region, az_index + 1),
955                    format!("vpc-{:017x}", h & 0x000f_ffff_ffff_ffff),
956                )
957            }
958        };
959        let mut guard = self.state.write();
960        let data = self.account(&mut guard, ctx);
961        // The file system must exist and be `available`. A file system settles
962        // `creating` -> `available` on its first describe (the same reconcile
963        // the create waiter drives), so a normally-provisioned file system is
964        // available by the time a mount target is created; a file system still
965        // in any non-`available` state is rejected, exactly as real EFS does.
966        let fs = data
967            .file_systems
968            .get(&fsid)
969            .ok_or_else(|| fs_not_found(&fsid))?;
970        let fs_state = fs
971            .get("LifeCycleState")
972            .and_then(Value::as_str)
973            .unwrap_or("");
974        if fs_state != "available" {
975            return Err(incorrect_fs_lifecycle_state(&fsid, fs_state));
976        }
977        // One mount target per Availability Zone per file system.
978        for mt in data.mount_targets.values() {
979            if mt.get("FileSystemId").and_then(Value::as_str) == Some(fsid.as_str())
980                && mt.get("AvailabilityZoneName").and_then(Value::as_str) == Some(az_name.as_str())
981            {
982                return Err(conflict(
983                    "MountTargetConflict",
984                    "A mount target already exists in this Availability Zone for the file system.",
985                ));
986            }
987        }
988
989        let mtid = format!("fsmt-{}", hex17());
990        let ip = b
991            .get("IpAddress")
992            .and_then(Value::as_str)
993            .map(str::to_string)
994            .unwrap_or_else(|| format!("10.0.{}.{}", (h >> 8) % 256, h % 254 + 1));
995        let eni_id = format!("eni-{}", hex17());
996        let security_groups: Vec<String> = b
997            .get("SecurityGroups")
998            .and_then(Value::as_array)
999            .map(|a| {
1000                a.iter()
1001                    .filter_map(|v| v.as_str().map(str::to_string))
1002                    .collect()
1003            })
1004            .filter(|v: &Vec<String>| !v.is_empty())
1005            .unwrap_or_else(|| vec![format!("sg-{:017x}", h & 0x000f_ffff_ffff_ffff)]);
1006
1007        let mut mt = Map::new();
1008        mt.insert("OwnerId".into(), json!(ctx.account));
1009        mt.insert("MountTargetId".into(), json!(mtid));
1010        mt.insert("FileSystemId".into(), json!(fsid));
1011        mt.insert("SubnetId".into(), json!(subnet_id));
1012        mt.insert("LifeCycleState".into(), json!("creating"));
1013        mt.insert("IpAddress".into(), json!(ip));
1014        mt.insert("NetworkInterfaceId".into(), json!(eni_id));
1015        mt.insert("AvailabilityZoneId".into(), json!(az_id));
1016        mt.insert("AvailabilityZoneName".into(), json!(az_name));
1017        mt.insert("VpcId".into(), json!(vpc_id));
1018        if let Some(ip6) = b.get("Ipv6Address") {
1019            mt.insert("Ipv6Address".into(), ip6.clone());
1020        }
1021
1022        data.mount_target_security_groups
1023            .insert(mtid.clone(), security_groups);
1024        data.mount_targets
1025            .insert(mtid.clone(), Value::Object(mt.clone()));
1026        ok(StatusCode::OK, Value::Object(mt))
1027    }
1028
1029    fn describe_mount_targets(
1030        &self,
1031        ctx: &Ctx,
1032        q: &[(String, String)],
1033    ) -> Result<AwsResponse, AwsServiceError> {
1034        let by_fs = query_one(q, "FileSystemId").map(normalize_fs_id);
1035        let by_mt = query_one(q, "MountTargetId");
1036        let by_ap = query_one(q, "AccessPointId");
1037        // Real EFS requires EXACTLY ONE of these three filters. Zero is a
1038        // BadRequest, and so is supplying more than one.
1039        let filter_count = usize::from(by_fs.is_some())
1040            + usize::from(by_mt.is_some())
1041            + usize::from(by_ap.is_some());
1042        if filter_count != 1 {
1043            return Err(bad_request(
1044                "Exactly one of FileSystemId, MountTargetId, or AccessPointId must be specified.",
1045            ));
1046        }
1047        let mut guard = self.state.write();
1048        let data = self.account(&mut guard, ctx);
1049        data.reconcile_lifecycle();
1050
1051        // Resolve an access-point filter to its file system.
1052        let ap_fs = if let Some(ap) = by_ap {
1053            match data.access_points.get(ap) {
1054                Some(ap_obj) => Some(normalize_fs_id(
1055                    ap_obj
1056                        .get("FileSystemId")
1057                        .and_then(Value::as_str)
1058                        .unwrap_or(""),
1059                )),
1060                None => return Err(ap_not_found(ap)),
1061            }
1062        } else {
1063            None
1064        };
1065        if let Some(fsid) = &by_fs {
1066            if !data.file_systems.contains_key(fsid) {
1067                return Err(fs_not_found(fsid));
1068            }
1069        }
1070        if let Some(mtid) = by_mt {
1071            if !data.mount_targets.contains_key(mtid) {
1072                return Err(mt_not_found(mtid));
1073            }
1074        }
1075
1076        let rows: Vec<Value> = data
1077            .mount_targets
1078            .iter()
1079            .filter(|(mtid, mt)| {
1080                let fs = mt.get("FileSystemId").and_then(Value::as_str);
1081                by_fs.as_deref().map(|f| fs == Some(f)).unwrap_or(true)
1082                    && by_mt.map(|m| mtid.as_str() == m).unwrap_or(true)
1083                    && ap_fs.as_deref().map(|f| fs == Some(f)).unwrap_or(true)
1084            })
1085            .map(|(_, mt)| mt.clone())
1086            .collect();
1087
1088        // DescribeMountTargets: MaxItems default is 10.
1089        let (page, next) =
1090            paginate_marker(rows, query_one(q, "Marker"), query_one(q, "MaxItems"), 10);
1091        let mut out = Map::new();
1092        out.insert("MountTargets".into(), Value::Array(page));
1093        if let Some(n) = next {
1094            out.insert("NextMarker".into(), json!(n));
1095        }
1096        ok(StatusCode::OK, Value::Object(out))
1097    }
1098
1099    fn delete_mount_target(&self, ctx: &Ctx, label: &str) -> Result<AwsResponse, AwsServiceError> {
1100        let mut guard = self.state.write();
1101        let data = self.account(&mut guard, ctx);
1102        if data.mount_targets.remove(label).is_none() {
1103            return Err(mt_not_found(label));
1104        }
1105        data.mount_target_security_groups.remove(label);
1106        empty(StatusCode::NO_CONTENT)
1107    }
1108
1109    fn describe_mt_security_groups(
1110        &self,
1111        ctx: &Ctx,
1112        label: &str,
1113    ) -> Result<AwsResponse, AwsServiceError> {
1114        let mut guard = self.state.write();
1115        let data = self.account(&mut guard, ctx);
1116        data.reconcile_lifecycle();
1117        if !data.mount_targets.contains_key(label) {
1118            return Err(mt_not_found(label));
1119        }
1120        let sgs = data
1121            .mount_target_security_groups
1122            .get(label)
1123            .cloned()
1124            .unwrap_or_default();
1125        ok(StatusCode::OK, json!({ "SecurityGroups": sgs }))
1126    }
1127
1128    fn modify_mt_security_groups(
1129        &self,
1130        ctx: &Ctx,
1131        label: &str,
1132        b: &Value,
1133    ) -> Result<AwsResponse, AwsServiceError> {
1134        let mut guard = self.state.write();
1135        let data = self.account(&mut guard, ctx);
1136        if !data.mount_targets.contains_key(label) {
1137            return Err(mt_not_found(label));
1138        }
1139        let sgs: Vec<String> = b
1140            .get("SecurityGroups")
1141            .and_then(Value::as_array)
1142            .map(|a| {
1143                a.iter()
1144                    .filter_map(|v| v.as_str().map(str::to_string))
1145                    .collect()
1146            })
1147            .unwrap_or_default();
1148        data.mount_target_security_groups
1149            .insert(label.to_string(), sgs);
1150        empty(StatusCode::NO_CONTENT)
1151    }
1152}
1153
1154// ===================== access points =====================
1155
1156impl EfsService {
1157    fn create_access_point(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
1158        let client_token = b
1159            .get("ClientToken")
1160            .and_then(Value::as_str)
1161            .unwrap_or_default()
1162            .to_string();
1163        let fsid = normalize_fs_id(b.get("FileSystemId").and_then(Value::as_str).unwrap_or(""));
1164        let mut guard = self.state.write();
1165        let data = self.account(&mut guard, ctx);
1166        if !data.file_systems.contains_key(&fsid) {
1167            return Err(fs_not_found(&fsid));
1168        }
1169        if let Some((existing_id, _)) = data.access_points.iter().find(|(_, ap)| {
1170            ap.get("ClientToken").and_then(Value::as_str) == Some(client_token.as_str())
1171        }) {
1172            let existing_id = existing_id.clone();
1173            return Err(AwsServiceError::aws_error_with_fields(
1174                StatusCode::CONFLICT,
1175                "AccessPointAlreadyExists",
1176                format!("Access point already exists with client token {client_token}"),
1177                vec![("AccessPointId".to_string(), existing_id)],
1178            ));
1179        }
1180
1181        let apid = format!("fsap-{}", hex17());
1182        let tag_map = tags_to_map(b);
1183        let mut ap = Map::new();
1184        ap.insert("ClientToken".into(), json!(client_token));
1185        if let Some(name) = tag_map.get("Name").and_then(Value::as_str) {
1186            ap.insert("Name".into(), json!(name));
1187        }
1188        ap.insert("Tags".into(), tags_list_from_map(&tag_map));
1189        ap.insert("AccessPointId".into(), json!(apid));
1190        ap.insert("AccessPointArn".into(), json!(ap_arn(ctx, &apid)));
1191        ap.insert("FileSystemId".into(), json!(fsid));
1192        if let Some(pu) = b.get("PosixUser") {
1193            ap.insert("PosixUser".into(), pu.clone());
1194        }
1195        if let Some(rd) = b.get("RootDirectory") {
1196            ap.insert("RootDirectory".into(), rd.clone());
1197        } else {
1198            ap.insert("RootDirectory".into(), json!({ "Path": "/" }));
1199        }
1200        ap.insert("OwnerId".into(), json!(ctx.account));
1201        ap.insert("LifeCycleState".into(), json!("creating"));
1202
1203        if !tag_map.is_empty() {
1204            let entry = data.tags.entry(apid.clone()).or_default();
1205            for (k, v) in &tag_map {
1206                if let Some(vs) = v.as_str() {
1207                    entry.insert(k.clone(), vs.to_string());
1208                }
1209            }
1210        }
1211        data.access_points
1212            .insert(apid.clone(), Value::Object(ap.clone()));
1213        ok(StatusCode::OK, Value::Object(ap))
1214    }
1215
1216    fn describe_access_points(
1217        &self,
1218        ctx: &Ctx,
1219        q: &[(String, String)],
1220    ) -> Result<AwsResponse, AwsServiceError> {
1221        let by_ap = query_one(q, "AccessPointId");
1222        let by_fs = query_one(q, "FileSystemId").map(normalize_fs_id);
1223        let mut guard = self.state.write();
1224        let data = self.account(&mut guard, ctx);
1225        data.reconcile_lifecycle();
1226
1227        if let Some(ap) = by_ap {
1228            if !data.access_points.contains_key(ap) {
1229                return Err(ap_not_found(ap));
1230            }
1231        }
1232        if let Some(fsid) = &by_fs {
1233            if !data.file_systems.contains_key(fsid) {
1234                return Err(fs_not_found(fsid));
1235            }
1236        }
1237        let rows: Vec<Value> = data
1238            .access_points
1239            .iter()
1240            .filter(|(apid, ap)| {
1241                by_ap.map(|a| apid.as_str() == a).unwrap_or(true)
1242                    && by_fs
1243                        .as_deref()
1244                        .map(|f| ap.get("FileSystemId").and_then(Value::as_str) == Some(f))
1245                        .unwrap_or(true)
1246            })
1247            .map(|(_, ap)| ap.clone())
1248            .collect();
1249
1250        // AccessPoints uses NextToken pagination; MaxResults default is 100.
1251        let start = query_one(q, "NextToken")
1252            .and_then(|t| t.parse::<usize>().ok())
1253            .unwrap_or(0);
1254        let max = query_one(q, "MaxResults")
1255            .and_then(|m| m.parse::<usize>().ok())
1256            .map(|m| m.max(1))
1257            .unwrap_or(100);
1258        let end = start.saturating_add(max).min(rows.len());
1259        let page = rows.get(start..end).unwrap_or(&[]).to_vec();
1260        let mut out = Map::new();
1261        out.insert("AccessPoints".into(), Value::Array(page));
1262        if end < rows.len() {
1263            out.insert("NextToken".into(), json!(end.to_string()));
1264        }
1265        ok(StatusCode::OK, Value::Object(out))
1266    }
1267
1268    fn delete_access_point(&self, ctx: &Ctx, label: &str) -> Result<AwsResponse, AwsServiceError> {
1269        let mut guard = self.state.write();
1270        let data = self.account(&mut guard, ctx);
1271        if data.access_points.remove(label).is_none() {
1272            return Err(ap_not_found(label));
1273        }
1274        data.tags.remove(label);
1275        empty(StatusCode::NO_CONTENT)
1276    }
1277}
1278
1279// ===================== lifecycle / backup / policy =====================
1280
1281impl EfsService {
1282    fn put_lifecycle_configuration(
1283        &self,
1284        ctx: &Ctx,
1285        label: &str,
1286        b: &Value,
1287    ) -> Result<AwsResponse, AwsServiceError> {
1288        let fsid = normalize_fs_id(label);
1289        let mut guard = self.state.write();
1290        let data = self.account(&mut guard, ctx);
1291        if !data.file_systems.contains_key(&fsid) {
1292            return Err(fs_not_found(&fsid));
1293        }
1294        let policies = b.get("LifecyclePolicies").cloned().unwrap_or(json!([]));
1295        data.lifecycle_configs.insert(fsid, policies.clone());
1296        ok(StatusCode::OK, json!({ "LifecyclePolicies": policies }))
1297    }
1298
1299    fn describe_lifecycle_configuration(
1300        &self,
1301        ctx: &Ctx,
1302        label: &str,
1303    ) -> Result<AwsResponse, AwsServiceError> {
1304        let fsid = normalize_fs_id(label);
1305        let guard = self.state.read();
1306        let data = guard.get(&ctx.account);
1307        let exists = data
1308            .map(|d| d.file_systems.contains_key(&fsid))
1309            .unwrap_or(false);
1310        if !exists {
1311            return Err(fs_not_found(&fsid));
1312        }
1313        let policies = data
1314            .and_then(|d| d.lifecycle_configs.get(&fsid).cloned())
1315            .unwrap_or(json!([]));
1316        ok(StatusCode::OK, json!({ "LifecyclePolicies": policies }))
1317    }
1318
1319    fn put_backup_policy(
1320        &self,
1321        ctx: &Ctx,
1322        label: &str,
1323        b: &Value,
1324    ) -> Result<AwsResponse, AwsServiceError> {
1325        let fsid = normalize_fs_id(label);
1326        let mut guard = self.state.write();
1327        let data = self.account(&mut guard, ctx);
1328        if !data.file_systems.contains_key(&fsid) {
1329            return Err(fs_not_found(&fsid));
1330        }
1331        let status = b
1332            .get("BackupPolicy")
1333            .and_then(|p| p.get("Status"))
1334            .and_then(Value::as_str)
1335            .unwrap_or("DISABLED")
1336            .to_string();
1337        data.backup_policies.insert(fsid, status.clone());
1338        ok(
1339            StatusCode::OK,
1340            json!({ "BackupPolicy": { "Status": status } }),
1341        )
1342    }
1343
1344    fn describe_backup_policy(
1345        &self,
1346        ctx: &Ctx,
1347        label: &str,
1348    ) -> Result<AwsResponse, AwsServiceError> {
1349        let fsid = normalize_fs_id(label);
1350        let guard = self.state.read();
1351        let data = guard.get(&ctx.account);
1352        let exists = data
1353            .map(|d| d.file_systems.contains_key(&fsid))
1354            .unwrap_or(false);
1355        if !exists {
1356            return Err(fs_not_found(&fsid));
1357        }
1358        let status = data
1359            .and_then(|d| d.backup_policies.get(&fsid).cloned())
1360            .unwrap_or_else(|| "DISABLED".to_string());
1361        ok(
1362            StatusCode::OK,
1363            json!({ "BackupPolicy": { "Status": status } }),
1364        )
1365    }
1366
1367    fn put_file_system_policy(
1368        &self,
1369        ctx: &Ctx,
1370        label: &str,
1371        b: &Value,
1372    ) -> Result<AwsResponse, AwsServiceError> {
1373        let fsid = normalize_fs_id(label);
1374        let mut guard = self.state.write();
1375        let data = self.account(&mut guard, ctx);
1376        if !data.file_systems.contains_key(&fsid) {
1377            return Err(fs_not_found(&fsid));
1378        }
1379        let policy = b
1380            .get("Policy")
1381            .and_then(Value::as_str)
1382            .unwrap_or_default()
1383            .to_string();
1384        data.file_system_policies
1385            .insert(fsid.clone(), policy.clone());
1386        ok(
1387            StatusCode::OK,
1388            json!({ "FileSystemId": fsid, "Policy": policy }),
1389        )
1390    }
1391
1392    fn describe_file_system_policy(
1393        &self,
1394        ctx: &Ctx,
1395        label: &str,
1396    ) -> Result<AwsResponse, AwsServiceError> {
1397        let fsid = normalize_fs_id(label);
1398        let guard = self.state.read();
1399        let data = guard.get(&ctx.account);
1400        let exists = data
1401            .map(|d| d.file_systems.contains_key(&fsid))
1402            .unwrap_or(false);
1403        if !exists {
1404            return Err(fs_not_found(&fsid));
1405        }
1406        match data.and_then(|d| d.file_system_policies.get(&fsid).cloned()) {
1407            Some(policy) => ok(
1408                StatusCode::OK,
1409                json!({ "FileSystemId": fsid, "Policy": policy }),
1410            ),
1411            None => Err(AwsServiceError::aws_error(
1412                StatusCode::NOT_FOUND,
1413                "PolicyNotFound",
1414                format!("No policy is attached to file system '{fsid}'."),
1415            )),
1416        }
1417    }
1418
1419    fn delete_file_system_policy(
1420        &self,
1421        ctx: &Ctx,
1422        label: &str,
1423    ) -> Result<AwsResponse, AwsServiceError> {
1424        let fsid = normalize_fs_id(label);
1425        let mut guard = self.state.write();
1426        let data = self.account(&mut guard, ctx);
1427        if !data.file_systems.contains_key(&fsid) {
1428            return Err(fs_not_found(&fsid));
1429        }
1430        data.file_system_policies.remove(&fsid);
1431        empty(StatusCode::OK)
1432    }
1433}
1434
1435// ===================== replication =====================
1436
1437impl EfsService {
1438    fn create_replication_configuration(
1439        &self,
1440        ctx: &Ctx,
1441        label: &str,
1442        b: &Value,
1443    ) -> Result<AwsResponse, AwsServiceError> {
1444        let fsid = normalize_fs_id(label);
1445        let mut guard = self.state.write();
1446        let data = self.account(&mut guard, ctx);
1447        if !data.file_systems.contains_key(&fsid) {
1448            return Err(fs_not_found(&fsid));
1449        }
1450        let requested = b
1451            .get("Destinations")
1452            .and_then(Value::as_array)
1453            .cloned()
1454            .unwrap_or_default();
1455        let mut destinations: Vec<Value> = Vec::with_capacity(requested.len());
1456        // Any destination file system EFS itself provisions (the caller did not
1457        // point at a pre-existing one) must become a real FileSystem so
1458        // DescribeFileSystems on it resolves instead of 404ing.
1459        let mut new_dest_systems: Vec<(String, Value)> = Vec::new();
1460        for d in &requested {
1461            let region = d
1462                .get("Region")
1463                .and_then(Value::as_str)
1464                .unwrap_or(&ctx.region)
1465                .to_string();
1466            let az = d.get("AvailabilityZoneName").and_then(Value::as_str);
1467            let provided = d
1468                .get("FileSystemId")
1469                .and_then(Value::as_str)
1470                .map(normalize_fs_id);
1471            let dest_fs = provided
1472                .clone()
1473                .unwrap_or_else(|| format!("fs-{}", hex17()));
1474            // Synthesize the destination FileSystem unless the caller pointed at
1475            // one that already exists.
1476            if !data.file_systems.contains_key(&dest_fs) {
1477                new_dest_systems.push((
1478                    dest_fs.clone(),
1479                    destination_file_system(ctx, &dest_fs, &region, az),
1480                ));
1481            }
1482            let mut dest = Map::new();
1483            dest.insert("Status".into(), json!("ENABLED"));
1484            dest.insert("FileSystemId".into(), json!(dest_fs));
1485            dest.insert("Region".into(), json!(region));
1486            dest.insert("LastReplicatedTimestamp".into(), json!(now_ts()));
1487            dest.insert("OwnerId".into(), json!(ctx.account));
1488            if let Some(role) = d.get("RoleArn") {
1489                dest.insert("RoleArn".into(), role.clone());
1490            }
1491            destinations.push(Value::Object(dest));
1492        }
1493        for (id, fs) in new_dest_systems {
1494            data.file_systems.insert(id, fs);
1495        }
1496
1497        let desc = json!({
1498            "SourceFileSystemId": fsid,
1499            "SourceFileSystemRegion": ctx.region,
1500            "SourceFileSystemArn": fs_arn(ctx, &fsid),
1501            "OriginalSourceFileSystemArn": fs_arn(ctx, &fsid),
1502            "CreationTime": now_ts(),
1503            "Destinations": destinations,
1504            "SourceFileSystemOwnerId": ctx.account,
1505        });
1506        data.replications.insert(fsid, desc.clone());
1507        ok(StatusCode::OK, desc)
1508    }
1509
1510    fn describe_replication_configurations(
1511        &self,
1512        ctx: &Ctx,
1513        q: &[(String, String)],
1514    ) -> Result<AwsResponse, AwsServiceError> {
1515        let by_fs = query_one(q, "FileSystemId").map(normalize_fs_id);
1516        let guard = self.state.read();
1517        let data = guard.get(&ctx.account);
1518        if let Some(fsid) = &by_fs {
1519            let fs_exists = data
1520                .map(|d| d.file_systems.contains_key(fsid))
1521                .unwrap_or(false);
1522            let has_replication = data
1523                .map(|d| d.replications.contains_key(fsid))
1524                .unwrap_or(false);
1525            if !fs_exists && !has_replication {
1526                return Err(fs_not_found(fsid));
1527            }
1528            // The file system exists but has no replication configuration: real
1529            // EFS returns 404 ReplicationNotFound rather than an empty list.
1530            if !has_replication {
1531                return Err(replication_not_found(fsid));
1532            }
1533        }
1534        let rows: Vec<Value> = data
1535            .map(|d| {
1536                d.replications
1537                    .iter()
1538                    .filter(|(src, _)| by_fs.as_deref().map(|f| src.as_str() == f).unwrap_or(true))
1539                    .map(|(_, r)| r.clone())
1540                    .collect()
1541            })
1542            .unwrap_or_default();
1543        ok(StatusCode::OK, json!({ "Replications": rows }))
1544    }
1545
1546    fn delete_replication_configuration(
1547        &self,
1548        ctx: &Ctx,
1549        label: &str,
1550    ) -> Result<AwsResponse, AwsServiceError> {
1551        let fsid = normalize_fs_id(label);
1552        let mut guard = self.state.write();
1553        let data = self.account(&mut guard, ctx);
1554        if !data.file_systems.contains_key(&fsid) {
1555            return Err(fs_not_found(&fsid));
1556        }
1557        if data.replications.remove(&fsid).is_none() {
1558            return Err(AwsServiceError::aws_error(
1559                StatusCode::NOT_FOUND,
1560                "ReplicationNotFound",
1561                format!("No replication configuration found for file system '{fsid}'."),
1562            ));
1563        }
1564        empty(StatusCode::NO_CONTENT)
1565    }
1566}
1567
1568// ===================== tagging + account preferences =====================
1569
1570impl EfsService {
1571    fn create_tags(
1572        &self,
1573        ctx: &Ctx,
1574        label: &str,
1575        b: &Value,
1576    ) -> Result<AwsResponse, AwsServiceError> {
1577        let fsid = normalize_fs_id(label);
1578        let mut guard = self.state.write();
1579        let data = self.account(&mut guard, ctx);
1580        if !data.file_systems.contains_key(&fsid) {
1581            return Err(fs_not_found(&fsid));
1582        }
1583        merge_tags(data, &fsid, b);
1584        sync_resource_tags(data, &fsid);
1585        empty(StatusCode::NO_CONTENT)
1586    }
1587
1588    fn delete_tags(
1589        &self,
1590        ctx: &Ctx,
1591        label: &str,
1592        b: &Value,
1593    ) -> Result<AwsResponse, AwsServiceError> {
1594        let fsid = normalize_fs_id(label);
1595        let mut guard = self.state.write();
1596        let data = self.account(&mut guard, ctx);
1597        if !data.file_systems.contains_key(&fsid) {
1598            return Err(fs_not_found(&fsid));
1599        }
1600        if let Some(keys) = b.get("TagKeys").and_then(Value::as_array) {
1601            if let Some(entry) = data.tags.get_mut(&fsid) {
1602                for k in keys {
1603                    if let Some(ks) = k.as_str() {
1604                        entry.remove(ks);
1605                    }
1606                }
1607            }
1608        }
1609        sync_resource_tags(data, &fsid);
1610        empty(StatusCode::NO_CONTENT)
1611    }
1612
1613    fn describe_tags(
1614        &self,
1615        ctx: &Ctx,
1616        label: &str,
1617        q: &[(String, String)],
1618    ) -> Result<AwsResponse, AwsServiceError> {
1619        let fsid = normalize_fs_id(label);
1620        let guard = self.state.read();
1621        let data = guard.get(&ctx.account);
1622        let exists = data
1623            .map(|d| d.file_systems.contains_key(&fsid))
1624            .unwrap_or(false);
1625        if !exists {
1626            return Err(fs_not_found(&fsid));
1627        }
1628        let all = data
1629            .map(|d| tags_list(d, &fsid))
1630            .unwrap_or_else(|| json!([]));
1631        let rows = all.as_array().cloned().unwrap_or_default();
1632        // DescribeTags: MaxItems default is 100.
1633        let (page, next) =
1634            paginate_marker(rows, query_one(q, "Marker"), query_one(q, "MaxItems"), 100);
1635        let mut out = Map::new();
1636        out.insert("Tags".into(), Value::Array(page));
1637        if let Some(n) = next {
1638            out.insert("NextMarker".into(), json!(n));
1639        }
1640        ok(StatusCode::OK, Value::Object(out))
1641    }
1642
1643    fn tag_resource(
1644        &self,
1645        ctx: &Ctx,
1646        label: &str,
1647        b: &Value,
1648    ) -> Result<AwsResponse, AwsServiceError> {
1649        let rid = normalize_fs_id(label);
1650        let mut guard = self.state.write();
1651        let data = self.account(&mut guard, ctx);
1652        self.require_taggable(data, &rid)?;
1653        merge_tags(data, &rid, b);
1654        sync_resource_tags(data, &rid);
1655        empty(StatusCode::OK)
1656    }
1657
1658    fn untag_resource(
1659        &self,
1660        ctx: &Ctx,
1661        label: &str,
1662        q: &[(String, String)],
1663    ) -> Result<AwsResponse, AwsServiceError> {
1664        let rid = normalize_fs_id(label);
1665        let mut guard = self.state.write();
1666        let data = self.account(&mut guard, ctx);
1667        self.require_taggable(data, &rid)?;
1668        let keys = query_all(q, "tagKeys");
1669        if let Some(entry) = data.tags.get_mut(&rid) {
1670            for k in &keys {
1671                entry.remove(k);
1672            }
1673        }
1674        sync_resource_tags(data, &rid);
1675        empty(StatusCode::OK)
1676    }
1677
1678    fn list_tags_for_resource(
1679        &self,
1680        ctx: &Ctx,
1681        label: &str,
1682        q: &[(String, String)],
1683    ) -> Result<AwsResponse, AwsServiceError> {
1684        let rid = normalize_fs_id(label);
1685        let guard = self.state.read();
1686        let data = guard.get(&ctx.account);
1687        // Validate the resource exists (fs or access point).
1688        let known = data
1689            .map(|d| {
1690                (rid.starts_with("fsap-") && d.access_points.contains_key(&rid))
1691                    || (rid.starts_with("fs-") && d.file_systems.contains_key(&rid))
1692            })
1693            .unwrap_or(false);
1694        if !known {
1695            return Err(if rid.starts_with("fsap-") {
1696                ap_not_found(&rid)
1697            } else {
1698                fs_not_found(&rid)
1699            });
1700        }
1701        let all = data
1702            .map(|d| tags_list(d, &rid))
1703            .unwrap_or_else(|| json!([]));
1704        let rows = all.as_array().cloned().unwrap_or_default();
1705        // ListTagsForResource uses NextToken pagination; MaxResults default is 100.
1706        let start = query_one(q, "NextToken")
1707            .and_then(|t| t.parse::<usize>().ok())
1708            .unwrap_or(0);
1709        let max = query_one(q, "MaxResults")
1710            .and_then(|m| m.parse::<usize>().ok())
1711            .map(|m| m.max(1))
1712            .unwrap_or(100);
1713        let end = start.saturating_add(max).min(rows.len());
1714        let page = rows.get(start..end).unwrap_or(&[]).to_vec();
1715        let mut out = Map::new();
1716        out.insert("Tags".into(), Value::Array(page));
1717        if end < rows.len() {
1718            out.insert("NextToken".into(), json!(end.to_string()));
1719        }
1720        ok(StatusCode::OK, Value::Object(out))
1721    }
1722
1723    fn require_taggable(&self, data: &EfsData, rid: &str) -> Result<(), AwsServiceError> {
1724        if rid.starts_with("fsap-") {
1725            if data.access_points.contains_key(rid) {
1726                Ok(())
1727            } else {
1728                Err(ap_not_found(rid))
1729            }
1730        } else if data.file_systems.contains_key(rid) {
1731            Ok(())
1732        } else {
1733            Err(fs_not_found(rid))
1734        }
1735    }
1736
1737    fn describe_account_preferences(&self, ctx: &Ctx) -> Result<AwsResponse, AwsServiceError> {
1738        let guard = self.state.read();
1739        // Real EFS returns an empty response ({}) until PutAccountPreferences
1740        // has been called; only then is a ResourceIdPreference present.
1741        match guard
1742            .get(&ctx.account)
1743            .and_then(|d| d.resource_id_preference.clone())
1744        {
1745            Some(pref) => ok(
1746                StatusCode::OK,
1747                json!({
1748                    "ResourceIdPreference": {
1749                        "ResourceIdType": pref,
1750                        "Resources": ["FILE_SYSTEM", "MOUNT_TARGET"]
1751                    }
1752                }),
1753            ),
1754            None => empty(StatusCode::OK),
1755        }
1756    }
1757
1758    fn put_account_preferences(
1759        &self,
1760        ctx: &Ctx,
1761        b: &Value,
1762    ) -> Result<AwsResponse, AwsServiceError> {
1763        let id_type = b
1764            .get("ResourceIdType")
1765            .and_then(Value::as_str)
1766            .unwrap_or("LONG_ID")
1767            .to_string();
1768        let mut guard = self.state.write();
1769        let data = self.account(&mut guard, ctx);
1770        data.resource_id_preference = Some(id_type.clone());
1771        ok(
1772            StatusCode::OK,
1773            json!({
1774                "ResourceIdPreference": {
1775                    "ResourceIdType": id_type,
1776                    "Resources": ["FILE_SYSTEM", "MOUNT_TARGET"]
1777                }
1778            }),
1779        )
1780    }
1781}
1782
1783/// Merge a `Tags` input list into the account tag map under `resource_id`.
1784fn merge_tags(data: &mut EfsData, resource_id: &str, b: &Value) {
1785    let map = tags_to_map(b);
1786    if map.is_empty() {
1787        return;
1788    }
1789    let entry = data.tags.entry(resource_id.to_string()).or_default();
1790    for (k, v) in &map {
1791        if let Some(vs) = v.as_str() {
1792            entry.insert(k.clone(), vs.to_string());
1793        }
1794    }
1795}
1796
1797/// Keep a taggable resource's embedded `Name` field and `Tags` list in sync
1798/// with its tag map after a tag mutation, so a `Describe*` reading the embedded
1799/// copy always agrees with `ListTagsForResource` reading the tag map. Applies to
1800/// both file systems (`fs-`) and access points (`fsap-`).
1801fn sync_resource_tags(data: &mut EfsData, rid: &str) {
1802    let tags = data.tags.get(rid).cloned().unwrap_or_default();
1803    let obj = if rid.starts_with("fsap-") {
1804        data.access_points.get_mut(rid)
1805    } else {
1806        data.file_systems.get_mut(rid)
1807    }
1808    .and_then(Value::as_object_mut);
1809    if let Some(obj) = obj {
1810        match tags.get("Name") {
1811            Some(name) => {
1812                obj.insert("Name".into(), json!(name));
1813            }
1814            None => {
1815                obj.remove("Name");
1816            }
1817        }
1818        obj.insert(
1819            "Tags".into(),
1820            Value::Array(
1821                tags.iter()
1822                    .map(|(k, v)| json!({ "Key": k, "Value": v }))
1823                    .collect(),
1824            ),
1825        );
1826    }
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831    use super::*;
1832    use parking_lot::RwLock;
1833
1834    fn ctx() -> Ctx {
1835        Ctx {
1836            account: "000000000000".to_string(),
1837            region: "us-east-1".to_string(),
1838        }
1839    }
1840
1841    fn svc() -> EfsService {
1842        let state: SharedEfsState = Arc::new(RwLock::new(MultiAccountState::new(
1843            "000000000000",
1844            "us-east-1",
1845            "",
1846        )));
1847        EfsService::new(state)
1848    }
1849
1850    fn empty_ec2() -> fakecloud_ec2::SharedEc2State {
1851        Arc::new(RwLock::new(MultiAccountState::new(
1852            "000000000000",
1853            "us-east-1",
1854            "",
1855        )))
1856    }
1857
1858    fn seed_fs(s: &EfsService, fsid: &str, life_cycle: &str) {
1859        let mut g = s.state.write();
1860        let d = g.get_or_create("000000000000");
1861        d.file_systems.insert(
1862            fsid.to_string(),
1863            json!({ "FileSystemId": fsid, "LifeCycleState": life_cycle }),
1864        );
1865    }
1866
1867    fn body_value(resp: &AwsResponse) -> Value {
1868        serde_json::from_slice(resp.body.expect_bytes()).unwrap()
1869    }
1870
1871    // Defect #1: a nonexistent subnet must be rejected with SubnetNotFound once
1872    // EC2 state is wired to validate against (rather than synthesizing one).
1873    #[test]
1874    fn create_mount_target_nonexistent_subnet_is_subnet_not_found() {
1875        let s = svc().with_ec2_state(empty_ec2());
1876        seed_fs(&s, "fs-1", "available");
1877        let err = s
1878            .create_mount_target(
1879                &ctx(),
1880                &json!({ "FileSystemId": "fs-1", "SubnetId": "subnet-does-not-exist" }),
1881            )
1882            .err()
1883            .unwrap();
1884        assert_eq!(err.code(), "SubnetNotFound");
1885        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1886    }
1887
1888    // Without EC2 state wired, the deterministic-synthesis fallback still
1889    // applies (no VPC subsystem to validate against).
1890    #[test]
1891    fn create_mount_target_without_ec2_synthesizes_subnet() {
1892        let s = svc();
1893        seed_fs(&s, "fs-1", "available");
1894        let resp = s
1895            .create_mount_target(
1896                &ctx(),
1897                &json!({ "FileSystemId": "fs-1", "SubnetId": "subnet-synth" }),
1898            )
1899            .unwrap();
1900        assert!(resp.status.is_success());
1901    }
1902
1903    // Defect #4: a file system that is not yet `available` rejects a mount
1904    // target with IncorrectFileSystemLifeCycleState.
1905    #[test]
1906    fn create_mount_target_on_creating_fs_is_incorrect_lifecycle() {
1907        let s = svc();
1908        seed_fs(&s, "fs-1", "creating");
1909        let err = s
1910            .create_mount_target(
1911                &ctx(),
1912                &json!({ "FileSystemId": "fs-1", "SubnetId": "subnet-1" }),
1913            )
1914            .err()
1915            .unwrap();
1916        assert_eq!(err.code(), "IncorrectFileSystemLifeCycleState");
1917        assert_eq!(err.status(), StatusCode::CONFLICT);
1918    }
1919
1920    // Defect #2: a file system with an access point cannot be deleted.
1921    #[test]
1922    fn delete_file_system_with_access_point_is_in_use() {
1923        let s = svc();
1924        seed_fs(&s, "fs-1", "available");
1925        {
1926            let mut g = s.state.write();
1927            let d = g.get_or_create("000000000000");
1928            d.access_points.insert(
1929                "fsap-1".to_string(),
1930                json!({ "AccessPointId": "fsap-1", "FileSystemId": "fs-1" }),
1931            );
1932        }
1933        let err = s.delete_file_system(&ctx(), "fs-1").err().unwrap();
1934        assert_eq!(err.code(), "FileSystemInUse");
1935        assert_eq!(err.status(), StatusCode::CONFLICT);
1936    }
1937
1938    // Defect #3: describing replication for a file system with none returns 404
1939    // ReplicationNotFound, not an empty list.
1940    #[test]
1941    fn describe_replication_missing_is_replication_not_found() {
1942        let s = svc();
1943        seed_fs(&s, "fs-1", "available");
1944        let err = s
1945            .describe_replication_configurations(
1946                &ctx(),
1947                &[("FileSystemId".to_string(), "fs-1".to_string())],
1948            )
1949            .err()
1950            .unwrap();
1951        assert_eq!(err.code(), "ReplicationNotFound");
1952        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1953    }
1954
1955    // With no FileSystemId filter, an empty list is correct (not an error).
1956    #[test]
1957    fn describe_replication_no_filter_is_empty_ok() {
1958        let s = svc();
1959        let resp = s.describe_replication_configurations(&ctx(), &[]).unwrap();
1960        assert!(resp.status.is_success());
1961        assert_eq!(
1962            body_value(&resp)["Replications"].as_array().unwrap().len(),
1963            0
1964        );
1965    }
1966
1967    // CreateReplicationConfiguration must create the destination file system so
1968    // DescribeFileSystems on it resolves instead of 404ing (bug-hunt).
1969    #[test]
1970    fn create_replication_creates_the_destination_file_system() {
1971        let s = svc();
1972        seed_fs(&s, "fs-source", "available");
1973        let resp = s
1974            .create_replication_configuration(
1975                &ctx(),
1976                "fs-source",
1977                &json!({ "Destinations": [{ "Region": "us-west-2" }] }),
1978            )
1979            .unwrap();
1980        assert!(resp.status.is_success());
1981        let dest_id = body_value(&resp)["Destinations"][0]["FileSystemId"]
1982            .as_str()
1983            .unwrap()
1984            .to_string();
1985        assert!(dest_id.starts_with("fs-"));
1986
1987        // The destination is a real, describable file system (previously 404).
1988        let described = s
1989            .describe_file_systems(&ctx(), &[("FileSystemId".to_string(), dest_id.clone())])
1990            .unwrap();
1991        let fs = &body_value(&described)["FileSystems"][0];
1992        assert_eq!(fs["FileSystemId"], dest_id);
1993        assert_eq!(
1994            fs["FileSystemProtection"]["ReplicationOverwriteProtection"],
1995            "DISABLED"
1996        );
1997
1998        // The replication configuration is still queryable.
1999        let repl = s
2000            .describe_replication_configurations(
2001                &ctx(),
2002                &[("FileSystemId".to_string(), "fs-source".to_string())],
2003            )
2004            .unwrap();
2005        assert!(repl.status.is_success());
2006    }
2007
2008    // Defect #5: DescribeMountTargets requires exactly one filter; supplying two
2009    // is a BadRequest.
2010    #[test]
2011    fn describe_mount_targets_two_filters_is_bad_request() {
2012        let s = svc();
2013        let err = s
2014            .describe_mount_targets(
2015                &ctx(),
2016                &[
2017                    ("FileSystemId".to_string(), "fs-1".to_string()),
2018                    ("MountTargetId".to_string(), "fsmt-1".to_string()),
2019                ],
2020            )
2021            .err()
2022            .unwrap();
2023        assert_eq!(err.code(), "BadRequest");
2024        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
2025    }
2026
2027    // Defect #8: ThroughputMode=provisioned without ProvisionedThroughputInMibps
2028    // is a BadRequest; the wrong-mode variant is rejected too.
2029    #[test]
2030    fn create_file_system_provisioned_without_mibps_is_bad_request() {
2031        let s = svc();
2032        let err = s
2033            .create_file_system(
2034                &ctx(),
2035                &json!({ "CreationToken": "tok-1", "ThroughputMode": "provisioned" }),
2036            )
2037            .err()
2038            .unwrap();
2039        assert_eq!(err.code(), "BadRequest");
2040
2041        let err2 = s
2042            .create_file_system(
2043                &ctx(),
2044                &json!({
2045                    "CreationToken": "tok-2",
2046                    "ThroughputMode": "bursting",
2047                    "ProvisionedThroughputInMibps": 128
2048                }),
2049            )
2050            .err()
2051            .unwrap();
2052        assert_eq!(err2.code(), "BadRequest");
2053
2054        // A valid provisioned request succeeds.
2055        let ok = s
2056            .create_file_system(
2057                &ctx(),
2058                &json!({
2059                    "CreationToken": "tok-3",
2060                    "ThroughputMode": "provisioned",
2061                    "ProvisionedThroughputInMibps": 256
2062                }),
2063            )
2064            .unwrap();
2065        assert!(ok.status.is_success());
2066    }
2067
2068    // Defect #9: a fresh account returns an empty DescribeAccountPreferences
2069    // response until PutAccountPreferences is called.
2070    #[test]
2071    fn describe_account_preferences_fresh_account_is_empty() {
2072        let s = svc();
2073        let resp = s.describe_account_preferences(&ctx()).unwrap();
2074        assert!(resp.status.is_success());
2075        let v = body_value(&resp);
2076        assert!(
2077            v.get("ResourceIdPreference").is_none(),
2078            "expected empty {{}}, got {v}"
2079        );
2080
2081        // After PutAccountPreferences the preference is surfaced.
2082        s.put_account_preferences(&ctx(), &json!({ "ResourceIdType": "SHORT_ID" }))
2083            .unwrap();
2084        let resp2 = s.describe_account_preferences(&ctx()).unwrap();
2085        let v2 = body_value(&resp2);
2086        assert_eq!(v2["ResourceIdPreference"]["ResourceIdType"], "SHORT_ID");
2087    }
2088
2089    // Defect #10: after TagResource on an access point, the embedded Tags field
2090    // and ListTagsForResource must agree.
2091    #[test]
2092    fn access_point_tags_resync_agrees() {
2093        let s = svc();
2094        seed_fs(&s, "fs-1", "available");
2095        let created = s
2096            .create_access_point(
2097                &ctx(),
2098                &json!({ "ClientToken": "ct-1", "FileSystemId": "fs-1" }),
2099            )
2100            .unwrap();
2101        let apid = body_value(&created)["AccessPointId"]
2102            .as_str()
2103            .unwrap()
2104            .to_string();
2105
2106        s.tag_resource(
2107            &ctx(),
2108            &apid,
2109            &json!({ "Tags": [{ "Key": "Env", "Value": "prod" }] }),
2110        )
2111        .unwrap();
2112
2113        // Embedded Tags on the access-point object.
2114        let embedded: Vec<(String, String)> = {
2115            let g = s.state.read();
2116            let ap = g
2117                .get("000000000000")
2118                .unwrap()
2119                .access_points
2120                .get(&apid)
2121                .unwrap()
2122                .clone();
2123            ap["Tags"]
2124                .as_array()
2125                .unwrap()
2126                .iter()
2127                .map(|t| {
2128                    (
2129                        t["Key"].as_str().unwrap().to_string(),
2130                        t["Value"].as_str().unwrap().to_string(),
2131                    )
2132                })
2133                .collect()
2134        };
2135
2136        // Tags via ListTagsForResource (the tag-map source of truth).
2137        let listed_resp = s.list_tags_for_resource(&ctx(), &apid, &[]).unwrap();
2138        let listed: Vec<(String, String)> = body_value(&listed_resp)["Tags"]
2139            .as_array()
2140            .unwrap()
2141            .iter()
2142            .map(|t| {
2143                (
2144                    t["Key"].as_str().unwrap().to_string(),
2145                    t["Value"].as_str().unwrap().to_string(),
2146                )
2147            })
2148            .collect();
2149
2150        assert_eq!(embedded, vec![("Env".to_string(), "prod".to_string())]);
2151        assert_eq!(
2152            embedded, listed,
2153            "embedded Tags must match ListTagsForResource"
2154        );
2155
2156        // Removing the tag keeps both views in agreement (empty).
2157        s.untag_resource(&ctx(), &apid, &[("tagKeys".to_string(), "Env".to_string())])
2158            .unwrap();
2159        let g = s.state.read();
2160        let ap = g
2161            .get("000000000000")
2162            .unwrap()
2163            .access_points
2164            .get(&apid)
2165            .unwrap();
2166        assert_eq!(ap["Tags"].as_array().unwrap().len(), 0);
2167    }
2168}