Skip to main content

fakecloud_secretsmanager/
service.rs

1use std::collections::{BTreeMap, HashMap};
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use chrono::Utc;
6use http::StatusCode;
7use serde_json::{json, Value};
8
9use tokio::sync::Mutex as AsyncMutex;
10
11use fakecloud_aws::arn::Arn;
12use fakecloud_core::delivery::DeliveryBus;
13use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
14use fakecloud_core::validation::*;
15use fakecloud_persistence::SnapshotStore;
16
17use crate::state::{
18    RotationRules, Secret, SecretVersion, SecretsManagerSnapshot, SecretsManagerState,
19    SharedSecretsManagerState, SECRETSMANAGER_SNAPSHOT_SCHEMA_VERSION,
20};
21
22/// Information needed to invoke the rotation Lambda after releasing state lock.
23struct RotationInvocation {
24    lambda_arn: String,
25    secret_id: String,
26    client_request_token: String,
27    /// The rotation steps to invoke, in order. A full rotation runs
28    /// createSecret/setSecret/testSecret/finishSecret; RotateImmediately=false
29    /// with a Lambda runs only testSecret to validate the configuration.
30    steps: Vec<&'static str>,
31    /// A temporary AWSPENDING version created purely so a test-only
32    /// (RotateImmediately=false) invocation has something for the Lambda's
33    /// testSecret step to read. Removed once the steps complete.
34    cleanup_pending: Option<PendingCleanup>,
35}
36
37/// Locates a temporary AWSPENDING version to delete after a test-only rotation.
38struct PendingCleanup {
39    account_id: String,
40    secret_name: String,
41    version_id: String,
42}
43
44/// Remove the temporary AWSPENDING version created for a test-only rotation.
45fn remove_rotation_test_pending(state: &SharedSecretsManagerState, cleanup: &PendingCleanup) {
46    let mut accounts = state.write();
47    if let Some(secret) = accounts
48        .get_or_create(&cleanup.account_id)
49        .secrets
50        .get_mut(&cleanup.secret_name)
51    {
52        secret.versions.remove(&cleanup.version_id);
53    }
54}
55
56/// Invoke the rotation Lambda once per step, in order. Stops early if delivery
57/// is not configured for the target function.
58async fn run_rotation_steps(bus: &DeliveryBus, inv: &RotationInvocation) {
59    for step in &inv.steps {
60        let payload = serde_json::json!({
61            "SecretId": inv.secret_id,
62            "ClientRequestToken": inv.client_request_token,
63            "Step": step,
64        });
65        match bus
66            .invoke_lambda(&inv.lambda_arn, &payload.to_string())
67            .await
68        {
69            Some(Ok(_)) => {}
70            Some(Err(e)) => {
71                tracing::warn!(step = step, error = %e, "rotation Lambda invocation failed");
72            }
73            None => {
74                tracing::warn!(
75                    lambda_arn = %inv.lambda_arn,
76                    step = step,
77                    "rotation Lambda delivery not configured; Lambda invocation skipped"
78                );
79                break;
80            }
81        }
82    }
83}
84
85/// Result of an idempotency check against an existing
86/// `ClientRequestToken` / version id.
87pub(crate) enum VersionIdempotency {
88    /// The version id isn't in the secret yet — this is a fresh write.
89    NotFound,
90    /// The version id exists and stores the exact same payload we're
91    /// about to write — callers should return the existing version as
92    /// a successful no-op response.
93    Match,
94    /// The version id exists but stores a different payload — AWS
95    /// surfaces this as a `ResourceExistsException`.
96    Conflict,
97}
98
99pub struct SecretsManagerService {
100    state: SharedSecretsManagerState,
101    delivery_bus: Option<Arc<DeliveryBus>>,
102    snapshot_store: Option<Arc<dyn SnapshotStore>>,
103    snapshot_lock: Arc<AsyncMutex<()>>,
104    kms_hook: Option<Arc<dyn fakecloud_core::delivery::KmsHook>>,
105}
106
107impl SecretsManagerService {
108    pub fn new(state: SharedSecretsManagerState) -> Self {
109        Self {
110            state,
111            delivery_bus: None,
112            snapshot_store: None,
113            snapshot_lock: Arc::new(AsyncMutex::new(())),
114            kms_hook: None,
115        }
116    }
117
118    pub fn with_delivery(mut self, delivery_bus: Arc<DeliveryBus>) -> Self {
119        self.delivery_bus = Some(delivery_bus);
120        self
121    }
122
123    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
124        self.snapshot_store = Some(store);
125        self
126    }
127
128    pub fn with_kms_hook(mut self, hook: Arc<dyn fakecloud_core::delivery::KmsHook>) -> Self {
129        self.kms_hook = Some(hook);
130        self
131    }
132
133    fn maybe_encrypt_secret_string(
134        &self,
135        account_id: &str,
136        region: &str,
137        secret_arn: &str,
138        kms_key_id: Option<&str>,
139        plaintext: Option<String>,
140    ) -> Option<String> {
141        let pt = plaintext?;
142        let (Some(hook), Some(key)) = (&self.kms_hook, kms_key_id) else {
143            return Some(pt);
144        };
145        let key = if key.is_empty() {
146            "aws/secretsmanager"
147        } else {
148            key
149        };
150        let mut ctx = HashMap::new();
151        ctx.insert(
152            "aws:secretsmanager:secretArn".to_string(),
153            secret_arn.to_string(),
154        );
155        match hook.encrypt(
156            account_id,
157            region,
158            key,
159            pt.as_bytes(),
160            "secretsmanager.amazonaws.com",
161            ctx,
162        ) {
163            Ok(ciphertext) => Some(ciphertext),
164            Err(err) => {
165                tracing::warn!(
166                    secret_arn = %secret_arn,
167                    error = %err,
168                    "KMS encrypt failed for secret; storing plaintext"
169                );
170                Some(pt)
171            }
172        }
173    }
174
175    fn maybe_decrypt_secret_string(
176        &self,
177        account_id: &str,
178        secret_arn: &str,
179        kms_key_id: Option<&str>,
180        stored: Option<&str>,
181    ) -> Option<String> {
182        let stored = stored?;
183        let (Some(hook), Some(_)) = (&self.kms_hook, kms_key_id) else {
184            return Some(stored.to_string());
185        };
186        let mut ctx = HashMap::new();
187        ctx.insert(
188            "aws:secretsmanager:secretArn".to_string(),
189            secret_arn.to_string(),
190        );
191        match hook.decrypt(account_id, stored, "secretsmanager.amazonaws.com", ctx) {
192            Ok(bytes) => Some(String::from_utf8_lossy(&bytes).to_string()),
193            Err(_) => Some(stored.to_string()),
194        }
195    }
196
197    /// Persist current state as a snapshot. Held across the
198    /// clone-serialize-write sequence to prevent stale-last writes,
199    /// with serde + file I/O offloaded to the blocking pool.
200    async fn save_snapshot(&self) {
201        save_secretsmanager_snapshot(
202            &self.state,
203            self.snapshot_store.clone(),
204            &self.snapshot_lock,
205        )
206        .await;
207    }
208
209    /// Build a hook that persists the current Secrets Manager state when
210    /// invoked, or `None` in memory mode (no snapshot store). The
211    /// CloudFormation provisioner mutates `state` directly and uses this to
212    /// write a CFN-provisioned secret through to disk, the same way a direct
213    /// mutating API call would.
214    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
215        let store = self.snapshot_store.clone()?;
216        let state = self.state.clone();
217        let lock = self.snapshot_lock.clone();
218        Some(Arc::new(move || {
219            let state = state.clone();
220            let store = store.clone();
221            let lock = lock.clone();
222            Box::pin(async move {
223                save_secretsmanager_snapshot(&state, Some(store), &lock).await;
224            })
225        }))
226    }
227
228    fn create_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
229        let input = CreateSecretInput::from_body(&req.json_body())?;
230        let has_value = input.secret_string.is_some() || input.secret_binary.is_some();
231
232        let mut accounts = self.state.write();
233        let state = accounts.get_or_create(&req.account_id);
234
235        // A name that is scheduled for deletion can't be recreated while its
236        // recovery window is still open — AWS returns InvalidRequestException
237        // (not ResourceExistsException). Once the recovery window has elapsed
238        // the secret is treated as purged, so lazily remove it here and allow
239        // the create to proceed with a fresh secret.
240        if let Some((deleted, elapsed)) = state
241            .secrets
242            .get(&input.name)
243            .map(|s| (s.deleted, secret_recovery_window_elapsed(s, Utc::now())))
244        {
245            if deleted {
246                if elapsed {
247                    state.secrets.remove(&input.name);
248                } else {
249                    return Err(AwsServiceError::aws_error(
250                        StatusCode::BAD_REQUEST,
251                        "InvalidRequestException",
252                        "You can't create this secret because a secret with this name is already scheduled for deletion.",
253                    ));
254                }
255            }
256        }
257
258        if let Some(existing) = state.secrets.get(&input.name) {
259            if let Some(ref token) = input.client_request_token {
260                let existing_plaintext = existing.versions.get(token).and_then(|v| {
261                    self.maybe_decrypt_secret_string(
262                        &req.account_id,
263                        &existing.arn,
264                        existing.kms_key_id.as_deref(),
265                        v.secret_string.as_deref(),
266                    )
267                });
268                match check_secret_version_idempotency(
269                    &existing.versions,
270                    token,
271                    existing_plaintext,
272                    &input.secret_string,
273                    &input.secret_binary,
274                ) {
275                    VersionIdempotency::Match => {
276                        let mut response = json!({
277                            "ARN": existing.arn,
278                            "Name": existing.name,
279                            "VersionId": token,
280                        });
281                        if !has_value {
282                            response.as_object_mut().unwrap().remove("VersionId");
283                        }
284                        return Ok(AwsResponse::ok_json(response));
285                    }
286                    VersionIdempotency::Conflict => {
287                        return Err(AwsServiceError::aws_error(
288                            StatusCode::BAD_REQUEST,
289                            "ResourceExistsException",
290                            format!(
291                                "You can't use ClientRequestToken {token} because that value is already in use for a version of secret {}.",
292                                existing.arn
293                            ),
294                        ));
295                    }
296                    VersionIdempotency::NotFound => {}
297                }
298            }
299            return Err(AwsServiceError::aws_error(
300                StatusCode::BAD_REQUEST,
301                "ResourceExistsException",
302                format!(
303                    "The operation failed because the secret {} already exists.",
304                    input.name
305                ),
306            ));
307        }
308
309        let arn = format!(
310            "arn:aws:secretsmanager:{}:{}:secret:{}-{}",
311            req.region,
312            req.account_id,
313            input.name,
314            &uuid::Uuid::new_v4().to_string()[..6]
315        );
316
317        let now = Utc::now();
318
319        let (versions, current_version_id, version_id_for_response) = if has_value {
320            let vid = input
321                .client_request_token
322                .clone()
323                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
324            let stored_string = self.maybe_encrypt_secret_string(
325                &req.account_id,
326                &req.region,
327                &arn,
328                input.kms_key_id.as_deref(),
329                input.secret_string,
330            );
331            let version = SecretVersion {
332                version_id: vid.clone(),
333                secret_string: stored_string,
334                secret_binary: input.secret_binary,
335                stages: vec!["AWSCURRENT".to_string()],
336                created_at: now,
337            };
338            let mut versions = std::collections::BTreeMap::new();
339            versions.insert(vid.clone(), version);
340            (versions, Some(vid.clone()), Some(vid))
341        } else {
342            (std::collections::BTreeMap::new(), None, None)
343        };
344
345        let tags_ever_set = !input.tags.is_empty();
346        let replica_regions = input.add_replica_regions.clone();
347        let secret = Secret {
348            name: input.name.clone(),
349            arn: arn.clone(),
350            description: input.description,
351            kms_key_id: input.kms_key_id,
352            versions,
353            current_version_id,
354            tags: input.tags,
355            tags_ever_set,
356            deleted: false,
357            deletion_date: None,
358            created_at: now,
359            last_changed_at: now,
360            last_accessed_at: None,
361            rotation_enabled: None,
362            rotation_lambda_arn: None,
363            rotation_rules: None,
364            last_rotated_at: None,
365            resource_policy: None,
366            replica_regions,
367        };
368
369        state.secrets.insert(input.name.clone(), secret);
370
371        let mut response = json!({
372            "ARN": arn,
373            "Name": input.name,
374        });
375        if let Some(vid) = version_id_for_response {
376            response["VersionId"] = json!(vid);
377        }
378        // CreateSecret echoes ReplicationStatus when replica regions were
379        // requested via AddReplicaRegions.
380        if !input.add_replica_regions.is_empty() {
381            response["ReplicationStatus"] = replication_status_json(&input.add_replica_regions);
382        }
383
384        Ok(AwsResponse::ok_json(response))
385    }
386
387    fn get_secret_value(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
388        let body = req.json_body();
389        let secret_id = require_secret_id(&body)?;
390        validate_optional_string_length("versionId", body["VersionId"].as_str(), 32, 64)?;
391        validate_optional_string_length("versionStage", body["VersionStage"].as_str(), 1, 256)?;
392
393        // Resolve owning account from an ARN form. Cross-account
394        // GetSecretValue then evaluates `secret.resource_policy` via
395        // the IAM evaluator before returning the value.
396        let owner_account = secret_owner_account(&secret_id, &req.account_id);
397        let mut accounts = self.state.write();
398        let state = accounts.get_or_create(&owner_account);
399        let secret = self.find_secret_mut(state, &secret_id)?;
400        if owner_account != req.account_id {
401            let policy_doc = secret.resource_policy.as_deref().unwrap_or("");
402            let secret_arn = secret.arn.clone();
403            if !resource_policy_allows(policy_doc, &req.account_id, &secret_arn) {
404                return Err(AwsServiceError::aws_error(
405                    StatusCode::FORBIDDEN,
406                    "AccessDeniedException",
407                    "User is not authorized to perform: secretsmanager:GetSecretValue on the requested resource",
408                ));
409            }
410        }
411
412        if secret.deleted {
413            return Err(AwsServiceError::aws_error(
414                StatusCode::BAD_REQUEST,
415                "InvalidRequestException",
416                "You can't perform this operation on the secret because it was marked for deletion.",
417            ));
418        }
419
420        let requested_stage = body["VersionStage"].as_str().unwrap_or("AWSCURRENT");
421
422        // Determine which version to return
423        let version_id = body["VersionId"]
424            .as_str()
425            .map(|s| s.to_string())
426            .or_else(|| {
427                secret
428                    .versions
429                    .iter()
430                    .find(|(_, v)| v.stages.contains(&requested_stage.to_string()))
431                    .map(|(id, _)| id.clone())
432            });
433
434        let version_id = match version_id {
435            Some(vid) => vid,
436            None => {
437                // No versions exist
438                return Err(AwsServiceError::aws_error(
439                    StatusCode::NOT_FOUND,
440                    "ResourceNotFoundException",
441                    format!(
442                        "Secrets Manager can't find the specified secret value for staging label: {requested_stage}"
443                    ),
444                ));
445            }
446        };
447
448        let version = secret.versions.get(&version_id).ok_or_else(|| {
449            AwsServiceError::aws_error(
450                StatusCode::NOT_FOUND,
451                "ResourceNotFoundException",
452                format!(
453                    "Secrets Manager can't find the specified secret value for VersionId: {version_id}"
454                ),
455            )
456        })?;
457
458        // If VersionStage is specified with VersionId, verify they match
459        if body["VersionId"].as_str().is_some() {
460            if let Some(stage) = body["VersionStage"].as_str() {
461                if !version.stages.contains(&stage.to_string()) {
462                    return Err(AwsServiceError::aws_error(
463                        StatusCode::NOT_FOUND,
464                        "ResourceNotFoundException",
465                        "You provided a VersionStage that is not associated to the provided VersionId.",
466                    ));
467                }
468            }
469        }
470
471        // Only set last_accessed_at on successful retrieval
472        secret.last_accessed_at = Some(Utc::now());
473
474        let mut response = json!({
475            "ARN": secret.arn,
476            "Name": secret.name,
477            "VersionId": version.version_id,
478            "VersionStages": version.stages,
479            "CreatedDate": version.created_at.timestamp_millis() as f64 / 1000.0,
480        });
481
482        let kms_for_decrypt = secret.kms_key_id.clone();
483        let arn_for_decrypt = secret.arn.clone();
484        if let Some(ref s) = version.secret_string {
485            let plaintext = self
486                .maybe_decrypt_secret_string(
487                    &req.account_id,
488                    &arn_for_decrypt,
489                    kms_for_decrypt.as_deref(),
490                    Some(s.as_str()),
491                )
492                .unwrap_or_else(|| s.clone());
493            response["SecretString"] = json!(plaintext);
494        }
495        if let Some(ref b) = version.secret_binary {
496            response["SecretBinary"] = json!(base64_encode(b));
497        }
498
499        Ok(AwsResponse::ok_json(response))
500    }
501
502    fn put_secret_value(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
503        let body = req.json_body();
504        let secret_id = require_secret_id(&body)?;
505        validate_optional_string_length(
506            "clientRequestToken",
507            body["ClientRequestToken"].as_str(),
508            32,
509            64,
510        )?;
511        validate_optional_string_length("secretString", body["SecretString"].as_str(), 1, 65536)?;
512
513        let secret_string = body["SecretString"].as_str().map(|s| s.to_string());
514        let secret_binary = parse_secret_binary(&body)?;
515
516        // Validate that either SecretString or SecretBinary is provided
517        if secret_string.is_none() && secret_binary.is_none() {
518            return Err(AwsServiceError::aws_error(
519                StatusCode::BAD_REQUEST,
520                "InvalidRequestException",
521                "You must provide either SecretString or SecretBinary.",
522            ));
523        }
524        // SecretString and SecretBinary are mutually exclusive.
525        if secret_string.is_some() && secret_binary.is_some() {
526            return Err(both_secret_fields_error());
527        }
528
529        let mut accounts = self.state.write();
530        let state = accounts.get_or_create(&req.account_id);
531        let secret = match self.find_secret_mut(state, &secret_id) {
532            Ok(s) => s,
533            Err(_) => {
534                return Err(AwsServiceError::aws_error(
535                    StatusCode::NOT_FOUND,
536                    "ResourceNotFoundException",
537                    "Secrets Manager can't find the specified secret.",
538                ));
539            }
540        };
541
542        if secret.deleted {
543            return Err(AwsServiceError::aws_error(
544                StatusCode::BAD_REQUEST,
545                "InvalidRequestException",
546                "You can't perform this operation on the secret because it was marked for deletion.",
547            ));
548        }
549
550        let now = Utc::now();
551        let version_id = body["ClientRequestToken"]
552            .as_str()
553            .map(|s| s.to_string())
554            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
555
556        let existing_plaintext = secret.versions.get(&version_id).and_then(|v| {
557            self.maybe_decrypt_secret_string(
558                &req.account_id,
559                &secret.arn,
560                secret.kms_key_id.as_deref(),
561                v.secret_string.as_deref(),
562            )
563        });
564        match check_secret_version_idempotency(
565            &secret.versions,
566            &version_id,
567            existing_plaintext,
568            &secret_string,
569            &secret_binary,
570        ) {
571            VersionIdempotency::Match => {
572                let existing_stages = secret.versions[&version_id].stages.clone();
573                return Ok(AwsResponse::ok_json(json!({
574                    "ARN": secret.arn,
575                    "Name": secret.name,
576                    "VersionId": version_id,
577                    "VersionStages": existing_stages,
578                })));
579            }
580            VersionIdempotency::Conflict => {
581                return Err(AwsServiceError::aws_error(
582                    StatusCode::BAD_REQUEST,
583                    "ResourceExistsException",
584                    format!(
585                        "You can't use ClientRequestToken {version_id} because that value is already in use for a version of secret {}.",
586                        secret.arn
587                    ),
588                ));
589            }
590            VersionIdempotency::NotFound => {}
591        }
592
593        let mut version_stages: Vec<String> = body["VersionStages"]
594            .as_array()
595            .map(|arr| {
596                arr.iter()
597                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
598                    .collect()
599            })
600            .unwrap_or_else(|| vec!["AWSCURRENT".to_string()]);
601
602        // If this is the first version with a value, add AWSCURRENT to stages
603        let has_current = secret
604            .versions
605            .values()
606            .any(|v| v.stages.contains(&"AWSCURRENT".to_string()));
607        if !has_current && !version_stages.contains(&"AWSCURRENT".to_string()) {
608            version_stages.push("AWSCURRENT".to_string());
609        }
610
611        // Move AWSCURRENT from old version to AWSPREVIOUS if new version has AWSCURRENT
612        if version_stages.contains(&"AWSCURRENT".to_string()) {
613            if let Some(ref old_vid) = secret.current_version_id.clone() {
614                demote_current_to_previous(&mut secret.versions, old_vid);
615            }
616            secret.current_version_id = Some(version_id.clone());
617        }
618
619        // Remove custom stages from other versions that have them
620        for stage in &version_stages {
621            if stage == "AWSCURRENT" || stage == "AWSPREVIOUS" {
622                continue;
623            }
624            for v in secret.versions.values_mut() {
625                v.stages.retain(|s| s != stage);
626            }
627        }
628
629        // Remove versions with no stages
630        secret.versions.retain(|_, v| !v.stages.is_empty());
631
632        let kms_key_for_enc = secret.kms_key_id.clone();
633        let arn_for_enc = secret.arn.clone();
634        let stored_secret_string = self.maybe_encrypt_secret_string(
635            &req.account_id,
636            &req.region,
637            &arn_for_enc,
638            kms_key_for_enc.as_deref(),
639            secret_string,
640        );
641        let version = SecretVersion {
642            version_id: version_id.clone(),
643            secret_string: stored_secret_string,
644            secret_binary,
645            stages: version_stages.clone(),
646            created_at: now,
647        };
648
649        secret.versions.insert(version_id.clone(), version);
650        secret.last_changed_at = now;
651
652        let response = json!({
653            "ARN": secret.arn,
654            "Name": secret.name,
655            "VersionId": version_id,
656            "VersionStages": version_stages,
657        });
658
659        Ok(AwsResponse::ok_json(response))
660    }
661
662    fn update_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
663        let body = req.json_body();
664        let secret_id = require_secret_id(&body)?;
665        validate_optional_string_length(
666            "clientRequestToken",
667            body["ClientRequestToken"].as_str(),
668            32,
669            64,
670        )?;
671        validate_optional_string_length("description", body["Description"].as_str(), 0, 2048)?;
672        validate_optional_string_length("kmsKeyId", body["KmsKeyId"].as_str(), 0, 2048)?;
673        validate_optional_string_length("secretString", body["SecretString"].as_str(), 1, 65536)?;
674
675        let mut accounts = self.state.write();
676        let state = accounts.get_or_create(&req.account_id);
677        let secret = match self.find_secret_mut(state, &secret_id) {
678            Ok(s) => s,
679            Err(_) => {
680                return Err(AwsServiceError::aws_error(
681                    StatusCode::NOT_FOUND,
682                    "ResourceNotFoundException",
683                    "Secrets Manager can't find the specified secret.",
684                ));
685            }
686        };
687
688        if secret.deleted {
689            return Err(AwsServiceError::aws_error(
690                StatusCode::BAD_REQUEST,
691                "InvalidRequestException",
692                "You can't perform this operation on the secret because it was marked for deletion.",
693            ));
694        }
695
696        if let Some(desc) = body["Description"].as_str() {
697            secret.description = Some(desc.to_string());
698        }
699        if let Some(kms) = body["KmsKeyId"].as_str() {
700            secret.kms_key_id = Some(kms.to_string());
701        }
702
703        // If SecretString or SecretBinary is provided, create a new version
704        let secret_string = body["SecretString"].as_str().map(|s| s.to_string());
705        let secret_binary = parse_secret_binary(&body)?;
706
707        // SecretString and SecretBinary are mutually exclusive.
708        if secret_string.is_some() && secret_binary.is_some() {
709            return Err(both_secret_fields_error());
710        }
711
712        let version_id = if secret_string.is_some() || secret_binary.is_some() {
713            let vid = body["ClientRequestToken"]
714                .as_str()
715                .map(|s| s.to_string())
716                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
717
718            let existing_plaintext = secret.versions.get(&vid).and_then(|v| {
719                self.maybe_decrypt_secret_string(
720                    &req.account_id,
721                    &secret.arn,
722                    secret.kms_key_id.as_deref(),
723                    v.secret_string.as_deref(),
724                )
725            });
726            match check_secret_version_idempotency(
727                &secret.versions,
728                &vid,
729                existing_plaintext,
730                &secret_string,
731                &secret_binary,
732            ) {
733                VersionIdempotency::Match => {
734                    return Ok(AwsResponse::ok_json(json!({
735                        "ARN": secret.arn,
736                        "Name": secret.name,
737                        "VersionId": vid,
738                    })));
739                }
740                VersionIdempotency::Conflict => {
741                    return Err(AwsServiceError::aws_error(
742                        StatusCode::BAD_REQUEST,
743                        "ResourceExistsException",
744                        format!(
745                            "You can't use ClientRequestToken {vid} because that value is already in use for a version of secret {}.",
746                            secret.arn
747                        ),
748                    ));
749                }
750                VersionIdempotency::NotFound => {}
751            }
752
753            let now = Utc::now();
754
755            // Move AWSCURRENT -> AWSPREVIOUS on old version, keeping AWSPREVIOUS
756            // unique across versions.
757            if let Some(ref old_vid) = secret.current_version_id.clone() {
758                demote_current_to_previous(&mut secret.versions, old_vid);
759            }
760
761            let version = SecretVersion {
762                version_id: vid.clone(),
763                secret_string,
764                secret_binary,
765                stages: vec!["AWSCURRENT".to_string()],
766                created_at: now,
767            };
768            secret.versions.insert(vid.clone(), version);
769            secret.current_version_id = Some(vid.clone());
770            secret.last_changed_at = now;
771            // Prune deprecated versions (no staging labels) so they don't leak,
772            // matching PutSecretValue. Demoting AWSCURRENT can strip the last
773            // label off the previously-previous version.
774            secret.versions.retain(|_, v| !v.stages.is_empty());
775            Some(vid)
776        } else {
777            secret.last_changed_at = Utc::now();
778            None
779        };
780
781        let mut response = json!({
782            "ARN": secret.arn,
783            "Name": secret.name,
784        });
785        if let Some(vid) = version_id {
786            response["VersionId"] = json!(vid);
787        }
788
789        Ok(AwsResponse::ok_json(response))
790    }
791
792    fn delete_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
793        let body = req.json_body();
794        let secret_id = require_secret_id(&body)?;
795
796        let force_delete = body["ForceDeleteWithoutRecovery"]
797            .as_bool()
798            .unwrap_or(false);
799        let recovery_window = body.get("RecoveryWindowInDays").and_then(|v| v.as_i64());
800
801        // Validate recovery window range first (AWS validates this before the conflict check)
802        if let Some(days) = recovery_window {
803            if !(7..=30).contains(&days) {
804                return Err(AwsServiceError::aws_error(
805                    StatusCode::BAD_REQUEST,
806                    "InvalidParameterException",
807                    "An error occurred (InvalidParameterException) when calling the DeleteSecret operation: RecoveryWindowInDays value must be between 7 and 30 days (inclusive).",
808                ));
809            }
810        }
811
812        // Validate: can't use both force delete and recovery window
813        if force_delete && recovery_window.is_some() {
814            return Err(AwsServiceError::aws_error(
815                StatusCode::BAD_REQUEST,
816                "InvalidParameterException",
817                "An error occurred (InvalidParameterException) when calling the DeleteSecret operation: You can't use ForceDeleteWithoutRecovery in conjunction with RecoveryWindowInDays.",
818            ));
819        }
820
821        let mut accounts = self.state.write();
822        let state = accounts.get_or_create(&req.account_id);
823
824        if force_delete {
825            // Force delete: if secret doesn't exist, create a fake response
826            match self.find_secret_mut(state, &secret_id) {
827                Ok(secret) => {
828                    let arn = secret.arn.clone();
829                    let name = secret.name.clone();
830                    let deletion_date = Utc::now();
831                    state.secrets.remove(&name);
832                    let response = json!({
833                        "ARN": arn,
834                        "Name": name,
835                        "DeletionDate": deletion_date.timestamp_millis() as f64 / 1000.0,
836                    });
837                    return Ok(AwsResponse::ok_json(response));
838                }
839                Err(_) => {
840                    // For force delete of non-existent secret, AWS returns success
841                    let arn = format!(
842                        "arn:aws:secretsmanager:{}:{}:secret:{}-{}",
843                        req.region,
844                        req.account_id,
845                        secret_id,
846                        &uuid::Uuid::new_v4().to_string()[..6]
847                    );
848                    let deletion_date = Utc::now();
849                    let response = json!({
850                        "ARN": arn,
851                        "Name": secret_id,
852                        "DeletionDate": deletion_date.timestamp_millis() as f64 / 1000.0,
853                    });
854                    return Ok(AwsResponse::ok_json(response));
855                }
856            }
857        }
858
859        let secret = self.find_secret_mut(state, &secret_id)?;
860
861        if secret.deleted {
862            return Err(AwsServiceError::aws_error(
863                StatusCode::BAD_REQUEST,
864                "InvalidRequestException",
865                "You can't perform this operation on the secret because it was already scheduled for deletion.",
866            ));
867        }
868
869        let now = Utc::now();
870        let days = recovery_window.unwrap_or(30);
871        let deletion_date = now + chrono::Duration::days(days);
872        secret.deleted = true;
873        secret.deletion_date = Some(deletion_date);
874
875        let response = json!({
876            "ARN": secret.arn,
877            "Name": secret.name,
878            "DeletionDate": deletion_date.timestamp_millis() as f64 / 1000.0,
879        });
880
881        Ok(AwsResponse::ok_json(response))
882    }
883
884    fn restore_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
885        let body = req.json_body();
886        let secret_id = require_secret_id(&body)?;
887
888        let mut accounts = self.state.write();
889        let state = accounts.get_or_create(&req.account_id);
890        let secret = self.find_secret_mut(state, &secret_id)?;
891
892        // AWS allows restoring a secret that is not deleted (no-op)
893        secret.deleted = false;
894        secret.deletion_date = None;
895
896        let response = json!({
897            "ARN": secret.arn,
898            "Name": secret.name,
899        });
900
901        Ok(AwsResponse::ok_json(response))
902    }
903
904    fn describe_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
905        let body = req.json_body();
906        let secret_id = require_secret_id(&body)?;
907
908        let accounts = self.state.read();
909        let empty = SecretsManagerState::new(&req.account_id, &req.region);
910        let state = accounts.get(&req.account_id).unwrap_or(&empty);
911        let secret = self.find_secret_ref(state, &secret_id)?;
912
913        let mut response = json!({
914            "ARN": secret.arn,
915            "Name": secret.name,
916            "CreatedDate": secret.created_at.timestamp_millis() as f64 / 1000.0,
917            "LastChangedDate": secret.last_changed_at.timestamp_millis() as f64 / 1000.0,
918        });
919
920        if !secret.versions.is_empty() {
921            let mut version_ids_to_stages: serde_json::Map<String, Value> = serde_json::Map::new();
922            for (vid, version) in &secret.versions {
923                version_ids_to_stages.insert(vid.clone(), json!(version.stages));
924            }
925            response["VersionIdsToStages"] = Value::Object(version_ids_to_stages);
926        }
927
928        if let Some(ref desc) = secret.description {
929            if !desc.is_empty() {
930                response["Description"] = json!(desc);
931            }
932        }
933
934        if secret.tags_ever_set || !secret.tags.is_empty() {
935            response["Tags"] = json!(tags_to_json(&secret.tags));
936        }
937
938        if let Some(ref kms) = secret.kms_key_id {
939            response["KmsKeyId"] = json!(kms);
940        }
941        if secret.deleted {
942            response["DeletedDate"] = json!(secret
943                .deletion_date
944                .map(|d| d.timestamp_millis() as f64 / 1000.0));
945        }
946        if let Some(rotation_enabled) = secret.rotation_enabled {
947            response["RotationEnabled"] = json!(rotation_enabled);
948        }
949        if let Some(ref lambda_arn) = secret.rotation_lambda_arn {
950            response["RotationLambdaARN"] = json!(lambda_arn);
951        }
952        if let Some(ref rules) = secret.rotation_rules {
953            let mut rules_json = json!({});
954            if let Some(days) = rules.automatically_after_days {
955                rules_json["AutomaticallyAfterDays"] = json!(days);
956            }
957            if let Some(ref duration) = rules.duration {
958                rules_json["Duration"] = json!(duration);
959            }
960            if let Some(ref expr) = rules.schedule_expression {
961                rules_json["ScheduleExpression"] = json!(expr);
962            }
963            response["RotationRules"] = rules_json;
964        }
965        if let Some(last_rotated) = secret.last_rotated_at {
966            response["LastRotatedDate"] = json!(last_rotated.timestamp_millis() as f64 / 1000.0);
967        }
968        if !secret.replica_regions.is_empty() {
969            response["ReplicationStatus"] = replication_status_json(&secret.replica_regions);
970        }
971        // Calculate NextRotationDate if rotation is enabled
972        if secret.rotation_enabled == Some(true) {
973            if let Some(ref rules) = secret.rotation_rules {
974                if let Some(days) = rules.automatically_after_days {
975                    let base = secret.last_rotated_at.unwrap_or(secret.created_at);
976                    let next = base + chrono::Duration::days(days);
977                    response["NextRotationDate"] = json!(next.timestamp_millis() as f64 / 1000.0);
978                }
979            }
980        }
981
982        Ok(AwsResponse::ok_json(response))
983    }
984
985    fn list_secrets(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
986        let body = req.json_body();
987        validate_optional_string_length("nextToken", body["NextToken"].as_str(), 1, 4096)?;
988        validate_optional_range_i64("maxResults", body["MaxResults"].as_i64(), 1, 100)?;
989        validate_optional_enum("sortBy", body["SortBy"].as_str(), &["name", "created-date"])?;
990        validate_optional_enum("sortOrder", body["SortOrder"].as_str(), &["asc", "desc"])?;
991        let max_results = body["MaxResults"].as_i64().unwrap_or(100) as usize;
992        let next_token = body["NextToken"].as_str();
993        let filters = body["Filters"].as_array();
994        let include_deleted = body["IncludePlannedDeletion"].as_bool().unwrap_or(false);
995
996        // Validate filters
997        if let Some(filters) = filters {
998            for filter in filters {
999                let key = filter["Key"].as_str().unwrap_or("");
1000                let values = filter["Values"].as_array();
1001
1002                if key.is_empty() {
1003                    return Err(AwsServiceError::aws_error(
1004                        StatusCode::BAD_REQUEST,
1005                        "InvalidParameterException",
1006                        "Invalid filter key",
1007                    ));
1008                }
1009
1010                let valid_keys = [
1011                    "all",
1012                    "name",
1013                    "tag-key",
1014                    "description",
1015                    "tag-value",
1016                    "owning-service",
1017                    "primary-region",
1018                ];
1019                if !valid_keys.contains(&key) {
1020                    return Err(AwsServiceError::aws_error(
1021                        StatusCode::BAD_REQUEST,
1022                        "ValidationException",
1023                        format!(
1024                            "1 validation error detected: Value '{}' at 'filters.1.member.key' failed to satisfy constraint: Member must satisfy enum value set: [all, name, tag-key, description, tag-value]",
1025                            key
1026                        ),
1027                    ));
1028                }
1029
1030                if values.is_none() || values.unwrap().is_empty() {
1031                    return Err(AwsServiceError::aws_error(
1032                        StatusCode::BAD_REQUEST,
1033                        "InvalidParameterException",
1034                        format!("Invalid filter values for key: {key}"),
1035                    ));
1036                }
1037            }
1038        }
1039
1040        let accounts = self.state.read();
1041        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1042        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1043
1044        let mut secrets: Vec<&Secret> = state
1045            .secrets
1046            .values()
1047            .filter(|s| {
1048                // Exclude deleted unless IncludePlannedDeletion
1049                if s.deleted && !include_deleted {
1050                    return false;
1051                }
1052
1053                if let Some(filters) = filters {
1054                    for filter in filters {
1055                        let key = filter["Key"].as_str().unwrap_or("");
1056                        let values: Vec<&str> = filter["Values"]
1057                            .as_array()
1058                            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1059                            .unwrap_or_default();
1060
1061                        let matches = match key {
1062                            "name" => filter_name(s, &values),
1063                            "description" => filter_description(s, &values),
1064                            "tag-key" => filter_tag_key(s, &values),
1065                            "tag-value" => filter_tag_value(s, &values),
1066                            "all" => filter_all(s, &values),
1067                            "owning-service" => false,
1068                            "primary-region" => false,
1069                            _ => true,
1070                        };
1071
1072                        if !matches {
1073                            return false;
1074                        }
1075                    }
1076                }
1077                true
1078            })
1079            .collect();
1080        secrets.sort_by_key(|a| a.created_at);
1081
1082        // Simple pagination with name-based token. When the token's secret was
1083        // deleted between pages the lookup fails; end the listing (empty page,
1084        // no token) rather than restarting at offset 0 (which could loop).
1085        let start_idx = if let Some(token) = next_token {
1086            secrets
1087                .iter()
1088                .position(|s| s.name == token)
1089                .unwrap_or(secrets.len())
1090        } else {
1091            0
1092        };
1093
1094        let page: Vec<Value> = secrets
1095            .iter()
1096            .skip(start_idx)
1097            .take(max_results)
1098            .map(|s| {
1099                // AWS always echoes `Description` and `SecretVersionsToStages`
1100                // on every SecretListEntry. The documented `@examples` for
1101                // ListSecrets relies on the fields being present even when
1102                // empty, and SDK consumers index into them unconditionally.
1103                let mut version_ids_to_stages: serde_json::Map<String, Value> =
1104                    serde_json::Map::new();
1105                for (vid, version) in &s.versions {
1106                    version_ids_to_stages.insert(vid.clone(), json!(version.stages));
1107                }
1108                let mut entry = json!({
1109                    "ARN": s.arn,
1110                    "Name": s.name,
1111                    "CreatedDate": s.created_at.timestamp_millis() as f64 / 1000.0,
1112                    "LastChangedDate": s.last_changed_at.timestamp_millis() as f64 / 1000.0,
1113                    "Description": s.description.clone().unwrap_or_default(),
1114                    "SecretVersionsToStages": Value::Object(version_ids_to_stages),
1115                });
1116
1117                if s.tags_ever_set || !s.tags.is_empty() {
1118                    entry["Tags"] = json!(tags_to_json(&s.tags));
1119                }
1120
1121                if let Some(ref kms) = s.kms_key_id {
1122                    entry["KmsKeyId"] = json!(kms);
1123                }
1124                if s.deleted {
1125                    entry["DeletedDate"] = json!(s
1126                        .deletion_date
1127                        .map(|d| d.timestamp_millis() as f64 / 1000.0));
1128                }
1129                entry
1130            })
1131            .collect();
1132
1133        let has_more = start_idx + max_results < secrets.len();
1134        let mut response = json!({
1135            "SecretList": page,
1136        });
1137        if has_more {
1138            if let Some(next) = secrets.get(start_idx + max_results) {
1139                response["NextToken"] = json!(next.name);
1140            }
1141        }
1142
1143        Ok(AwsResponse::ok_json(response))
1144    }
1145
1146    fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1147        let body = req.json_body();
1148        let secret_id = require_secret_id(&body)?;
1149
1150        let new_tags = parse_tags(&body["Tags"]);
1151
1152        let mut accounts = self.state.write();
1153        let state = accounts.get_or_create(&req.account_id);
1154        let secret = self.find_secret_mut(state, &secret_id)?;
1155
1156        if !new_tags.is_empty() {
1157            secret.tags_ever_set = true;
1158        }
1159        for (k, v) in new_tags {
1160            // Update existing tag or add new one
1161            if let Some(existing) = secret.tags.iter_mut().find(|(ek, _)| *ek == k) {
1162                existing.1 = v;
1163            } else {
1164                secret.tags.push((k, v));
1165            }
1166        }
1167
1168        Ok(AwsResponse::json(StatusCode::OK, "{}"))
1169    }
1170
1171    fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1172        let body = req.json_body();
1173        let secret_id = require_secret_id(&body)?;
1174
1175        let tag_keys: Vec<String> = body["TagKeys"]
1176            .as_array()
1177            .map(|arr| {
1178                arr.iter()
1179                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
1180                    .collect()
1181            })
1182            .unwrap_or_default();
1183
1184        let mut accounts = self.state.write();
1185        let state = accounts.get_or_create(&req.account_id);
1186        let secret = self.find_secret_mut(state, &secret_id)?;
1187
1188        secret.tags.retain(|(k, _)| !tag_keys.contains(k));
1189
1190        Ok(AwsResponse::json(StatusCode::OK, "{}"))
1191    }
1192
1193    fn list_secret_version_ids(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1194        let body = req.json_body();
1195        let secret_id = require_secret_id(&body)?;
1196        validate_optional_string_length("nextToken", body["NextToken"].as_str(), 1, 4096)?;
1197        validate_optional_range_i64("maxResults", body["MaxResults"].as_i64(), 1, 100)?;
1198        let max_results = body["MaxResults"].as_i64().unwrap_or(100) as usize;
1199        let next_token = body["NextToken"].as_str();
1200        // IncludeDeprecated defaults to false: a deprecated version (one with no
1201        // staging labels) is hidden unless the caller explicitly asks for it.
1202        let include_deprecated = body["IncludeDeprecated"].as_bool().unwrap_or(false);
1203
1204        let accounts = self.state.read();
1205        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1206        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1207        let secret = self.find_secret_ref(state, &secret_id)?;
1208
1209        // Stable order so the NextToken (a version id) resumes deterministically:
1210        // newest first by CreatedDate, version id as a tiebreaker.
1211        let mut versions: Vec<&_> = secret
1212            .versions
1213            .values()
1214            .filter(|v| include_deprecated || !v.stages.is_empty())
1215            .collect();
1216        versions.sort_by(|a, b| {
1217            b.created_at
1218                .cmp(&a.created_at)
1219                .then_with(|| a.version_id.cmp(&b.version_id))
1220        });
1221
1222        let start_idx = if let Some(token) = next_token {
1223            versions
1224                .iter()
1225                .position(|v| v.version_id == token)
1226                .unwrap_or(versions.len())
1227        } else {
1228            0
1229        };
1230
1231        let page: Vec<Value> = versions
1232            .iter()
1233            .skip(start_idx)
1234            .take(max_results)
1235            .map(|v| {
1236                json!({
1237                    "VersionId": v.version_id,
1238                    "VersionStages": v.stages,
1239                    "CreatedDate": v.created_at.timestamp_millis() as f64 / 1000.0,
1240                })
1241            })
1242            .collect();
1243
1244        let mut response = json!({
1245            "ARN": secret.arn,
1246            "Name": secret.name,
1247            "Versions": page,
1248        });
1249        if start_idx + max_results < versions.len() {
1250            if let Some(next) = versions.get(start_idx + max_results) {
1251                response["NextToken"] = json!(next.version_id);
1252            }
1253        }
1254
1255        Ok(AwsResponse::ok_json(response))
1256    }
1257
1258    fn get_random_password(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1259        let body = req.json_body();
1260        let length = body["PasswordLength"].as_i64().unwrap_or(32) as usize;
1261
1262        if length < 4 {
1263            return Err(AwsServiceError::aws_error(
1264                StatusCode::BAD_REQUEST,
1265                "InvalidParameterException",
1266                "Invalid value for parameter PasswordLength; must be 4..=4096.",
1267            ));
1268        }
1269        if length > 4096 {
1270            return Err(AwsServiceError::aws_error(
1271                StatusCode::BAD_REQUEST,
1272                "InvalidParameterException",
1273                "Invalid value for parameter PasswordLength; must be 4..=4096.",
1274            ));
1275        }
1276
1277        let exclude_lowercase = body["ExcludeLowercase"].as_bool().unwrap_or(false);
1278        let exclude_uppercase = body["ExcludeUppercase"].as_bool().unwrap_or(false);
1279        let exclude_numbers = body["ExcludeNumbers"].as_bool().unwrap_or(false);
1280        let exclude_punctuation = body["ExcludePunctuation"].as_bool().unwrap_or(false);
1281        let include_space = body["IncludeSpace"].as_bool().unwrap_or(false);
1282        let require_each = body["RequireEachIncludedType"].as_bool().unwrap_or(true);
1283        validate_optional_string_length(
1284            "excludeCharacters",
1285            body["ExcludeCharacters"].as_str(),
1286            0,
1287            4096,
1288        )?;
1289        let exclude_chars = body["ExcludeCharacters"].as_str().unwrap_or("").to_string();
1290
1291        let lowercase = "abcdefghijklmnopqrstuvwxyz";
1292        let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
1293        let digits = "0123456789";
1294        let punctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
1295
1296        let mut char_pool = String::new();
1297        let mut required_chars: Vec<String> = Vec::new();
1298
1299        if !exclude_lowercase {
1300            let filtered: String = lowercase
1301                .chars()
1302                .filter(|c| !exclude_chars.contains(*c))
1303                .collect();
1304            if !filtered.is_empty() {
1305                required_chars.push(filtered.clone());
1306                char_pool.push_str(&filtered);
1307            }
1308        }
1309        if !exclude_uppercase {
1310            let filtered: String = uppercase
1311                .chars()
1312                .filter(|c| !exclude_chars.contains(*c))
1313                .collect();
1314            if !filtered.is_empty() {
1315                required_chars.push(filtered.clone());
1316                char_pool.push_str(&filtered);
1317            }
1318        }
1319        if !exclude_numbers {
1320            let filtered: String = digits
1321                .chars()
1322                .filter(|c| !exclude_chars.contains(*c))
1323                .collect();
1324            if !filtered.is_empty() {
1325                required_chars.push(filtered.clone());
1326                char_pool.push_str(&filtered);
1327            }
1328        }
1329        if !exclude_punctuation {
1330            let filtered: String = punctuation
1331                .chars()
1332                .filter(|c| !exclude_chars.contains(*c))
1333                .collect();
1334            if !filtered.is_empty() {
1335                required_chars.push(filtered.clone());
1336                char_pool.push_str(&filtered);
1337            }
1338        }
1339        if include_space && !exclude_chars.contains(' ') {
1340            char_pool.push(' ');
1341        }
1342
1343        if char_pool.is_empty() {
1344            return Err(AwsServiceError::aws_error(
1345                StatusCode::BAD_REQUEST,
1346                "InvalidParameterException",
1347                "InvalidParameterException",
1348            ));
1349        }
1350
1351        let pool_bytes: Vec<char> = char_pool.chars().collect();
1352        let mut password = String::with_capacity(length);
1353
1354        // Use simple random generation
1355        if require_each {
1356            // Each required class (plus space, when included) consumes one
1357            // guaranteed slot. If the requested length can't hold one of each,
1358            // AWS rejects the request rather than silently dropping a class.
1359            let mut required_count = required_chars.len();
1360            if include_space && !exclude_chars.contains(' ') {
1361                required_count += 1;
1362            }
1363            if length < required_count {
1364                return Err(AwsServiceError::aws_error(
1365                    StatusCode::BAD_REQUEST,
1366                    "InvalidParameterException",
1367                    "PasswordLength is too short to include one of each required character type.",
1368                ));
1369            }
1370            // First, ensure at least one character from each required category
1371            for category in &required_chars {
1372                let chars: Vec<char> = category.chars().collect();
1373                let idx = simple_random() % chars.len();
1374                password.push(chars[idx]);
1375            }
1376            if include_space && !exclude_chars.contains(' ') {
1377                password.push(' ');
1378            }
1379        }
1380
1381        // Fill the rest randomly
1382        while password.len() < length {
1383            let idx = simple_random() % pool_bytes.len();
1384            password.push(pool_bytes[idx]);
1385        }
1386
1387        // Shuffle the password (Fisher-Yates)
1388        let mut chars: Vec<char> = password.chars().collect();
1389        for i in (1..chars.len()).rev() {
1390            let j = simple_random() % (i + 1);
1391            chars.swap(i, j);
1392        }
1393        let password: String = chars.into_iter().take(length).collect();
1394
1395        let response = json!({
1396            "RandomPassword": password,
1397        });
1398
1399        Ok(AwsResponse::ok_json(response))
1400    }
1401
1402    fn rotate_secret(
1403        &self,
1404        req: &AwsRequest,
1405    ) -> Result<(AwsResponse, Option<RotationInvocation>), AwsServiceError> {
1406        let body = req.json_body();
1407        let secret_id = require_secret_id(&body)?;
1408
1409        // Validate ClientRequestToken
1410        if let Some(token) = body["ClientRequestToken"].as_str() {
1411            if token.len() < 32 || token.len() > 64 {
1412                return Err(AwsServiceError::aws_error(
1413                    StatusCode::BAD_REQUEST,
1414                    "InvalidParameterException",
1415                    "ClientRequestToken must be 32-64 characters long.",
1416                ));
1417            }
1418        }
1419
1420        // Validate RotationLambdaARN
1421        if let Some(arn) = body["RotationLambdaARN"].as_str() {
1422            if arn.len() > 2048 {
1423                return Err(AwsServiceError::aws_error(
1424                    StatusCode::BAD_REQUEST,
1425                    "InvalidParameterException",
1426                    "RotationLambdaARN length must be less than or equal to 2048.",
1427                ));
1428            }
1429        }
1430
1431        // Validate RotationRules
1432        if let Some(rules) = body["RotationRules"].as_object() {
1433            if let Some(days) = rules.get("AutomaticallyAfterDays").and_then(|v| v.as_i64()) {
1434                if !(1..=1000).contains(&days) {
1435                    return Err(AwsServiceError::aws_error(
1436                        StatusCode::BAD_REQUEST,
1437                        "InvalidParameterException",
1438                        "RotationRules.AutomaticallyAfterDays must be within 1-1000.",
1439                    ));
1440                }
1441            }
1442        }
1443
1444        let mut accounts = self.state.write();
1445        let state = accounts.get_or_create(&req.account_id);
1446        let secret = self.find_secret_mut(state, &secret_id)?;
1447
1448        if secret.deleted {
1449            return Err(AwsServiceError::aws_error(
1450                StatusCode::BAD_REQUEST,
1451                "InvalidRequestException",
1452                "You can't perform this operation on the secret because it was marked for deletion.",
1453            ));
1454        }
1455
1456        // Set rotation config
1457        if let Some(lambda_arn) = body["RotationLambdaARN"].as_str() {
1458            secret.rotation_lambda_arn = Some(lambda_arn.to_string());
1459        }
1460
1461        if let Some(rules) = body["RotationRules"].as_object() {
1462            let days = rules.get("AutomaticallyAfterDays").and_then(|v| v.as_i64());
1463            secret.rotation_rules = Some(RotationRules {
1464                automatically_after_days: days,
1465                duration: rules
1466                    .get("Duration")
1467                    .and_then(|v| v.as_str())
1468                    .map(String::from),
1469                schedule_expression: rules
1470                    .get("ScheduleExpression")
1471                    .and_then(|v| v.as_str())
1472                    .map(String::from),
1473            });
1474        }
1475
1476        secret.rotation_enabled = Some(true);
1477        let now = Utc::now();
1478        secret.last_changed_at = now;
1479
1480        // RotateImmediately defaults to true. When false, Secrets Manager
1481        // saves the rotation configuration and schedules the next rotation
1482        // for the upcoming window WITHOUT rotating the value now (and
1483        // without invoking the rotation Lambda). LastRotatedDate is only
1484        // set when a rotation actually happens.
1485        let rotate_immediately = body["RotateImmediately"].as_bool().unwrap_or(true);
1486
1487        let version_id = body["ClientRequestToken"]
1488            .as_str()
1489            .map(|s| s.to_string())
1490            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1491
1492        let has_lambda =
1493            body["RotationLambdaARN"].as_str().is_some() || secret.rotation_lambda_arn.is_some();
1494        let lambda_arn = secret.rotation_lambda_arn.clone();
1495
1496        // Rotation behavior:
1497        // - RotateImmediately (default): a full rotation. With a Lambda, the
1498        //   Lambda runs all four steps; without one, the value is rotated
1499        //   in-place (new AWSCURRENT, old -> AWSPREVIOUS).
1500        // - RotateImmediately=false: the value is NOT rotated now. But when a
1501        //   rotation Lambda is configured, AWS still validates the rotation
1502        //   configuration by running ONLY the testSecret step (matching real
1503        //   Secrets Manager). LastRotatedDate is only set on an actual rotation.
1504        let mut invocation = None;
1505        if rotate_immediately {
1506            if let Some(current_vid) = secret.current_version_id.clone() {
1507                let current_value = secret.versions.get(&current_vid).cloned();
1508
1509                if let Some(cv) = current_value {
1510                    if has_lambda {
1511                        // With Lambda: do NOT pre-create the AWSPENDING version. The
1512                        // rotation Lambda is responsible for putting the new value via
1513                        // PutSecretValue with VersionStages=[AWSPENDING] during the
1514                        // createSecret step (matching real AWS Secrets Manager behavior).
1515
1516                        // Schedule Lambda invocation (full rotation steps).
1517                        if let Some(ref arn) = lambda_arn {
1518                            invocation = Some(RotationInvocation {
1519                                lambda_arn: arn.clone(),
1520                                secret_id: secret.arn.clone(),
1521                                client_request_token: version_id.clone(),
1522                                steps: vec![
1523                                    "createSecret",
1524                                    "setSecret",
1525                                    "testSecret",
1526                                    "finishSecret",
1527                                ],
1528                                cleanup_pending: None,
1529                            });
1530                        }
1531                    } else {
1532                        // Without Lambda: simple rotation - new version becomes AWSCURRENT.
1533                        // Move old version to AWSPREVIOUS (kept unique across versions).
1534                        demote_current_to_previous(&mut secret.versions, &current_vid);
1535                        let version = SecretVersion {
1536                            version_id: version_id.clone(),
1537                            secret_string: cv.secret_string.clone(),
1538                            secret_binary: cv.secret_binary.clone(),
1539                            stages: vec!["AWSCURRENT".to_string()],
1540                            created_at: now,
1541                        };
1542                        secret.versions.insert(version_id.clone(), version);
1543                        secret.current_version_id = Some(version_id.clone());
1544                        // Prune deprecated versions (no staging labels) so they
1545                        // don't leak, matching PutSecretValue / UpdateSecret.
1546                        secret.versions.retain(|_, v| !v.stages.is_empty());
1547                        // The value has now actually rotated (synchronously), so
1548                        // this is the point where LastRotatedDate is stamped. The
1549                        // Lambda path does not claim completion here because the
1550                        // rotation runs asynchronously and may not succeed.
1551                        secret.last_rotated_at = Some(now);
1552                    }
1553                }
1554            }
1555        } else if has_lambda {
1556            // RotateImmediately=false with a Lambda: AWS validates the rotation
1557            // configuration by running only the testSecret step. The current
1558            // value is NOT rotated and LastRotatedDate is left untouched, but
1559            // the Lambda's testSecret step needs an AWSPENDING version to read.
1560            // Mirror AWS by staging a temporary AWSPENDING copy of the current
1561            // value (keyed by the rotation token) and removing it once the test
1562            // completes.
1563            if let (Some(arn), Some(current_vid)) =
1564                (lambda_arn.as_ref(), secret.current_version_id.clone())
1565            {
1566                if let Some(cv) = secret.versions.get(&current_vid).cloned() {
1567                    secret.versions.insert(
1568                        version_id.clone(),
1569                        SecretVersion {
1570                            version_id: version_id.clone(),
1571                            secret_string: cv.secret_string.clone(),
1572                            secret_binary: cv.secret_binary.clone(),
1573                            stages: vec!["AWSPENDING".to_string()],
1574                            created_at: now,
1575                        },
1576                    );
1577                    invocation = Some(RotationInvocation {
1578                        lambda_arn: arn.clone(),
1579                        secret_id: secret.arn.clone(),
1580                        client_request_token: version_id.clone(),
1581                        steps: vec!["testSecret"],
1582                        cleanup_pending: Some(PendingCleanup {
1583                            account_id: req.account_id.clone(),
1584                            secret_name: secret.name.clone(),
1585                            version_id: version_id.clone(),
1586                        }),
1587                    });
1588                }
1589            }
1590        }
1591
1592        let response = json!({
1593            "ARN": secret.arn,
1594            "Name": secret.name,
1595            "VersionId": version_id,
1596        });
1597
1598        Ok((AwsResponse::ok_json(response), invocation))
1599    }
1600
1601    fn cancel_rotate_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1602        let body = req.json_body();
1603        let secret_id = require_secret_id(&body)?;
1604
1605        let mut accounts = self.state.write();
1606        let state = accounts.get_or_create(&req.account_id);
1607        let secret = self.find_secret_mut(state, &secret_id)?;
1608
1609        if secret.deleted {
1610            return Err(AwsServiceError::aws_error(
1611                StatusCode::BAD_REQUEST,
1612                "InvalidRequestException",
1613                "You can't perform this operation on the secret because it was marked for deletion.",
1614            ));
1615        }
1616
1617        if secret.rotation_enabled != Some(true) {
1618            return Err(AwsServiceError::aws_error(
1619                StatusCode::BAD_REQUEST,
1620                "InvalidRequestException",
1621                "You can't cancel rotation for a secret that does not have rotation enabled.",
1622            ));
1623        }
1624
1625        secret.rotation_enabled = Some(false);
1626
1627        let response = json!({
1628            "ARN": secret.arn,
1629            "Name": secret.name,
1630        });
1631
1632        Ok(AwsResponse::ok_json(response))
1633    }
1634
1635    fn update_secret_version_stage(
1636        &self,
1637        req: &AwsRequest,
1638    ) -> Result<AwsResponse, AwsServiceError> {
1639        let body = req.json_body();
1640        let secret_id = require_secret_id(&body)?;
1641        let version_stage = body["VersionStage"]
1642            .as_str()
1643            .ok_or_else(|| {
1644                AwsServiceError::aws_error(
1645                    StatusCode::BAD_REQUEST,
1646                    "InvalidParameterException",
1647                    "VersionStage is required",
1648                )
1649            })?
1650            .to_string();
1651        validate_string_length("versionStage", &version_stage, 1, 256)?;
1652        validate_optional_string_length(
1653            "removeFromVersionId",
1654            body["RemoveFromVersionId"].as_str(),
1655            32,
1656            64,
1657        )?;
1658        validate_optional_string_length(
1659            "moveToVersionId",
1660            body["MoveToVersionId"].as_str(),
1661            32,
1662            64,
1663        )?;
1664
1665        let move_to = body["MoveToVersionId"].as_str().map(|s| s.to_string());
1666        let remove_from = body["RemoveFromVersionId"].as_str().map(|s| s.to_string());
1667
1668        let mut accounts = self.state.write();
1669        let state = accounts.get_or_create(&req.account_id);
1670        let secret = self.find_secret_mut(state, &secret_id)?;
1671
1672        // Validate: if moving AWSCURRENT, must specify RemoveFromVersionId
1673        if version_stage == "AWSCURRENT" && move_to.is_some() && remove_from.is_none() {
1674            // Find the version that currently has AWSCURRENT
1675            let current_holder = secret
1676                .versions
1677                .iter()
1678                .find(|(_, v)| v.stages.contains(&"AWSCURRENT".to_string()))
1679                .map(|(id, _)| id.clone());
1680
1681            if let Some(current_vid) = current_holder {
1682                return Err(AwsServiceError::aws_error(
1683                    StatusCode::BAD_REQUEST,
1684                    "InvalidParameterException",
1685                    format!(
1686                        "The parameter RemoveFromVersionId can't be empty. Staging label AWSCURRENT is currently attached to version {current_vid}, so you must explicitly reference that version in RemoveFromVersionId."
1687                    ),
1688                ));
1689            }
1690        }
1691
1692        // Moving the AWSCURRENT label needs the single-AWSCURRENT invariant. A
1693        // caller-supplied RemoveFromVersionId that does not actually hold
1694        // AWSCURRENT must not leave two versions labelled AWSCURRENT: demote
1695        // whichever version really holds it today.
1696        if version_stage == "AWSCURRENT" {
1697            if let Some(ref move_vid) = move_to {
1698                let current_holder = secret
1699                    .versions
1700                    .iter()
1701                    .find(|(id, v)| {
1702                        id.as_str() != move_vid.as_str()
1703                            && v.stages.contains(&"AWSCURRENT".to_string())
1704                    })
1705                    .map(|(id, _)| id.clone());
1706                if let Some(holder) = current_holder {
1707                    demote_current_to_previous(&mut secret.versions, &holder);
1708                }
1709                if let Some(version) = secret.versions.get_mut(move_vid) {
1710                    version.stages.retain(|s| s != "AWSPREVIOUS");
1711                    if !version.stages.contains(&version_stage) {
1712                        version.stages.push(version_stage.clone());
1713                    }
1714                }
1715                secret.current_version_id = Some(move_vid.clone());
1716            } else if let Some(ref remove_vid) = remove_from {
1717                // Remove-only: demote the named version, keeping it the sole
1718                // AWSPREVIOUS.
1719                demote_current_to_previous(&mut secret.versions, remove_vid);
1720            }
1721        } else {
1722            // Generic (custom / AWSPENDING) stage move.
1723            if let Some(ref remove_vid) = remove_from {
1724                if let Some(version) = secret.versions.get_mut(remove_vid) {
1725                    version.stages.retain(|s| s != &version_stage);
1726                }
1727            }
1728            if let Some(ref move_vid) = move_to {
1729                if let Some(version) = secret.versions.get_mut(move_vid) {
1730                    if !version.stages.contains(&version_stage) {
1731                        version.stages.push(version_stage.clone());
1732                    }
1733                }
1734            }
1735        }
1736
1737        let response = json!({
1738            "ARN": secret.arn,
1739            "Name": secret.name,
1740        });
1741
1742        Ok(AwsResponse::ok_json(response))
1743    }
1744
1745    fn batch_get_secret_value(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1746        let body = req.json_body();
1747        validate_optional_string_length("nextToken", body["NextToken"].as_str(), 1, 4096)?;
1748        let secret_id_list = body["SecretIdList"].as_array();
1749        let filters = body["Filters"].as_array();
1750        let max_results = body.get("MaxResults").and_then(|v| v.as_i64());
1751
1752        // Validate: can't use both SecretIdList and Filters
1753        if secret_id_list.is_some() && filters.is_some() {
1754            return Err(AwsServiceError::aws_error(
1755                StatusCode::BAD_REQUEST,
1756                "InvalidParameterException",
1757                "Either 'SecretIdList' or 'Filters' must be provided, but not both.",
1758            ));
1759        }
1760
1761        // Validate: MaxResults requires Filters
1762        if max_results.is_some() && filters.is_none() {
1763            return Err(AwsServiceError::aws_error(
1764                StatusCode::BAD_REQUEST,
1765                "InvalidParameterException",
1766                "'Filters' not specified. 'Filters' must also be specified when 'MaxResults' is provided.",
1767            ));
1768        }
1769
1770        let accounts = self.state.read();
1771        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1772        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1773        let mut secret_values: Vec<Value> = Vec::new();
1774        let mut errors: Vec<Value> = Vec::new();
1775        let mut next_token: Option<String> = None;
1776
1777        if let Some(id_list) = secret_id_list {
1778            for id_val in id_list {
1779                let sid = id_val.as_str().unwrap_or("");
1780                match self.find_secret_ref(state, sid) {
1781                    Ok(secret) => {
1782                        if secret.deleted {
1783                            errors.push(json!({
1784                                "SecretId": sid,
1785                                "ErrorCode": "InvalidRequestException",
1786                                "Message": "Secret is currently marked deleted. Secret can be recovered with RestoreSecret. Secret is currently marked deleted.",
1787                            }));
1788                        } else if let Some(ref current_vid) = secret.current_version_id {
1789                            if let Some(version) = secret.versions.get(current_vid) {
1790                                let mut entry = json!({
1791                                    "ARN": secret.arn,
1792                                    "Name": secret.name,
1793                                    "VersionId": version.version_id,
1794                                    "VersionStages": version.stages,
1795                                    "CreatedDate": version.created_at.timestamp_millis() as f64 / 1000.0,
1796                                });
1797                                if let Some(ref s) = version.secret_string {
1798                                    // Decrypt the same way GetSecretValue does;
1799                                    // pushing the raw stored value leaked
1800                                    // ciphertext when a KMS hook + KmsKeyId were
1801                                    // configured (bug-audit 2026-06-20, 1.10).
1802                                    let plaintext = self
1803                                        .maybe_decrypt_secret_string(
1804                                            &req.account_id,
1805                                            &secret.arn,
1806                                            secret.kms_key_id.as_deref(),
1807                                            Some(s.as_str()),
1808                                        )
1809                                        .unwrap_or_else(|| s.clone());
1810                                    entry["SecretString"] = json!(plaintext);
1811                                }
1812                                if let Some(ref b) = version.secret_binary {
1813                                    entry["SecretBinary"] = json!(base64_encode(b));
1814                                }
1815                                secret_values.push(entry);
1816                            } else {
1817                                errors.push(json!({
1818                                    "SecretId": sid,
1819                                    "ErrorCode": "ResourceNotFoundException",
1820                                    "Message": "Secrets Manager can't find the specified secret.",
1821                                }));
1822                            }
1823                        } else {
1824                            errors.push(json!({
1825                                "SecretId": sid,
1826                                "ErrorCode": "ResourceNotFoundException",
1827                                "Message": "Secrets Manager can't find the specified secret.",
1828                            }));
1829                        }
1830                    }
1831                    Err(_) => {
1832                        errors.push(json!({
1833                            "SecretId": sid,
1834                            "ErrorCode": "ResourceNotFoundException",
1835                            "Message": "Secrets Manager can't find the specified secret.",
1836                        }));
1837                    }
1838                }
1839            }
1840        } else if let Some(filters) = filters {
1841            // Get secrets matching filters
1842            let matching: Vec<&Secret> = state
1843                .secrets
1844                .values()
1845                .filter(|s| {
1846                    if s.deleted {
1847                        return false;
1848                    }
1849                    for filter in filters {
1850                        let key = filter["Key"].as_str().unwrap_or("");
1851                        let values: Vec<&str> = filter["Values"]
1852                            .as_array()
1853                            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1854                            .unwrap_or_default();
1855                        let matches = match key {
1856                            "name" => filter_name(s, &values),
1857                            "description" => filter_description(s, &values),
1858                            "tag-key" => filter_tag_key(s, &values),
1859                            "tag-value" => filter_tag_value(s, &values),
1860                            "all" => filter_all(s, &values),
1861                            _ => true,
1862                        };
1863                        if !matches {
1864                            return false;
1865                        }
1866                    }
1867                    true
1868                })
1869                .collect();
1870
1871            let limit = max_results.unwrap_or(100) as usize;
1872            let mut no_value_found = false;
1873            let mut matching = matching;
1874            matching.sort_by(|a, b| a.name.cmp(&b.name));
1875
1876            // Resume from the incoming NextToken (an opaque base64 offset into
1877            // the sorted result set); skip consumed entries and emit a new token
1878            // when more remain. Previously the extra results were silently
1879            // dropped and no NextToken was ever returned.
1880            let start = body["NextToken"]
1881                .as_str()
1882                .and_then(|t| t.parse::<usize>().ok())
1883                .unwrap_or(0);
1884            let end = (start + limit).min(matching.len());
1885            if end < matching.len() {
1886                next_token = Some(end.to_string());
1887            }
1888
1889            for secret in matching.iter().skip(start).take(limit) {
1890                if let Some(ref current_vid) = secret.current_version_id {
1891                    if let Some(version) = secret.versions.get(current_vid) {
1892                        let mut entry = json!({
1893                            "ARN": secret.arn,
1894                            "Name": secret.name,
1895                            "VersionId": version.version_id,
1896                            "VersionStages": version.stages,
1897                            "CreatedDate": version.created_at.timestamp_millis() as f64 / 1000.0,
1898                        });
1899                        if let Some(ref s) = version.secret_string {
1900                            // Decrypt like GetSecretValue; the raw stored value
1901                            // is ciphertext under a configured KMS hook (1.10).
1902                            let plaintext = self
1903                                .maybe_decrypt_secret_string(
1904                                    &req.account_id,
1905                                    &secret.arn,
1906                                    secret.kms_key_id.as_deref(),
1907                                    Some(s.as_str()),
1908                                )
1909                                .unwrap_or_else(|| s.clone());
1910                            entry["SecretString"] = json!(plaintext);
1911                        }
1912                        if let Some(ref b) = version.secret_binary {
1913                            entry["SecretBinary"] = json!(base64_encode(b));
1914                        }
1915                        secret_values.push(entry);
1916                    } else {
1917                        no_value_found = true;
1918                    }
1919                } else {
1920                    no_value_found = true;
1921                }
1922            }
1923
1924            if no_value_found && secret_values.is_empty() {
1925                return Err(AwsServiceError::aws_error(
1926                    StatusCode::NOT_FOUND,
1927                    "ResourceNotFoundException",
1928                    "Secrets Manager can't find the specified secret.",
1929                ));
1930            }
1931        }
1932
1933        let mut response = json!({
1934            "SecretValues": secret_values,
1935            "Errors": errors,
1936        });
1937
1938        // Remove empty arrays
1939        if errors.is_empty() {
1940            response.as_object_mut().unwrap().remove("Errors");
1941        }
1942        if let Some(token) = next_token {
1943            response
1944                .as_object_mut()
1945                .unwrap()
1946                .insert("NextToken".to_string(), json!(token));
1947        }
1948
1949        Ok(AwsResponse::ok_json(response))
1950    }
1951
1952    fn get_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1953        let body = req.json_body();
1954        let secret_id = require_secret_id(&body)?;
1955
1956        let accounts = self.state.read();
1957        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1958        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1959        let secret = self.find_secret_ref(state, &secret_id)?;
1960
1961        // Real AWS omits ResourcePolicy when none is attached; terraform
1962        // provider and the SDK choke on an empty-string policy.
1963        let mut response = json!({
1964            "ARN": secret.arn,
1965            "Name": secret.name,
1966        });
1967        if let Some(ref policy) = secret.resource_policy {
1968            response["ResourcePolicy"] = json!(policy);
1969        }
1970
1971        Ok(AwsResponse::ok_json(response))
1972    }
1973
1974    fn validate_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1975        let body = req.json_body();
1976        validate_optional_string_length("secretId", body["SecretId"].as_str(), 1, 2048)?;
1977        validate_required("ResourcePolicy", &body["ResourcePolicy"])?;
1978        let policy_str = body["ResourcePolicy"].as_str().ok_or_else(|| {
1979            AwsServiceError::aws_error(
1980                StatusCode::BAD_REQUEST,
1981                "InvalidParameterException",
1982                "ResourcePolicy must be a string",
1983            )
1984        })?;
1985        validate_string_length("resourcePolicy", policy_str, 1, 20480)?;
1986
1987        // If SecretId is provided, verify the secret exists
1988        if let Some(secret_id) = body["SecretId"].as_str() {
1989            let accounts = self.state.read();
1990            let empty = SecretsManagerState::new(&req.account_id, &req.region);
1991            let state = accounts.get(&req.account_id).unwrap_or(&empty);
1992            self.find_secret_key(state, secret_id)?;
1993        }
1994
1995        let response = json!({
1996            "PolicyValidationPassed": true,
1997            "ValidationErrors": [],
1998        });
1999        Ok(AwsResponse::ok_json(response))
2000    }
2001
2002    fn put_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2003        let body = req.json_body();
2004        let secret_id = require_secret_id(&body)?;
2005        validate_required("ResourcePolicy", &body["ResourcePolicy"])?;
2006        validate_optional_string_length(
2007            "resourcePolicy",
2008            body["ResourcePolicy"].as_str(),
2009            1,
2010            20480,
2011        )?;
2012        let policy = body["ResourcePolicy"].as_str().map(|s| s.to_string());
2013
2014        let mut accounts = self.state.write();
2015        let state = accounts.get_or_create(&req.account_id);
2016        let secret = self.find_secret_mut(state, &secret_id)?;
2017        secret.resource_policy = policy;
2018
2019        let response = json!({
2020            "ARN": secret.arn,
2021            "Name": secret.name,
2022        });
2023
2024        Ok(AwsResponse::ok_json(response))
2025    }
2026
2027    fn delete_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2028        let body = req.json_body();
2029        let secret_id = require_secret_id(&body)?;
2030
2031        let mut accounts = self.state.write();
2032        let state = accounts.get_or_create(&req.account_id);
2033        let secret = self.find_secret_mut(state, &secret_id)?;
2034        secret.resource_policy = None;
2035
2036        let response = json!({
2037            "ARN": secret.arn,
2038            "Name": secret.name,
2039        });
2040
2041        Ok(AwsResponse::ok_json(response))
2042    }
2043
2044    fn replicate_secret_to_regions(
2045        &self,
2046        req: &AwsRequest,
2047    ) -> Result<AwsResponse, AwsServiceError> {
2048        let body = req.json_body();
2049        let secret_id = require_secret_id(&body)?;
2050        // AddReplicaRegions[].Region — the regions to replicate into.
2051        let add_regions: Vec<String> = body["AddReplicaRegions"]
2052            .as_array()
2053            .map(|arr| {
2054                arr.iter()
2055                    .filter_map(|r| r["Region"].as_str().map(String::from))
2056                    .collect()
2057            })
2058            .unwrap_or_default();
2059
2060        let mut accounts = self.state.write();
2061        let state = accounts.get_or_create(&req.account_id);
2062        let secret = self.find_secret_mut(state, &secret_id)?;
2063        for region in add_regions {
2064            if !secret.replica_regions.contains(&region) {
2065                secret.replica_regions.push(region);
2066            }
2067        }
2068        let response = json!({
2069            "ARN": secret.arn,
2070            "ReplicationStatus": replication_status_json(&secret.replica_regions),
2071        });
2072        Ok(AwsResponse::ok_json(response))
2073    }
2074
2075    fn remove_regions_from_replication(
2076        &self,
2077        req: &AwsRequest,
2078    ) -> Result<AwsResponse, AwsServiceError> {
2079        let body = req.json_body();
2080        let secret_id = require_secret_id(&body)?;
2081        let remove_regions: Vec<String> = body["RemoveReplicaRegions"]
2082            .as_array()
2083            .map(|arr| {
2084                arr.iter()
2085                    .filter_map(|r| r.as_str().map(String::from))
2086                    .collect()
2087            })
2088            .unwrap_or_default();
2089
2090        let mut accounts = self.state.write();
2091        let state = accounts.get_or_create(&req.account_id);
2092        let secret = self.find_secret_mut(state, &secret_id)?;
2093        secret
2094            .replica_regions
2095            .retain(|r| !remove_regions.contains(r));
2096        let response = json!({
2097            "ARN": secret.arn,
2098            "ReplicationStatus": replication_status_json(&secret.replica_regions),
2099        });
2100        Ok(AwsResponse::ok_json(response))
2101    }
2102
2103    fn stop_replication_to_replica(
2104        &self,
2105        req: &AwsRequest,
2106    ) -> Result<AwsResponse, AwsServiceError> {
2107        let body = req.json_body();
2108        let secret_id = require_secret_id(&body)?;
2109
2110        let accounts = self.state.read();
2111        let empty = SecretsManagerState::new(&req.account_id, &req.region);
2112        let state = accounts.get(&req.account_id).unwrap_or(&empty);
2113        let secret = self.find_secret_ref(state, &secret_id)?;
2114
2115        let response = json!({
2116            "ARN": secret.arn,
2117        });
2118        Ok(AwsResponse::ok_json(response))
2119    }
2120
2121    /// Find a secret by name, full ARN, or partial ARN (mutable).
2122    fn find_secret_mut<'a>(
2123        &self,
2124        state: &'a mut crate::state::SecretsManagerState,
2125        secret_id: &str,
2126    ) -> Result<&'a mut Secret, AwsServiceError> {
2127        let key = self.find_secret_key(state, secret_id)?;
2128        Ok(state.secrets.get_mut(&key).unwrap())
2129    }
2130
2131    fn find_secret_key(
2132        &self,
2133        state: &crate::state::SecretsManagerState,
2134        secret_id: &str,
2135    ) -> Result<String, AwsServiceError> {
2136        let key = if state.secrets.contains_key(secret_id) {
2137            Some(secret_id.to_string())
2138        } else if let Some(secret) = state.secrets.values().find(|s| s.arn == secret_id) {
2139            Some(secret.name.clone())
2140        } else if secret_id.starts_with("arn:aws:secretsmanager:") {
2141            state
2142                .secrets
2143                .values()
2144                .find(|s| s.arn.starts_with(secret_id))
2145                .map(|s| s.name.clone())
2146        } else {
2147            None
2148        };
2149
2150        match key {
2151            // A secret whose recovery window has elapsed is treated as gone.
2152            Some(key)
2153                if state
2154                    .secrets
2155                    .get(&key)
2156                    .map(|s| secret_recovery_window_elapsed(s, Utc::now()))
2157                    .unwrap_or(false) =>
2158            {
2159                Err(secret_not_found())
2160            }
2161            Some(key) => Ok(key),
2162            None => Err(secret_not_found()),
2163        }
2164    }
2165
2166    /// Find a secret by name, full ARN, or partial ARN (immutable).
2167    fn find_secret_ref<'a>(
2168        &self,
2169        state: &'a crate::state::SecretsManagerState,
2170        secret_id: &str,
2171    ) -> Result<&'a Secret, AwsServiceError> {
2172        let secret = state
2173            .secrets
2174            .get(secret_id)
2175            .or_else(|| state.secrets.values().find(|s| s.arn == secret_id))
2176            .or_else(|| {
2177                if secret_id.starts_with("arn:aws:secretsmanager:") {
2178                    state
2179                        .secrets
2180                        .values()
2181                        .find(|s| s.arn.starts_with(secret_id))
2182                } else {
2183                    None
2184                }
2185            });
2186
2187        match secret {
2188            // A secret whose recovery window has elapsed is treated as gone.
2189            Some(secret) if secret_recovery_window_elapsed(secret, Utc::now()) => {
2190                Err(secret_not_found())
2191            }
2192            Some(secret) => Ok(secret),
2193            None => Err(secret_not_found()),
2194        }
2195    }
2196}
2197
2198/// The standard "secret can't be found" error, shared by the resolution helpers.
2199fn secret_not_found() -> AwsServiceError {
2200    AwsServiceError::aws_error(
2201        StatusCode::NOT_FOUND,
2202        "ResourceNotFoundException",
2203        "Secrets Manager can't find the specified secret.",
2204    )
2205}
2206
2207/// Persist the current Secrets Manager state as a snapshot. Offloads the
2208/// serialization and blocking file write to the Tokio blocking pool. Noop when
2209/// `store` is `None` (memory mode). Shared by
2210/// `SecretsManagerService::save_snapshot` and
2211/// the CloudFormation provisioner's post-provision persist hook so both route
2212/// through the same serialize-and-write path.
2213pub async fn save_secretsmanager_snapshot(
2214    state: &SharedSecretsManagerState,
2215    store: Option<Arc<dyn SnapshotStore>>,
2216    lock: &AsyncMutex<()>,
2217) {
2218    let Some(store) = store else {
2219        return;
2220    };
2221    let _guard = lock.lock().await;
2222    let snapshot = SecretsManagerSnapshot {
2223        schema_version: SECRETSMANAGER_SNAPSHOT_SCHEMA_VERSION,
2224        state: None,
2225        accounts: Some(state.read().clone()),
2226    };
2227    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2228        let bytes = serde_json::to_vec(&snapshot)
2229            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
2230        store.save(&bytes)
2231    })
2232    .await;
2233    match join {
2234        Ok(Ok(())) => {}
2235        Ok(Err(err)) => tracing::error!(%err, "failed to write secretsmanager snapshot"),
2236        Err(err) => tracing::error!(%err, "secretsmanager snapshot task panicked"),
2237    }
2238}
2239
2240/// Parsed + validated inputs for `CreateSecret`.
2241struct CreateSecretInput {
2242    name: String,
2243    client_request_token: Option<String>,
2244    description: Option<String>,
2245    kms_key_id: Option<String>,
2246    secret_string: Option<String>,
2247    secret_binary: Option<Vec<u8>>,
2248    tags: Vec<(String, String)>,
2249    /// Regions requested via CreateSecret's `AddReplicaRegions`. Persisted
2250    /// as replica regions so DescribeSecret and the CreateSecret response
2251    /// both report `ReplicationStatus`.
2252    add_replica_regions: Vec<String>,
2253}
2254
2255impl CreateSecretInput {
2256    fn from_body(body: &Value) -> Result<Self, AwsServiceError> {
2257        validate_required("Name", &body["Name"])?;
2258        let name = body["Name"]
2259            .as_str()
2260            .ok_or_else(|| {
2261                AwsServiceError::aws_error(
2262                    StatusCode::BAD_REQUEST,
2263                    "InvalidParameterException",
2264                    "Name is required",
2265                )
2266            })?
2267            .to_string();
2268        validate_string_length("name", &name, 1, 512)?;
2269        validate_secret_name_charset(&name)?;
2270        validate_optional_string_length(
2271            "clientRequestToken",
2272            body["ClientRequestToken"].as_str(),
2273            32,
2274            64,
2275        )?;
2276        validate_optional_string_length("description", body["Description"].as_str(), 0, 2048)?;
2277        validate_optional_string_length("kmsKeyId", body["KmsKeyId"].as_str(), 0, 2048)?;
2278        validate_optional_string_length("secretString", body["SecretString"].as_str(), 1, 65536)?;
2279
2280        let secret_string = body["SecretString"].as_str().map(|s| s.to_string());
2281        let secret_binary = parse_secret_binary(body)?;
2282        if secret_string.is_some() && secret_binary.is_some() {
2283            return Err(both_secret_fields_error());
2284        }
2285
2286        Ok(Self {
2287            name,
2288            client_request_token: body["ClientRequestToken"].as_str().map(|s| s.to_string()),
2289            description: body["Description"].as_str().map(|s| s.to_string()),
2290            kms_key_id: body["KmsKeyId"].as_str().map(|s| s.to_string()),
2291            secret_string,
2292            secret_binary,
2293            tags: parse_tags(&body["Tags"]),
2294            add_replica_regions: body["AddReplicaRegions"]
2295                .as_array()
2296                .map(|arr| {
2297                    arr.iter()
2298                        .filter_map(|r| r["Region"].as_str().map(String::from))
2299                        .collect()
2300                })
2301                .unwrap_or_default(),
2302        })
2303    }
2304}
2305
2306#[async_trait]
2307impl AwsService for SecretsManagerService {
2308    fn service_name(&self) -> &str {
2309        "secretsmanager"
2310    }
2311
2312    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2313        let mutates = is_mutating_action(req.action.as_str());
2314        let result = match req.action.as_str() {
2315            "CreateSecret" => self.create_secret(&req),
2316            "GetSecretValue" => self.get_secret_value(&req),
2317            "PutSecretValue" => self.put_secret_value(&req),
2318            "UpdateSecret" => self.update_secret(&req),
2319            "DeleteSecret" => self.delete_secret(&req),
2320            "RestoreSecret" => self.restore_secret(&req),
2321            "DescribeSecret" => self.describe_secret(&req),
2322            "ListSecrets" => self.list_secrets(&req),
2323            "TagResource" => self.tag_resource(&req),
2324            "UntagResource" => self.untag_resource(&req),
2325            "ListSecretVersionIds" => self.list_secret_version_ids(&req),
2326            "GetRandomPassword" => self.get_random_password(&req),
2327            "RotateSecret" => {
2328                let (response, invocation) = self.rotate_secret(&req)?;
2329                if let Some(inv) = invocation {
2330                    if let Some(cleanup) = inv.cleanup_pending.as_ref() {
2331                        // Test-only rotation (RotateImmediately=false): run the
2332                        // testSecret invocation and remove the temporary
2333                        // AWSPENDING version SYNCHRONOUSLY, inside the handler's
2334                        // critical section, so the mutating-action snapshot
2335                        // taken at the end of `handle` never captures (and can't
2336                        // restore) the temporary version.
2337                        if let Some(ref bus) = self.delivery_bus {
2338                            run_rotation_steps(bus, &inv).await;
2339                        }
2340                        remove_rotation_test_pending(&self.state, cleanup);
2341                    } else {
2342                        // Full rotation: AWS invokes the rotation Lambda
2343                        // asynchronously for each step. The Lambda's own
2344                        // PutSecretValue calls persist their own snapshots.
2345                        let bus = self.delivery_bus.clone();
2346                        tokio::spawn(async move {
2347                            if let Some(bus) = bus {
2348                                run_rotation_steps(&bus, &inv).await;
2349                            }
2350                        });
2351                    }
2352                }
2353                Ok(response)
2354            }
2355            "CancelRotateSecret" => self.cancel_rotate_secret(&req),
2356            "UpdateSecretVersionStage" => self.update_secret_version_stage(&req),
2357            "BatchGetSecretValue" => self.batch_get_secret_value(&req),
2358            "GetResourcePolicy" => self.get_resource_policy(&req),
2359            "PutResourcePolicy" => self.put_resource_policy(&req),
2360            "DeleteResourcePolicy" => self.delete_resource_policy(&req),
2361            "ValidateResourcePolicy" => self.validate_resource_policy(&req),
2362            "ReplicateSecretToRegions" => self.replicate_secret_to_regions(&req),
2363            "RemoveRegionsFromReplication" => self.remove_regions_from_replication(&req),
2364            "StopReplicationToReplica" => self.stop_replication_to_replica(&req),
2365            _ => Err(AwsServiceError::action_not_implemented(
2366                "secretsmanager",
2367                &req.action,
2368            )),
2369        };
2370        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
2371            self.save_snapshot().await;
2372        }
2373        result.map_err(remap_validation_error)
2374    }
2375
2376    fn supported_actions(&self) -> &[&str] {
2377        &[
2378            "CreateSecret",
2379            "GetSecretValue",
2380            "PutSecretValue",
2381            "UpdateSecret",
2382            "DeleteSecret",
2383            "RestoreSecret",
2384            "DescribeSecret",
2385            "ListSecrets",
2386            "TagResource",
2387            "UntagResource",
2388            "ListSecretVersionIds",
2389            "GetRandomPassword",
2390            "RotateSecret",
2391            "CancelRotateSecret",
2392            "UpdateSecretVersionStage",
2393            "BatchGetSecretValue",
2394            "GetResourcePolicy",
2395            "PutResourcePolicy",
2396            "DeleteResourcePolicy",
2397            "ValidateResourcePolicy",
2398            "ReplicateSecretToRegions",
2399            "RemoveRegionsFromReplication",
2400            "StopReplicationToReplica",
2401        ]
2402    }
2403}
2404
2405#[path = "service_helpers.rs"]
2406mod service_helpers;
2407pub(crate) use service_helpers::*;
2408
2409/// The shared `fakecloud_core::validation` helpers raise
2410/// `ValidationException`, but the Secrets Manager Smithy model does not
2411/// declare `ValidationException` on any operation — its declared input
2412/// error is `InvalidParameterException`. Translate at the dispatcher
2413/// boundary so the wire-level error code matches real AWS without
2414/// duplicating every validator.
2415/// Build the `ReplicationStatus` array from a secret's replica regions.
2416/// Each replica reports `InSync`, matching a healthy replication.
2417fn replication_status_json(regions: &[String]) -> Value {
2418    Value::Array(
2419        regions
2420            .iter()
2421            .map(|r| {
2422                json!({
2423                    "Region": r,
2424                    "Status": "InSync",
2425                    "StatusMessage": "Replication succeeded",
2426                })
2427            })
2428            .collect(),
2429    )
2430}
2431
2432fn remap_validation_error(err: AwsServiceError) -> AwsServiceError {
2433    match err {
2434        AwsServiceError::AwsError {
2435            status,
2436            code,
2437            message,
2438            extra_fields,
2439            headers,
2440        } if code == "ValidationException" => AwsServiceError::AwsError {
2441            status,
2442            code: "InvalidParameterException".to_string(),
2443            message,
2444            extra_fields,
2445            headers,
2446        },
2447        other => other,
2448    }
2449}
2450
2451/// Extract the owning account-id from an `arn:aws:secretsmanager:...:ACCOUNT:secret:...`
2452/// secret id. Returns `caller_account` when the input is a bare name
2453/// or a same-account ARN.
2454fn secret_owner_account(secret_id: &str, caller_account: &str) -> String {
2455    if !secret_id.starts_with("arn:aws:secretsmanager:") {
2456        return caller_account.to_string();
2457    }
2458    let parts: Vec<&str> = secret_id.splitn(7, ':').collect();
2459    if parts.len() < 5 {
2460        return caller_account.to_string();
2461    }
2462    let account = parts[4];
2463    if account.is_empty() {
2464        caller_account.to_string()
2465    } else {
2466        account.to_string()
2467    }
2468}
2469
2470/// Evaluate a Secrets Manager resource policy against a cross-account
2471/// caller. Empty policy implicitly denies (real AWS behaviour). Uses
2472/// the shared IAM evaluator so the same policy semantics apply
2473/// service-wide.
2474fn resource_policy_allows(policy_doc: &str, caller_account: &str, secret_arn: &str) -> bool {
2475    if policy_doc.is_empty() {
2476        return false;
2477    }
2478    use fakecloud_core::auth::{Principal, PrincipalType};
2479    use fakecloud_iam::evaluator::{evaluate, EvalRequest, PolicyDocument};
2480    let doc = PolicyDocument::parse(policy_doc);
2481    let principal_arn = Arn::global("iam", caller_account, "root").to_string();
2482    let principal = Principal {
2483        arn: principal_arn.clone(),
2484        user_id: principal_arn.clone(),
2485        account_id: caller_account.to_string(),
2486        principal_type: PrincipalType::User,
2487        source_identity: None,
2488        tags: None,
2489    };
2490    let req = EvalRequest {
2491        principal: &principal,
2492        action: "secretsmanager:GetSecretValue".to_string(),
2493        resource: secret_arn.to_string(),
2494        context: Default::default(),
2495    };
2496    matches!(
2497        evaluate(&[doc], &req),
2498        fakecloud_iam::evaluator::Decision::Allow
2499    )
2500}
2501
2502#[cfg(test)]
2503#[path = "service_tests.rs"]
2504mod tests;