Skip to main content

fakecloud_iam/
xml_responses.rs

1use base64::Engine;
2
3use crate::state::{IamAccessKey, IamPolicy, IamRole, IamUser};
4
5fn xml_escape(s: &str) -> String {
6    s.replace('&', "&")
7        .replace('<', "&lt;")
8        .replace('>', "&gt;")
9        .replace('"', "&quot;")
10}
11
12/// URL-encode a policy document for XML embedding (like AWS does).
13fn url_encode_policy(s: &str) -> String {
14    let mut result = String::new();
15    for byte in s.bytes() {
16        match byte {
17            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
18                result.push(byte as char);
19            }
20            _ => {
21                use std::fmt::Write;
22                write!(result, "%{:02X}", byte).unwrap();
23            }
24        }
25    }
26    result
27}
28
29fn tags_xml(tags: &[crate::state::Tag]) -> String {
30    if tags.is_empty() {
31        return String::new();
32    }
33    tags.iter()
34        .map(|t| {
35            format!(
36                "        <member>\n          <Key>{}</Key>\n          <Value>{}</Value>\n        </member>",
37                xml_escape(&t.key),
38                xml_escape(&t.value)
39            )
40        })
41        .collect::<Vec<_>>()
42        .join("\n")
43}
44
45fn user_xml(user: &IamUser) -> String {
46    let tags_section = if user.tags.is_empty() {
47        String::new()
48    } else {
49        let tags_members = tags_xml(&user.tags);
50        format!("\n      <Tags>\n{tags_members}\n      </Tags>")
51    };
52
53    let pb_section = user
54        .permissions_boundary
55        .as_ref()
56        .map(|pb| {
57            format!(
58                "\n      <PermissionsBoundary>\n        <PermissionsBoundaryType>PermissionsBoundaryPolicy</PermissionsBoundaryType>\n        <PermissionsBoundaryArn>{pb}</PermissionsBoundaryArn>\n      </PermissionsBoundary>"
59            )
60        })
61        .unwrap_or_default();
62
63    format!(
64        r#"    <User>
65      <Path>{path}</Path>
66      <UserName>{name}</UserName>
67      <UserId>{id}</UserId>
68      <Arn>{arn}</Arn>
69      <CreateDate>{date}</CreateDate>{tags_section}{pb_section}
70    </User>"#,
71        path = user.path,
72        name = user.user_name,
73        id = user.user_id,
74        arn = user.arn,
75        date = user.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
76    )
77}
78
79fn role_xml(role: &IamRole) -> String {
80    let tags_section = if role.tags.is_empty() {
81        String::new()
82    } else {
83        let tags_members = tags_xml(&role.tags);
84        format!("\n      <Tags>\n{tags_members}\n      </Tags>")
85    };
86
87    let pb_section = role
88        .permissions_boundary
89        .as_ref()
90        .map(|pb| {
91            format!(
92                "\n      <PermissionsBoundary>\n        <PermissionsBoundaryType>PermissionsBoundaryPolicy</PermissionsBoundaryType>\n        <PermissionsBoundaryArn>{pb}</PermissionsBoundaryArn>\n      </PermissionsBoundary>"
93            )
94        })
95        .unwrap_or_default();
96
97    let description_section = match &role.description {
98        Some(desc) => format!("\n      <Description>{}</Description>", xml_escape(desc)),
99        None => String::new(),
100    };
101
102    format!(
103        r#"    <Role>
104      <Path>{path}</Path>
105      <RoleName>{name}</RoleName>
106      <RoleId>{id}</RoleId>
107      <Arn>{arn}</Arn>
108      <CreateDate>{date}</CreateDate>
109      <AssumeRolePolicyDocument>{policy}</AssumeRolePolicyDocument>{description_section}
110      <MaxSessionDuration>{max_session}</MaxSessionDuration>
111      <RoleLastUsed/>{tags_section}{pb_section}
112    </Role>"#,
113        path = role.path,
114        name = role.role_name,
115        id = role.role_id,
116        arn = role.arn,
117        date = role.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
118        policy = url_encode_policy(&role.assume_role_policy_document),
119        max_session = role.max_session_duration,
120    )
121}
122
123pub fn create_user_response(user: &IamUser, request_id: &str) -> String {
124    let user_xml = user_xml(user);
125    format!(
126        r#"<?xml version="1.0" encoding="UTF-8"?>
127<CreateUserResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
128  <CreateUserResult>
129{user_xml}
130  </CreateUserResult>
131  <ResponseMetadata>
132    <RequestId>{request_id}</RequestId>
133  </ResponseMetadata>
134</CreateUserResponse>"#,
135    )
136}
137
138pub fn get_user_response(user: &IamUser, request_id: &str) -> String {
139    let user_xml = user_xml(user);
140    format!(
141        r#"<?xml version="1.0" encoding="UTF-8"?>
142<GetUserResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
143  <GetUserResult>
144{user_xml}
145  </GetUserResult>
146  <ResponseMetadata>
147    <RequestId>{request_id}</RequestId>
148  </ResponseMetadata>
149</GetUserResponse>"#,
150    )
151}
152
153pub fn list_users_response(users: &[IamUser], request_id: &str) -> String {
154    let members: String = users
155        .iter()
156        .map(|u| {
157            // ListUsers never includes Tags or PermissionsBoundary (AWS behavior)
158            format!(
159                r#"      <member>
160        <Path>{path}</Path>
161        <UserName>{name}</UserName>
162        <UserId>{id}</UserId>
163        <Arn>{arn}</Arn>
164        <CreateDate>{date}</CreateDate>
165      </member>"#,
166                path = u.path,
167                name = u.user_name,
168                id = u.user_id,
169                arn = u.arn,
170                date = u.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
171            )
172        })
173        .collect::<Vec<_>>()
174        .join("\n");
175
176    format!(
177        r#"<?xml version="1.0" encoding="UTF-8"?>
178<ListUsersResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
179  <ListUsersResult>
180    <IsTruncated>false</IsTruncated>
181    <Users>
182{members}
183    </Users>
184  </ListUsersResult>
185  <ResponseMetadata>
186    <RequestId>{request_id}</RequestId>
187  </ResponseMetadata>
188</ListUsersResponse>"#,
189    )
190}
191
192pub fn create_access_key_response(key: &IamAccessKey, request_id: &str) -> String {
193    format!(
194        r#"<?xml version="1.0" encoding="UTF-8"?>
195<CreateAccessKeyResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
196  <CreateAccessKeyResult>
197    <AccessKey>
198      <UserName>{user}</UserName>
199      <AccessKeyId>{key_id}</AccessKeyId>
200      <Status>{status}</Status>
201      <SecretAccessKey>{secret}</SecretAccessKey>
202      <CreateDate>{date}</CreateDate>
203    </AccessKey>
204  </CreateAccessKeyResult>
205  <ResponseMetadata>
206    <RequestId>{request_id}</RequestId>
207  </ResponseMetadata>
208</CreateAccessKeyResponse>"#,
209        user = key.user_name,
210        key_id = key.access_key_id,
211        status = key.status,
212        secret = key.secret_access_key,
213        date = key.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
214    )
215}
216
217pub fn list_access_keys_response(
218    keys: &[IamAccessKey],
219    user_name: &str,
220    is_truncated: bool,
221    marker: Option<&str>,
222    request_id: &str,
223) -> String {
224    let members: String = keys
225        .iter()
226        .map(|k| {
227            format!(
228                r#"      <member>
229        <UserName>{user}</UserName>
230        <AccessKeyId>{key_id}</AccessKeyId>
231        <Status>{status}</Status>
232        <CreateDate>{date}</CreateDate>
233      </member>"#,
234                user = k.user_name,
235                key_id = k.access_key_id,
236                status = k.status,
237                date = k.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
238            )
239        })
240        .collect::<Vec<_>>()
241        .join("\n");
242
243    let marker_section = if let Some(m) = marker {
244        format!("\n    <Marker>{}</Marker>", xml_escape(m))
245    } else {
246        String::new()
247    };
248
249    format!(
250        r#"<?xml version="1.0" encoding="UTF-8"?>
251<ListAccessKeysResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
252  <ListAccessKeysResult>
253    <UserName>{user_name}</UserName>
254    <IsTruncated>{is_truncated}</IsTruncated>{marker_section}
255    <AccessKeyMetadata>
256{members}
257    </AccessKeyMetadata>
258  </ListAccessKeysResult>
259  <ResponseMetadata>
260    <RequestId>{request_id}</RequestId>
261  </ResponseMetadata>
262</ListAccessKeysResponse>"#,
263    )
264}
265
266pub fn create_role_response(role: &IamRole, request_id: &str) -> String {
267    let role_xml = role_xml(role);
268    format!(
269        r#"<?xml version="1.0" encoding="UTF-8"?>
270<CreateRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
271  <CreateRoleResult>
272{role_xml}
273  </CreateRoleResult>
274  <ResponseMetadata>
275    <RequestId>{request_id}</RequestId>
276  </ResponseMetadata>
277</CreateRoleResponse>"#,
278    )
279}
280
281pub fn get_role_response(role: &IamRole, request_id: &str) -> String {
282    let role_xml = role_xml(role);
283    format!(
284        r#"<?xml version="1.0" encoding="UTF-8"?>
285<GetRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
286  <GetRoleResult>
287{role_xml}
288  </GetRoleResult>
289  <ResponseMetadata>
290    <RequestId>{request_id}</RequestId>
291  </ResponseMetadata>
292</GetRoleResponse>"#,
293    )
294}
295
296pub fn list_roles_response(roles: &[IamRole], request_id: &str) -> String {
297    let members: String = roles
298        .iter()
299        .map(|r| {
300            // ListRoles does NOT include Tags, PermissionsBoundary, or RoleLastUsed
301            let description_section = match &r.description {
302                Some(desc) => format!("\n        <Description>{}</Description>", xml_escape(desc)),
303                None => String::new(),
304            };
305            format!(
306                r#"      <member>
307        <Path>{path}</Path>
308        <RoleName>{name}</RoleName>
309        <RoleId>{id}</RoleId>
310        <Arn>{arn}</Arn>
311        <CreateDate>{date}</CreateDate>
312        <AssumeRolePolicyDocument>{policy}</AssumeRolePolicyDocument>{description_section}
313        <MaxSessionDuration>{max_session}</MaxSessionDuration>
314      </member>"#,
315                path = r.path,
316                name = r.role_name,
317                id = r.role_id,
318                arn = r.arn,
319                date = r.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
320                policy = url_encode_policy(&r.assume_role_policy_document),
321                max_session = r.max_session_duration,
322            )
323        })
324        .collect::<Vec<_>>()
325        .join("\n");
326
327    format!(
328        r#"<?xml version="1.0" encoding="UTF-8"?>
329<ListRolesResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
330  <ListRolesResult>
331    <IsTruncated>false</IsTruncated>
332    <Roles>
333{members}
334    </Roles>
335  </ListRolesResult>
336  <ResponseMetadata>
337    <RequestId>{request_id}</RequestId>
338  </ResponseMetadata>
339</ListRolesResponse>"#,
340    )
341}
342
343pub fn list_roles_response_paginated(
344    roles: &[IamRole],
345    is_truncated: bool,
346    marker: Option<&str>,
347    request_id: &str,
348) -> String {
349    let members: String = roles
350        .iter()
351        .map(|r| {
352            // ListRoles does NOT include Tags, PermissionsBoundary, or RoleLastUsed
353            let description_section = match &r.description {
354                Some(desc) => format!("\n        <Description>{}</Description>", xml_escape(desc)),
355                None => String::new(),
356            };
357            format!(
358                r#"      <member>
359        <Path>{path}</Path>
360        <RoleName>{name}</RoleName>
361        <RoleId>{id}</RoleId>
362        <Arn>{arn}</Arn>
363        <CreateDate>{date}</CreateDate>
364        <AssumeRolePolicyDocument>{policy}</AssumeRolePolicyDocument>{description_section}
365        <MaxSessionDuration>{max_session}</MaxSessionDuration>
366      </member>"#,
367                path = r.path,
368                name = r.role_name,
369                id = r.role_id,
370                arn = r.arn,
371                date = r.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
372                policy = url_encode_policy(&r.assume_role_policy_document),
373                max_session = r.max_session_duration,
374            )
375        })
376        .collect::<Vec<_>>()
377        .join("\n");
378
379    let marker_section = if let Some(m) = marker {
380        format!("\n    <Marker>{}</Marker>", xml_escape(m))
381    } else {
382        String::new()
383    };
384
385    format!(
386        r#"<?xml version="1.0" encoding="UTF-8"?>
387<ListRolesResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
388  <ListRolesResult>
389    <IsTruncated>{is_truncated}</IsTruncated>{marker_section}
390    <Roles>
391{members}
392    </Roles>
393  </ListRolesResult>
394  <ResponseMetadata>
395    <RequestId>{request_id}</RequestId>
396  </ResponseMetadata>
397</ListRolesResponse>"#,
398    )
399}
400
401pub fn create_policy_response(policy: &IamPolicy, request_id: &str) -> String {
402    let tags_section = if policy.tags.is_empty() {
403        String::new()
404    } else {
405        let tags_members = tags_xml(&policy.tags);
406        format!("\n      <Tags>\n{tags_members}\n      </Tags>")
407    };
408
409    format!(
410        r#"<?xml version="1.0" encoding="UTF-8"?>
411<CreatePolicyResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
412  <CreatePolicyResult>
413    <Policy>
414      <PolicyName>{name}</PolicyName>
415      <PolicyId>{id}</PolicyId>
416      <Arn>{arn}</Arn>
417      <Path>{path}</Path>
418      <DefaultVersionId>{default_version}</DefaultVersionId>
419      <AttachmentCount>{attachment_count}</AttachmentCount>
420      <IsAttachable>true</IsAttachable>
421      <CreateDate>{date}</CreateDate>{tags_section}
422    </Policy>
423  </CreatePolicyResult>
424  <ResponseMetadata>
425    <RequestId>{request_id}</RequestId>
426  </ResponseMetadata>
427</CreatePolicyResponse>"#,
428        name = policy.policy_name,
429        id = policy.policy_id,
430        arn = policy.arn,
431        path = policy.path,
432        default_version = policy.default_version_id,
433        attachment_count = policy.attachment_count,
434        date = policy.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
435    )
436}
437
438pub fn list_policies_response(policies: &[IamPolicy], request_id: &str) -> String {
439    let members: String = policies
440        .iter()
441        .map(|p| {
442            // ListPolicies never includes Tags or Description (AWS behavior)
443            format!(
444                r#"      <member>
445        <PolicyName>{name}</PolicyName>
446        <PolicyId>{id}</PolicyId>
447        <Arn>{arn}</Arn>
448        <Path>{path}</Path>
449        <DefaultVersionId>{default_version}</DefaultVersionId>
450        <AttachmentCount>{attachment_count}</AttachmentCount>
451        <IsAttachable>true</IsAttachable>
452        <CreateDate>{date}</CreateDate>
453      </member>"#,
454                name = p.policy_name,
455                id = p.policy_id,
456                arn = p.arn,
457                path = p.path,
458                default_version = p.default_version_id,
459                attachment_count = p.attachment_count,
460                date = p.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
461            )
462        })
463        .collect::<Vec<_>>()
464        .join("\n");
465
466    format!(
467        r#"<?xml version="1.0" encoding="UTF-8"?>
468<ListPoliciesResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
469  <ListPoliciesResult>
470    <IsTruncated>false</IsTruncated>
471    <Policies>
472{members}
473    </Policies>
474  </ListPoliciesResult>
475  <ResponseMetadata>
476    <RequestId>{request_id}</RequestId>
477  </ResponseMetadata>
478</ListPoliciesResponse>"#,
479    )
480}
481
482pub fn get_policy_response(policy: &IamPolicy, request_id: &str) -> String {
483    // GetPolicy always includes Tags, even if empty (AWS behavior)
484    let tags_section = if policy.tags.is_empty() {
485        "\n      <Tags/>".to_string()
486    } else {
487        let tags_members = tags_xml(&policy.tags);
488        format!("\n      <Tags>\n{tags_members}\n      </Tags>")
489    };
490
491    let description_section = if policy.description.is_empty() {
492        String::new()
493    } else {
494        format!(
495            "\n      <Description>{}</Description>",
496            xml_escape(&policy.description)
497        )
498    };
499
500    format!(
501        r#"<?xml version="1.0" encoding="UTF-8"?>
502<GetPolicyResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
503  <GetPolicyResult>
504    <Policy>
505      <PolicyName>{name}</PolicyName>
506      <PolicyId>{id}</PolicyId>
507      <Arn>{arn}</Arn>
508      <Path>{path}</Path>
509      <DefaultVersionId>{default_version}</DefaultVersionId>
510      <AttachmentCount>{attachment_count}</AttachmentCount>
511      <IsAttachable>true</IsAttachable>
512      <CreateDate>{date}</CreateDate>{description_section}{tags_section}
513    </Policy>
514  </GetPolicyResult>
515  <ResponseMetadata>
516    <RequestId>{request_id}</RequestId>
517  </ResponseMetadata>
518</GetPolicyResponse>"#,
519        name = policy.policy_name,
520        id = policy.policy_id,
521        arn = policy.arn,
522        path = policy.path,
523        default_version = policy.default_version_id,
524        attachment_count = policy.attachment_count,
525        date = policy.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
526    )
527}
528
529pub fn list_role_policies_response(policy_names: &[String], request_id: &str) -> String {
530    let members: String = policy_names
531        .iter()
532        .map(|name| format!("      <member>{name}</member>"))
533        .collect::<Vec<_>>()
534        .join("\n");
535
536    format!(
537        r#"<?xml version="1.0" encoding="UTF-8"?>
538<ListRolePoliciesResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
539  <ListRolePoliciesResult>
540    <IsTruncated>false</IsTruncated>
541    <PolicyNames>
542{members}
543    </PolicyNames>
544  </ListRolePoliciesResult>
545  <ResponseMetadata>
546    <RequestId>{request_id}</RequestId>
547  </ResponseMetadata>
548</ListRolePoliciesResponse>"#,
549    )
550}
551
552pub fn get_caller_identity_response(
553    account_id: &str,
554    arn: &str,
555    user_id: &str,
556    request_id: &str,
557) -> String {
558    format!(
559        r#"<?xml version="1.0" encoding="UTF-8"?>
560<GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
561  <GetCallerIdentityResult>
562    <Arn>{arn}</Arn>
563    <UserId>{user_id}</UserId>
564    <Account>{account_id}</Account>
565  </GetCallerIdentityResult>
566  <ResponseMetadata>
567    <RequestId>{request_id}</RequestId>
568  </ResponseMetadata>
569</GetCallerIdentityResponse>"#,
570    )
571}
572
573/// Pre-generated STS credentials to be returned in XML responses.
574pub struct StsCredentials {
575    pub access_key_id: String,
576    pub secret_access_key: String,
577    pub session_token: String,
578}
579
580impl StsCredentials {
581    pub fn generate() -> Self {
582        Self {
583            access_key_id: generate_access_key_id(),
584            secret_access_key: generate_secret_access_key(),
585            session_token: generate_session_token(),
586        }
587    }
588}
589
590#[allow(clippy::too_many_arguments)]
591pub fn assume_role_response(
592    role_arn: &str,
593    role_session_name: &str,
594    role_id: &str,
595    account_id: &str,
596    partition: &str,
597    creds: &StsCredentials,
598    expiration: &str,
599    request_id: &str,
600) -> String {
601    // Extract role name from ARN
602    let role_name = role_arn.rsplit('/').next().unwrap_or("unknown");
603    let assumed_role_arn = format!(
604        "arn:{}:sts::{}:assumed-role/{}/{}",
605        partition, account_id, role_name, role_session_name
606    );
607
608    format!(
609        r#"<?xml version="1.0" encoding="UTF-8"?>
610<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
611  <AssumeRoleResult>
612    <Credentials>
613      <AccessKeyId>{access_key_id}</AccessKeyId>
614      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
615      <SessionToken>{session_token}</SessionToken>
616      <Expiration>{expiration}</Expiration>
617    </Credentials>
618    <AssumedRoleUser>
619      <AssumedRoleId>{role_id}:{session}</AssumedRoleId>
620      <Arn>{assumed_role_arn}</Arn>
621    </AssumedRoleUser>
622  </AssumeRoleResult>
623  <ResponseMetadata>
624    <RequestId>{request_id}</RequestId>
625  </ResponseMetadata>
626</AssumeRoleResponse>"#,
627        access_key_id = creds.access_key_id,
628        secret_access_key = creds.secret_access_key,
629        session_token = creds.session_token,
630        role_id = role_id,
631        assumed_role_arn = assumed_role_arn,
632        session = role_session_name,
633    )
634}
635
636#[allow(clippy::too_many_arguments)]
637pub fn assume_role_with_web_identity_response(
638    role_arn: &str,
639    role_session_name: &str,
640    account_id: &str,
641    partition: &str,
642    creds: &StsCredentials,
643    assumed_role_id: &str,
644    expiration: &str,
645    request_id: &str,
646) -> String {
647    let role_name = role_arn.rsplit('/').next().unwrap_or("unknown");
648    let assumed_role_arn = format!(
649        "arn:{}:sts::{}:assumed-role/{}/{}",
650        partition, account_id, role_name, role_session_name
651    );
652
653    format!(
654        r#"<?xml version="1.0" encoding="UTF-8"?>
655<AssumeRoleWithWebIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
656  <AssumeRoleWithWebIdentityResult>
657    <Credentials>
658      <AccessKeyId>{access_key_id}</AccessKeyId>
659      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
660      <SessionToken>{session_token}</SessionToken>
661      <Expiration>{expiration}</Expiration>
662    </Credentials>
663    <AssumedRoleUser>
664      <AssumedRoleId>{assumed_role_id}:{session}</AssumedRoleId>
665      <Arn>{assumed_role_arn}</Arn>
666    </AssumedRoleUser>
667  </AssumeRoleWithWebIdentityResult>
668  <ResponseMetadata>
669    <RequestId>{request_id}</RequestId>
670  </ResponseMetadata>
671</AssumeRoleWithWebIdentityResponse>"#,
672        access_key_id = creds.access_key_id,
673        secret_access_key = creds.secret_access_key,
674        session_token = creds.session_token,
675        assumed_role_id = assumed_role_id,
676        assumed_role_arn = assumed_role_arn,
677        session = role_session_name,
678        request_id = request_id,
679    )
680}
681
682#[allow(clippy::too_many_arguments)]
683pub fn assume_role_with_saml_response(
684    role_arn: &str,
685    role_session_name: &str,
686    account_id: &str,
687    partition: &str,
688    creds: &StsCredentials,
689    assumed_role_id: &str,
690    expiration: &str,
691    request_id: &str,
692) -> String {
693    let role_name = role_arn.rsplit('/').next().unwrap_or("unknown");
694    let assumed_role_arn = format!(
695        "arn:{}:sts::{}:assumed-role/{}/{}",
696        partition, account_id, role_name, role_session_name
697    );
698
699    format!(
700        r#"<?xml version="1.0" encoding="UTF-8"?>
701<AssumeRoleWithSAMLResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
702  <AssumeRoleWithSAMLResult>
703    <Credentials>
704      <AccessKeyId>{access_key_id}</AccessKeyId>
705      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
706      <SessionToken>{session_token}</SessionToken>
707      <Expiration>{expiration}</Expiration>
708    </Credentials>
709    <AssumedRoleUser>
710      <AssumedRoleId>{assumed_role_id}:{session}</AssumedRoleId>
711      <Arn>{assumed_role_arn}</Arn>
712    </AssumedRoleUser>
713  </AssumeRoleWithSAMLResult>
714  <ResponseMetadata>
715    <RequestId>{request_id}</RequestId>
716  </ResponseMetadata>
717</AssumeRoleWithSAMLResponse>"#,
718        access_key_id = creds.access_key_id,
719        secret_access_key = creds.secret_access_key,
720        session_token = creds.session_token,
721        assumed_role_id = assumed_role_id,
722        assumed_role_arn = assumed_role_arn,
723        session = role_session_name,
724        request_id = request_id,
725    )
726}
727
728pub fn get_session_token_response(expiration: &str, request_id: &str) -> String {
729    // AWS docs example credentials (deterministic for local testing)
730    let access_key_id = "FSIAIOSFODNN7EXAMPLE";
731    let secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY";
732    let session_token = "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE";
733
734    format!(
735        r#"<?xml version="1.0" encoding="UTF-8"?>
736<GetSessionTokenResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
737  <GetSessionTokenResult>
738    <Credentials>
739      <AccessKeyId>{access_key_id}</AccessKeyId>
740      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
741      <SessionToken>{session_token}</SessionToken>
742      <Expiration>{expiration}</Expiration>
743    </Credentials>
744  </GetSessionTokenResult>
745  <ResponseMetadata>
746    <RequestId>{request_id}</RequestId>
747  </ResponseMetadata>
748</GetSessionTokenResponse>"#,
749        access_key_id = access_key_id,
750        secret_access_key = secret_access_key,
751        session_token = session_token,
752        request_id = request_id,
753    )
754}
755
756pub fn get_federation_token_response(
757    name: &str,
758    account_id: &str,
759    partition: &str,
760    expiration: &str,
761    policy: Option<&str>,
762    request_id: &str,
763) -> String {
764    // AWS docs example credentials (deterministic for local testing)
765    let access_key_id = "FSIAIOSFODNN7EXAMPLE";
766    let secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY";
767    let session_token = "AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwdQWLWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/kMcGdQrmGdeehM4IC1NtBmUpp2wUE8phUZampKsburEDy0KPkyQDYwT7WZ0wq5VSXDvp75YU9HFvlRd8Tx6q6fE8YQcHNVXAkiY9q6d+xo0rKwT38xVqr7ZD0u0iPPkUL64lIZbqBAz+scqKmlzm8FDrypNC9Yjc8fPOLn9FX9KSYvKTr4rvx3iSIlTJabIQwj2ICCR/oLxBA==";
768
769    let name = xml_escape(name);
770    let federated_user_arn = format!(
771        "arn:{}:sts::{}:federated-user/{}",
772        partition, account_id, name
773    );
774    let federated_user_id = format!("{}:{}", account_id, name);
775
776    let policy_section = if let Some(p) = policy {
777        format!(
778            "\n    <PackedPolicySize>6</PackedPolicySize>\n    <Policy>{}</Policy>",
779            xml_escape(p)
780        )
781    } else {
782        String::new()
783    };
784
785    format!(
786        r#"<?xml version="1.0" encoding="UTF-8"?>
787<GetFederationTokenResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
788  <GetFederationTokenResult>
789    <Credentials>
790      <AccessKeyId>{access_key_id}</AccessKeyId>
791      <SecretAccessKey>{secret_access_key}</SecretAccessKey>
792      <SessionToken>{session_token}</SessionToken>
793      <Expiration>{expiration}</Expiration>
794    </Credentials>
795    <FederatedUser>
796      <FederatedUserId>{federated_user_id}</FederatedUserId>
797      <Arn>{federated_user_arn}</Arn>
798    </FederatedUser>{policy_section}
799  </GetFederationTokenResult>
800  <ResponseMetadata>
801    <RequestId>{request_id}</RequestId>
802  </ResponseMetadata>
803</GetFederationTokenResponse>"#,
804        access_key_id = access_key_id,
805        secret_access_key = secret_access_key,
806        session_token = session_token,
807        federated_user_arn = federated_user_arn,
808        federated_user_id = federated_user_id,
809        request_id = request_id,
810    )
811}
812
813pub fn decode_authorization_message_response(decoded_message: &str, request_id: &str) -> String {
814    format!(
815        r#"<?xml version="1.0" encoding="UTF-8"?>
816<DecodeAuthorizationMessageResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
817  <DecodeAuthorizationMessageResult>
818    <DecodedMessage>{decoded_message}</DecodedMessage>
819  </DecodeAuthorizationMessageResult>
820  <ResponseMetadata>
821    <RequestId>{request_id}</RequestId>
822  </ResponseMetadata>
823</DecodeAuthorizationMessageResponse>"#,
824        decoded_message = xml_escape(decoded_message),
825        request_id = request_id,
826    )
827}
828
829pub fn get_access_key_info_response(account_id: &str, request_id: &str) -> String {
830    format!(
831        r#"<?xml version="1.0" encoding="UTF-8"?>
832<GetAccessKeyInfoResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
833  <GetAccessKeyInfoResult>
834    <Account>{account_id}</Account>
835  </GetAccessKeyInfoResult>
836  <ResponseMetadata>
837    <RequestId>{request_id}</RequestId>
838  </ResponseMetadata>
839</GetAccessKeyInfoResponse>"#,
840        account_id = account_id,
841        request_id = request_id,
842    )
843}
844
845/// Generate an FSIA-prefixed temporary access key ID (20 chars total).
846pub fn generate_access_key_id() -> String {
847    let id = generate_alphanum_id(16);
848    format!("FSIA{}", id)
849}
850
851/// Generate a 40-character secret access key.
852pub fn generate_secret_access_key() -> String {
853    generate_alphanum_id(40)
854}
855
856/// Generate an AROA-prefixed role ID (21 chars total).
857pub fn generate_role_id() -> String {
858    let id = generate_alphanum_id(17);
859    format!("AROA{}", id)
860}
861
862/// Generate a session token that is exactly 356 characters starting with "FQoGZXIvYXdzE".
863pub fn generate_session_token() -> String {
864    // AWS session tokens are typically 356 chars and start with "FQoGZXIvYXdzE"
865    let prefix = "FQoGZXIvYXdzE";
866    let remaining = 356 - prefix.len(); // 343 chars needed
867                                        // Generate enough random bytes: we need at least ceil(343*3/4) = 258 bytes
868                                        // 18 UUIDs * 16 bytes = 288 bytes -> base64 = 384 chars (plenty)
869    let mut raw = Vec::with_capacity(288);
870    for _ in 0..18 {
871        raw.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
872    }
873    let encoded = base64::engine::general_purpose::STANDARD.encode(&raw);
874    // Take exactly what we need from the encoded data
875    let suffix = &encoded[..remaining];
876    format!("{}{}", prefix, suffix)
877}
878
879/// Generate alphanumeric ID of given length.
880fn generate_alphanum_id(len: usize) -> String {
881    let raw = format!(
882        "{}{}{}",
883        uuid::Uuid::new_v4(),
884        uuid::Uuid::new_v4(),
885        uuid::Uuid::new_v4(),
886    );
887    raw.replace('-', "")
888        .chars()
889        .filter(|c| c.is_alphanumeric())
890        .take(len)
891        .collect()
892}