rusty_cdk_macros/
lib.rs

1#![allow(unused_comparisons)]
2//! This crate provides compile-time validation macros for AWS cloud infrastructure configuration.
3//! These macros ensure type safety and enforce AWS service limits at build time, preventing
4//! runtime errors from invalid configurations.
5//!
6//! ## Overview
7//!
8//! All macros perform validation at compile time and generate wrapper types that encapsulate
9//! validated values.
10//!
11//! The macros always return a newtype 'wrapper'.
12//! You should import those from the rusty_cdk::wrappers directory, as seen in the below example.
13//!
14//! ## Usage Example
15//!
16//! ```rust,compile_fail
17//! use rusty_cdk::wrappers::Memory; // import the wrapper
18//! use rusty_cdk::memory;
19//!
20//! // Lambda memory configuration with validated limit
21//! let mem = memory!(512);        // 512 MB (128-10240 range)
22//! ```
23
24mod bucket;
25mod bucket_name;
26mod file_util;
27mod iam_validation;
28mod location_uri;
29mod object_sizes;
30mod strings;
31mod timeouts;
32mod transition_in_days;
33mod bucket_tiering;
34mod rate_expression;
35mod schedule_validation;
36
37use crate::bucket_tiering::BucketTiering;
38use crate::schedule_validation::{validate_at, validate_cron};
39use crate::file_util::get_absolute_file_path;
40use crate::iam_validation::{PermissionValidator, ValidationResponse};
41use crate::location_uri::LocationUri;
42use crate::object_sizes::ObjectSizes;
43use crate::rate_expression::RateExpression;
44use crate::strings::{validate_string, StringRequirements};
45use crate::timeouts::Timeouts;
46use crate::transition_in_days::TransitionInfo;
47use proc_macro::TokenStream;
48use quote::__private::Span;
49use quote::quote;
50use std::env;
51use syn::spanned::Spanned;
52use syn::{parse_macro_input, Error, LitInt, LitStr};
53
54/// Creates a validated `StringWithOnlyAlphaNumericsAndUnderscores` wrapper at compile time.
55///
56/// # Validation Rules
57///
58/// - String must not be empty
59/// - Only alphanumeric characters, and underscores are allowed
60#[proc_macro]
61pub fn string_with_only_alphanumerics_and_underscores(input: TokenStream) -> TokenStream {
62    let output: LitStr = syn::parse(input).unwrap();
63    let value = output.value();
64
65    let requirements = StringRequirements::not_empty_with_allowed_chars(vec!['_']);
66
67    match validate_string(&value, requirements) {
68        Ok(()) => quote!(
69            StringWithOnlyAlphaNumericsAndUnderscores(#value.to_string())
70        ),
71        Err(e) => Error::new(output.span(), e).into_compile_error(),
72    }.into()
73}
74
75/// Creates a validated `StringWithOnlyAlphaNumericsUnderscoresAndHyphens` wrapper at compile time.
76///
77/// # Validation Rules
78///
79/// - String must not be empty
80/// - Only alphanumeric characters, underscores, and hyphens are allowed
81#[proc_macro]
82pub fn string_with_only_alphanumerics_underscores_and_hyphens(input: TokenStream) -> TokenStream {
83    let output: LitStr = syn::parse(input).unwrap();
84    let value = output.value();
85
86    let requirements = StringRequirements::not_empty_with_allowed_chars(vec!['_', '-']);
87
88    match validate_string(&value, requirements) {
89        Ok(()) => quote!(
90            StringWithOnlyAlphaNumericsUnderscoresAndHyphens(#value.to_string())
91        ),
92        Err(e) => Error::new(output.span(), e).into_compile_error(),
93    }.into()
94}
95
96/// Creates a validated `StringWithOnlyAlphaNumericsUnderscoresAndHyphens` wrapper at compile time.
97///
98/// This macro ensures that the input string contains only alphanumeric characters (a-z, A-Z, 0-9),
99/// underscores (_), and hyphens (-). It's designed for creating safe identifiers for AWS resources
100/// that allow hyphens in their naming conventions.
101///
102/// # Validation Rules
103///
104/// - String must not be empty
105/// - Only alphanumeric characters, underscores, and hyphens are allowed
106#[proc_macro]
107pub fn string_with_only_alphanumerics_and_hyphens(input: TokenStream) -> TokenStream {
108    let output: LitStr = syn::parse(input).unwrap();
109    let value = output.value();
110
111    let requirements = StringRequirements::not_empty_with_allowed_chars(vec!['-']);
112
113    match validate_string(&value, requirements) {
114        Ok(()) => quote!(
115            StringWithOnlyAlphaNumericsAndHyphens(#value.to_string())
116        ),
117        Err(e) => Error::new(output.span(), e).into_compile_error(),
118    }.into()
119}
120
121/// Creates a validated `AppSyncApiName` wrapper for AppSync Api names at compile time.
122///
123/// This macro ensures that the input string is a valid name for AppSync Apis,
124/// following AWS naming conventions and character restrictions.
125///
126/// # Validation Rules
127///
128/// - String must not be empty
129/// - Only alphanumeric characters, and the following special characters are allowed: _, - and whitespace
130/// - Max length 50 characters
131#[proc_macro]
132pub fn app_sync_api_name(input: TokenStream) -> TokenStream {
133    let output: LitStr = syn::parse(input).unwrap();
134    let value = output.value();
135    let requirements = StringRequirements::not_empty_with_allowed_chars(vec!['-', '_', ' ']).with_max_length(50);
136
137    match validate_string(&value, requirements) {
138        Ok(()) => quote!(
139            AppSyncApiName(#value.to_string())
140        ),
141        Err(e) => Error::new(output.span(), e).into_compile_error(),
142    }.into()
143}
144
145#[proc_macro]
146pub fn schedule_name(input: TokenStream) -> TokenStream {
147    let output: LitStr = syn::parse(input).unwrap();
148    let value = output.value();
149    let requirements = StringRequirements::not_empty_with_allowed_chars(vec!['-', '_', '.']).with_max_length(64);
150
151    match validate_string(&value, requirements) {
152        Ok(()) => quote!(
153            ScheduleName(#value.to_string())
154        ),
155        Err(e) => Error::new(output.span(), e).into_compile_error(),
156    }.into()
157}
158
159/// Creates a validated `ChannelNamespaceName` wrapper for AppSync Api at compile time.
160///
161/// This macro ensures that the input string is a valid name for a Channel Namespace,
162/// following AWS naming conventions and character restrictions.
163///
164/// # Validation Rules
165///
166/// - String must not be empty
167/// - Only alphanumeric characters, and the following special characters are allowed: -
168/// - Max length 50 characters
169#[proc_macro]
170pub fn channel_namespace_name(input: TokenStream) -> TokenStream {
171    let output: LitStr = syn::parse(input).unwrap();
172    let value = output.value();
173    let requirements = StringRequirements::not_empty_with_allowed_chars(vec!['-']).with_max_length(50);
174
175    match validate_string(&value, requirements) {
176        Ok(()) => quote!(
177            ChannelNamespaceName(#value.to_string())
178        ),
179        Err(e) => Error::new(output.span(), e).into_compile_error(),
180    }.into()
181}
182
183/// Creates a validated `StringForSecret` wrapper for AWS Secrets Manager secret names at compile time.
184///
185/// This macro ensures that the input string is a valid name for AWS Secrets Manager secrets,
186/// following AWS naming conventions and character restrictions.
187///
188/// # Validation Rules
189///
190/// - String must not be empty
191/// - Only alphanumeric characters, and the following special characters are allowed: /, _, +, =, ., @, -
192#[proc_macro]
193pub fn string_for_secret(input: TokenStream) -> TokenStream {
194    let output: LitStr = syn::parse(input).unwrap();
195    let value = output.value();
196
197    let requirements = StringRequirements::not_empty_with_allowed_chars(vec!['/', '_', '+', '=', '.', '@', '-']);
198
199    match validate_string(&value, requirements) {
200        Ok(()) => quote!(
201            StringForSecret(#value.to_string())
202        ),
203        Err(e) => Error::new(output.span(), e).into_compile_error(),
204    }.into()
205}
206
207/// Creates a validated `EnvVarKey` wrapper for AWS Lambda environment variable keys at compile time.
208///
209/// # Validation Rules
210///
211/// - Key must be at least 2 characters long
212/// - Cannot start with an underscore (_)
213/// - Only alphanumeric characters and underscores are allowed
214#[proc_macro]
215pub fn env_var_key(input: TokenStream) -> TokenStream {
216    let output: LitStr = syn::parse(input).unwrap();
217    let value = output.value();
218
219    if value.len() < 2 {
220        return Error::new(output.span(), "env var key should be at least two characters long".to_string())
221            .into_compile_error()
222            .into();
223    }
224
225    if value.get(0..1).expect("just checked that length is at least 2") == "_" {
226        return Error::new(output.span(), "env var key should not start with an underscore".to_string())
227            .into_compile_error()
228            .into();
229    }
230
231    if value.chars().any(|c| !c.is_alphanumeric() && c != '_') {
232        return Error::new(
233            output.span(),
234            "env var key should only contain alphanumeric characters and underscores".to_string(),
235        )
236            .into_compile_error()
237            .into();
238    }
239
240    quote!(
241        EnvVarKey(#value.to_string())
242    )
243        .into()
244}
245
246/// Creates a validated `ZipFile` wrapper for AWS Lambda deployment packages at compile time.
247///
248/// This macro ensures that the input string refers to a valid ZIP file that exists on the filesystem at compile time.
249///
250/// See the `examples` dir of this library for some usage examples
251///
252/// # Validation Rules
253///
254/// - Path must end with `.zip` extension
255/// - File must exist at compile time
256/// - Path must be valid Unicode
257/// - Both relative and absolute paths are allowed
258#[proc_macro]
259pub fn zip_file(input: TokenStream) -> TokenStream {
260    let output: syn::Result<LitStr> = syn::parse(input);
261
262    let output = match output {
263        Ok(output) => output,
264        Err(_) => {
265            return Error::new(Span::call_site(), "zip_file macro should contain value".to_string())
266                .into_compile_error()
267                .into();
268        }
269    };
270
271    let value = output.value();
272
273    if !value.ends_with(".zip") {
274        return Error::new(output.span(), format!("zip should end with `.zip` (found `{value}`)"))
275            .into_compile_error()
276            .into();
277    }
278
279    let value = match get_absolute_file_path(&value) {
280        Ok(v) => v,
281        Err(e) => {
282            return Error::new(output.span(), e).into_compile_error().into();
283        }
284    };
285
286    quote!(
287        ZipFile(#value.to_string())
288    )
289        .into()
290}
291
292/// Creates a validated `TomlFile` wrapper.
293///
294/// See the `examples` dir of this library for some usage examples
295///
296/// # Validation Rules
297///
298/// - Path must end with `.toml` extension
299/// - File must exist at compile time
300/// - Path must be valid Unicode
301/// - Both relative and absolute paths are allowed
302#[proc_macro]
303pub fn toml_file(input: TokenStream) -> TokenStream {
304    let output: syn::Result<LitStr> = syn::parse(input);
305
306    let output = match output {
307        Ok(output) => output,
308        Err(_) => {
309            return Error::new(Span::call_site(), "toml_file macro should contain value".to_string())
310                .into_compile_error()
311                .into();
312        }
313    };
314
315    let value = output.value();
316
317    if !value.ends_with(".toml") {
318        return Error::new(output.span(), format!("toml file should end with `.toml` (found `{value}`)"))
319            .into_compile_error()
320            .into();
321    }
322
323    let value = match get_absolute_file_path(&value) {
324        Ok(v) => v,
325        Err(e) => {
326            return Error::new(output.span(), e).into_compile_error().into();
327        }
328    };
329
330    quote!(
331        TomlFile(#value.to_string())
332    )
333        .into()
334}
335
336/// Creates a validated `NonZeroNumber` wrapper for positive integers at compile time.
337#[proc_macro]
338pub fn non_zero_number(input: TokenStream) -> TokenStream {
339    let output = match syn::parse::<LitInt>(input) {
340        Ok(v) => v,
341        Err(_) => {
342            return Error::new(Span::call_site(), "value is not a valid number".to_string())
343                .into_compile_error()
344                .into();
345        }
346    };
347
348    let as_number: syn::Result<u32> = output.base10_parse();
349
350    let num = if let Ok(num) = as_number {
351        if num == 0 {
352            return Error::new(output.span(), "value should not be null".to_string())
353                .into_compile_error()
354                .into();
355        }
356        num
357    } else {
358        return Error::new(output.span(), "value is not a valid u32 number".to_string())
359            .into_compile_error()
360            .into();
361    };
362
363    quote!(
364        NonZeroNumber(#num)
365    )
366        .into()
367}
368
369macro_rules! number_check {
370    ($name:ident,$min:literal,$max:literal,$output:ident,$type:ty) => {
371        #[doc = "Checks whether the value that will be wrapped in the "]
372		#[doc = stringify!($output)]
373		#[doc = "struct is between "]
374		#[doc = stringify!($min)]
375		#[doc = "and "]
376        #[doc = stringify!($max)]
377        #[proc_macro]
378        pub fn $name(input: TokenStream) -> TokenStream {
379            let output: LitInt = syn::parse(input).unwrap();
380
381            let as_number: syn::Result<$type> = output.base10_parse();
382
383            if let Ok(num) = as_number {
384                if num < $min {
385                    Error::new(output.span(), format!("value should be at least {}", $min)).into_compile_error().into()
386                } else if num > $max {
387                    Error::new(output.span(), format!("value should be at most {}", $max)).into_compile_error().into()
388                } else {
389                    quote!(
390                        $output(#num)
391                    ).into()
392                }
393            } else {
394                Error::new(output.span(), "value is not a valid number".to_string()).into_compile_error().into()
395            }
396        }
397    }
398}
399
400number_check!(memory, 128, 10240, Memory, u16);
401number_check!(timeout, 1, 900, Timeout, u16);
402number_check!(delay_seconds, 0, 900, DelaySeconds, u16);
403number_check!(maximum_message_size, 1024, 1048576, MaximumMessageSize, u32);
404number_check!(message_retention_period, 60, 1209600, MessageRetentionPeriod, u32);
405number_check!(visibility_timeout, 0, 43200, VisibilityTimeout, u32);
406number_check!(receive_message_wait_time, 0, 20, ReceiveMessageWaitTime, u8);
407number_check!(sqs_event_source_max_concurrency, 2, 1000, SqsEventSourceMaxConcurrency, u16);
408number_check!(connection_attempts, 1, 3, ConnectionAttempts, u8);
409number_check!(s3_origin_read_timeout, 1, 120, S3OriginReadTimeout, u8);
410number_check!(deployment_duration_in_minutes, 0, 1440, DeploymentDurationInMinutes, u16);
411number_check!(growth_factor, 0, 100, GrowthFactor, u8);
412number_check!(record_expiration_days, 7, 2147483647, RecordExpirationDays, u32);
413number_check!(retry_policy_event_age, 60, 86400, RetryPolicyEventAge, u32);
414number_check!(retry_policy_retries, 0, 185, RetryPolicyRetries, u8);
415number_check!(max_flexible_time_window, 1, 1440, MaxFlexibleTimeWindow, u16);
416
417const NO_REMOTE_OVERRIDE_ENV_VAR_NAME: &str = "RUSTY_CDK_NO_REMOTE";
418const RUSTY_CDK_RECHECK_ENV_VAR_NAME: &str = "RUSTY_CDK_RECHECK";
419
420/// Creates a validated `Bucket` wrapper for existing AWS S3 bucket references at compile time.
421///
422/// This macro ensures that the input string refers to an existing S3 bucket in your AWS account.
423/// It queries S3 to verify the bucket exists.
424///
425/// # Validation Rules
426///
427/// - Value must not be an ARN (cannot start with "arn:")
428/// - Value must not include the "s3:" prefix
429/// - Bucket must exist in your AWS account (verified at compile time)
430///
431/// # Environment Variables
432///
433/// - `rusty_cdk_NO_REMOTE`: Set to `true` to skip remote AWS checks (for offline development)
434/// - `rusty_cdk_RECHECK`: Set to `true` to force revalidation of cached bucket names
435///
436/// # Note
437///
438/// This macro caches validation results to improve compile times. The first compilation will
439/// query AWS to verify the bucket exists. Later compilations will use the cached result unless `rusty_cdk_RECHECK` is set to true.
440///
441/// # Override
442///
443/// You can avoid this verification by using the wrapper directly, but you lose all the above compile time guarantees by doing so.
444#[proc_macro]
445pub fn bucket(input: TokenStream) -> TokenStream {
446    let input: LitStr = syn::parse(input).unwrap();
447    let value = input.value();
448
449    if value.starts_with("arn:") {
450        return Error::new(input.span(), "value is an arn, not a bucket name".to_string())
451            .into_compile_error()
452            .into();
453    }
454
455    if value.starts_with("s3:") {
456        return Error::new(input.span(), "value has s3 prefix, should be plain bucket name".to_string())
457            .into_compile_error()
458            .into();
459    }
460
461    let no_remote_check_wanted = env::var(NO_REMOTE_OVERRIDE_ENV_VAR_NAME)
462        .ok()
463        .and_then(|v| v.parse().ok())
464        .unwrap_or(false);
465
466    if no_remote_check_wanted {
467        return bucket::bucket_output(value);
468    }
469
470    let rechecked_wanted = env::var(RUSTY_CDK_RECHECK_ENV_VAR_NAME)
471        .ok()
472        .and_then(|v| v.parse().ok())
473        .unwrap_or(false);
474
475    if !rechecked_wanted {
476        match bucket::valid_bucket_according_to_file_storage(&value) {
477            bucket::FileStorageOutput::Valid => {
478                return bucket::bucket_output(value)
479            }
480            bucket::FileStorageOutput::Invalid => {
481                return Error::new(input.span(), format!("(cached) did not find bucket with name `{value}` in your account. You can rerun this check by adding setting the `{RUSTY_CDK_RECHECK_ENV_VAR_NAME}` env var to true")).into_compile_error().into()
482            }
483            bucket::FileStorageOutput::Unknown => {}
484        }
485    }
486
487    let rt = tokio::runtime::Runtime::new().unwrap();
488
489    match rt.block_on(bucket::find_bucket(input.clone())) {
490        Ok(_) => {
491            bucket::update_file_storage(bucket::FileStorageInput::Valid(&value));
492            bucket::bucket_output(value)
493        }
494        Err(e) => {
495            bucket::update_file_storage(bucket::FileStorageInput::Invalid(&value));
496            e.into_compile_error().into()
497        }
498    }
499}
500
501const ADDITIONAL_ALLOWED_FOR_BUCKET_NAME: [char; 2] = ['.', '-'];
502
503/// Creates a validated `BucketName` wrapper for new AWS S3 bucket names at compile time.
504///
505/// This macro ensures that the input string is a valid S3 bucket name that follows AWS naming
506/// requirements and verifies the name is available at compile time.
507///
508/// # Validation Rules
509///
510/// - Must contain only lowercase letters, numbers, periods (.), and hyphens (-)
511/// - No uppercase letters are allowed
512/// - Bucket name must be globally unique and available (verified at compile time)
513///
514/// # Environment Variables
515///
516/// - `RUSTY_CDK_NO_REMOTE`: Set to `true` to skip remote AWS checks (for offline development)
517/// - `RUSTY_CDK_RECHECK`: Set to `true` to force revalidation of cached bucket name availability
518///
519/// # Note
520///
521/// This macro caches validation results to improve compile times. The first compilation will
522/// query AWS to verify the bucket name is available. Later compilations will use the cached
523/// result unless `RUSTY_CDK_RECHECK` is set to true.
524///
525/// # Override
526///
527/// You can avoid this verification by using the wrapper directly, but you lose all the above compile time guarantees by doing so.
528#[proc_macro]
529pub fn bucket_name(input: TokenStream) -> TokenStream {
530    let input: LitStr = syn::parse(input).unwrap();
531    let value = input.value();
532
533    if value.chars().any(|c| c.is_uppercase()) {
534        return Error::new(input.span(), "value contains uppercase letters".to_string())
535            .into_compile_error()
536            .into();
537    }
538
539    if value
540        .chars()
541        .any(|c| !c.is_alphanumeric() && !ADDITIONAL_ALLOWED_FOR_BUCKET_NAME.contains(&c))
542    {
543        return Error::new(
544            input.span(),
545            "value should contain only letters, numbers, periods and dashes".to_string(),
546        )
547            .into_compile_error()
548            .into();
549    }
550
551    let no_remote_check_wanted = env::var(NO_REMOTE_OVERRIDE_ENV_VAR_NAME)
552        .ok()
553        .and_then(|v| v.parse().ok())
554        .unwrap_or(false);
555
556    if no_remote_check_wanted {
557        return bucket_name::bucket_name_output(value);
558    }
559
560    let rechecked_wanted = env::var(RUSTY_CDK_RECHECK_ENV_VAR_NAME)
561        .ok()
562        .and_then(|v| v.parse().ok())
563        .unwrap_or(false);
564
565    if !rechecked_wanted {
566        match bucket_name::valid_bucket_name_according_to_file_storage(&value) {
567            bucket_name::FileStorageOutput::Valid => {
568                return bucket_name::bucket_name_output(value)
569            }
570            bucket_name::FileStorageOutput::Invalid => {
571                return Error::new(input.span(), format!("(cached) bucket name is already taken. You can rerun this check by adding setting the `{RUSTY_CDK_RECHECK_ENV_VAR_NAME}` env var to true")).into_compile_error().into()
572            }
573            bucket_name::FileStorageOutput::Unknown => {}
574        }
575    }
576
577    match bucket_name::check_bucket_name(input) {
578        Ok(_) => {
579            bucket_name::update_file_storage(bucket_name::FileStorageInput::Valid(&value));
580            bucket_name::bucket_name_output(value)
581        }
582        Err(e) => {
583            bucket_name::update_file_storage(bucket_name::FileStorageInput::Invalid(&value));
584            e.into_compile_error().into()
585        }
586    }
587}
588
589const POSSIBLE_LOG_RETENTION_VALUES: [u16; 22] = [
590    1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653,
591];
592
593/// Creates a validated `RetentionInDays` wrapper for AWS CloudWatch Logs retention periods at compile time.
594///
595/// # Validation Rules
596///
597/// - Value must be a number, and of the AWS-approved retention periods (in days)
598#[proc_macro]
599pub fn log_retention(input: TokenStream) -> TokenStream {
600    let output = match syn::parse::<LitInt>(input) {
601        Ok(v) => v,
602        Err(_) => {
603            return Error::new(Span::call_site(), "value is not a valid number".to_string())
604                .into_compile_error()
605                .into();
606        }
607    };
608
609    let as_number: syn::Result<u16> = output.base10_parse();
610
611    if let Ok(num) = as_number {
612        if POSSIBLE_LOG_RETENTION_VALUES.contains(&num) {
613            quote! {
614                RetentionInDays(#num)
615            }
616                .into()
617        } else {
618            Error::new(output.span(), format!("value should be one of {:?}", POSSIBLE_LOG_RETENTION_VALUES))
619                .into_compile_error()
620                .into()
621        }
622    } else {
623        Error::new(output.span(), "value is not a valid u16 number".to_string())
624            .into_compile_error()
625            .into()
626    }
627}
628
629const ADDITIONAL_ALLOWED_FOR_LOG_GROUP: [char; 6] = ['.', '-', '_', '#', '/', '\\'];
630
631/// Creates a validated `LogGroupName` wrapper for AWS CloudWatch Logs log group names at compile time.
632///
633/// # Validation Rules
634///
635/// - String must not be empty
636/// - The maximum length is 512 characters
637/// - Only alphanumeric characters, and the following special characters are allowed: . - _ # / \
638#[proc_macro]
639pub fn log_group_name(input: TokenStream) -> TokenStream {
640    let output: LitStr = syn::parse(input).unwrap();
641    let value = output.value();
642
643    if value.is_empty() {
644        return Error::new(output.span(), "value should not be blank".to_string())
645            .into_compile_error()
646            .into();
647    }
648
649    if value.len() > 512 {
650        return Error::new(output.span(), "value should not be longer than 512 chars".to_string())
651            .into_compile_error()
652            .into();
653    }
654
655    if value
656        .chars()
657        .any(|c| !c.is_alphanumeric() && !ADDITIONAL_ALLOWED_FOR_LOG_GROUP.contains(&c))
658    {
659        return Error::new(
660            output.span(),
661            format!(
662                "value should only contain alphanumeric characters and {:?}",
663                ADDITIONAL_ALLOWED_FOR_LOG_GROUP
664            ),
665        )
666            .into_compile_error()
667            .into();
668    }
669
670    quote!(
671        LogGroupName(#value.to_string())
672    )
673        .into()
674}
675
676/// Creates a validated `IamAction` wrapper for AWS IAM permissions at compile time.
677///
678/// This macro ensures that the input string represents a valid AWS IAM action permission.
679/// It validates the action against a comprehensive list of AWS service permissions to catch
680/// typos and invalid permissions at compile time.
681///
682/// # Validation Rules
683///
684/// - String must not be empty
685/// - Action must be a valid AWS IAM action (e.g., "s3:GetObject", "s3:Put*")
686/// - Action is validated against AWS's official permission list
687/// - Wildcards are supported
688///
689#[proc_macro]
690pub fn iam_action(input: TokenStream) -> TokenStream {
691    let output: LitStr = syn::parse(input).unwrap();
692    let value = output.value();
693    let validator = PermissionValidator::new();
694
695    match validator.is_valid_action(&value) {
696        ValidationResponse::Valid => quote!(
697            IamAction(#value.to_string())
698        ),
699        ValidationResponse::Invalid(message) => Error::new(output.span(), message).into_compile_error()
700    }.into()
701}
702
703/// Creates a validated `S3LifecycleObjectSizes` wrapper for S3 lifecycle rule object size constraints at compile time.
704///
705/// This macro defines minimum and maximum object sizes for S3 lifecycle transitions, allowing
706/// lifecycle rules to apply only to objects within a specific size range.
707///
708/// # Validation Rules
709///
710/// - Both minimum and maximum sizes are optional
711/// - If both are provided, the minimum must be smaller than the maximum
712/// - Values are specified in bytes
713#[proc_macro]
714pub fn lifecycle_object_sizes(input: TokenStream) -> TokenStream {
715    let ObjectSizes { first, second } = parse_macro_input!(input);
716
717    // replace with if let Some
718    if first.is_some() && second.is_some() && first.unwrap() > second.unwrap() {
719        return Error::new(
720            Span::call_site(),
721            format!(
722                "first number ({}) in `lifecycle_object_sizes` should be smaller than second ({})",
723                first.unwrap(),
724                second.unwrap()
725            ),
726        )
727            .into_compile_error()
728            .into();
729    }
730
731    let first_output = if let Some(first) = first {
732        quote!(Some(#first))
733    } else {
734        quote!(None)
735    };
736
737    let second_output = if let Some(second) = second {
738        quote!(Some(#second))
739    } else {
740        quote!(None)
741    };
742
743    quote!(S3LifecycleObjectSizes(#first_output, #second_output)).into()
744}
745
746/// Creates a validated `OriginPath` wrapper for CloudFront origin path prefixes at compile time.
747///
748/// This macro ensures that the path string follows CloudFront's requirements for origin paths, which are appended to requests forwarded to the origin.
749///
750/// # Validation Rules
751///
752/// - Must start with a forward slash (/)
753/// - Must NOT end with a forward slash (/)
754/// - Example: "/production" is valid, but "/production/" and "production" are not
755#[proc_macro]
756pub fn origin_path(input: TokenStream) -> TokenStream {
757    let output: LitStr = syn::parse(input).unwrap();
758    let value = output.value();
759
760    if !value.starts_with("/") || value.ends_with("/") {
761        return Error::new(
762            value.span(),
763            format!("origin path should start with a / and should not end with / (but got {})", value),
764        )
765            .into_compile_error()
766            .into();
767    }
768
769    quote! {
770        OriginPath(#value)
771    }
772        .into()
773}
774
775/// Creates a validated `DefaultRootObject` wrapper for CloudFront default root objects at compile time.
776///
777/// This macro ensures that the object name follows CloudFront's requirements for default root objects, which are returned when viewers request the root URL of a distribution.
778///
779/// # Validation Rules
780///
781/// - Must NOT start with a forward slash (/)
782/// - Must NOT end with a forward slash (/)
783/// - Example: "index.html" is valid, but "/index.html" and "index.html/" are not
784#[proc_macro]
785pub fn default_root_object(input: TokenStream) -> TokenStream {
786    let output: LitStr = syn::parse(input).unwrap();
787    let value = output.value();
788
789    if value.starts_with("/") || value.ends_with("/") {
790        return Error::new(value.span(), "default root object should not start with /".to_string())
791            .into_compile_error()
792            .into();
793    }
794
795    quote! {
796        DefaultRootObject(#value)
797    }
798        .into()
799}
800
801/// Creates a validated `CfConnectionTimeout` wrapper for CloudFront origin connection timeouts at compile time.
802///
803/// # Validation Rules
804///
805/// - Connection timeout (first value) must be between 1 and 10 seconds (if provided)
806/// - Response completion timeout (second value) must be greater than or equal to connection timeout (if both provided)
807/// - Both values are optional
808#[proc_macro]
809pub fn cf_connection_timeout(input: TokenStream) -> TokenStream {
810    let Timeouts { first, second } = parse_macro_input!(input);
811
812    if let Some(first) = first {
813        if first > 10 {
814            return Error::new(
815                Span::call_site(),
816                format!("connection timeout was {} but should be between 1 and 10", first),
817            )
818                .into_compile_error()
819                .into();
820        } else if let Some(second) = second && second < first {
821            return Error::new(
822                Span::call_site(),
823                format!(
824                    "response completion timeout was {} but should be larger than connection timeout ({})",
825                    second, first
826                ),
827            )
828                .into_compile_error()
829                .into();
830        }
831    }
832
833    let first_output = if let Some(first) = first {
834        quote!(Some(#first))
835    } else {
836        quote!(None)
837    };
838
839    let second_output = if let Some(second) = second {
840        quote!(Some(#second))
841    } else {
842        quote!(None)
843    };
844
845    quote!(CfConnectionTimeout(#first_output, #second_output)).into()
846}
847
848/// Creates a validated `LambdaPermissionAction` wrapper for Lambda resource-based policy actions at compile time.
849///
850/// This macro ensures that the action string is properly formatted for Lambda resource-based
851/// policies, which control what AWS services and accounts can invoke Lambda functions.
852///
853/// # Validation Rules
854///
855/// - String must not be empty
856/// - Must start with "lambda:" prefix
857/// - Common values include "lambda:InvokeFunction" and "lambda:GetFunction"
858#[proc_macro]
859pub fn lambda_permission_action(input: TokenStream) -> TokenStream {
860    let output: LitStr = syn::parse(input).unwrap();
861    let value = output.value();
862    let requirements = StringRequirements::not_empty_prefix("lambda");
863
864    match validate_string(&value, requirements) {
865        Ok(()) => quote!(
866            LambdaPermissionAction(#value.to_string())
867        ),
868        Err(e) => Error::new(output.span(), e).into_compile_error(),
869    }.into()
870}
871
872/// Creates a validated `AppConfigName` wrapper for AWS AppConfig resource names at compile time.
873///
874/// This macro ensures that the name string follows AWS AppConfig naming conventions and
875/// length restrictions for applications, environments, and configuration profiles.
876///
877/// # Validation Rules
878///
879/// - String must not be empty
880/// - Maximum length of 64 characters
881/// - Used for AppConfig application names, environment names, and configuration profile names
882#[proc_macro]
883pub fn app_config_name(input: TokenStream) -> TokenStream {
884    let output: LitStr = syn::parse(input).unwrap();
885    let value = output.value();
886
887    if value.is_empty() || value.len() > 64 {
888        return Error::new(
889            Span::call_site(),
890            "app config name should be between 1 and 64 chars in length".to_string(),
891        )
892            .into_compile_error()
893            .into();
894    }
895
896    quote!(AppConfigName(#value.to_string())).into()
897}
898
899const LIFECYCLE_STORAGE_TYPES: [&str; 6] = [
900    "IntelligentTiering",
901    "OneZoneIA",
902    "StandardIA",
903    "GlacierDeepArchive",
904    "Glacier",
905    "GlacierInstantRetrieval",
906];
907const LIFECYCLE_STORAGE_TYPES_MORE_THAN_THIRTY_DAYS: [&str; 2] = ["OneZoneIA", "StandardIA"];
908
909/// Creates a validated `LifecycleTransitionInDays` wrapper for S3 lifecycle transition rules at compile time.
910///
911/// # Validation Rules
912///
913/// - Days must be a positive number
914/// - Storage class must be one of: IntelligentTiering, OneZoneIA, StandardIA, GlacierDeepArchive, Glacier, GlacierInstantRetrieval
915/// - OneZoneIA and StandardIA storage classes require at least 30 days (not allowed to transition sooner)
916#[proc_macro]
917pub fn lifecycle_transition_in_days(input: TokenStream) -> TokenStream {
918    let TransitionInfo { days, service } = parse_macro_input!(input);
919    let service = service.trim();
920
921    if !LIFECYCLE_STORAGE_TYPES.contains(&service) {
922        return Error::new(
923            Span::call_site(),
924            format!("service should be one of {} (was {})", LIFECYCLE_STORAGE_TYPES.join(","), service),
925        )
926            .into_compile_error()
927            .into();
928    } else if LIFECYCLE_STORAGE_TYPES_MORE_THAN_THIRTY_DAYS.contains(&service) && days <= 30 {
929        return Error::new(
930            Span::call_site(),
931            format!(
932                "service of type {} cannot have transition under 30 days",
933                LIFECYCLE_STORAGE_TYPES_MORE_THAN_THIRTY_DAYS.join(" or ")
934            ),
935        )
936            .into_compile_error()
937            .into();
938    }
939
940    quote!(LifecycleTransitionInDays(#days)).into()
941}
942
943const ACCESS_TIERS: [&str; 2] = ["ARCHIVE_ACCESS", "DEEP_ARCHIVE_ACCESS"];
944
945#[proc_macro]
946pub fn bucket_tiering(input: TokenStream) -> TokenStream {
947    let BucketTiering { access_tier, days } = parse_macro_input!(input);
948
949    if !ACCESS_TIERS.contains(&access_tier.as_str()) {
950        return Error::new(
951            Span::call_site(),
952            format!("access tier should be one of {} (was {})", ACCESS_TIERS.join(","), access_tier),
953        )
954            .into_compile_error()
955            .into();
956    }
957
958    if &access_tier == "ARCHIVE_ACCESS" {
959        if days < 90 || days > 730 {
960            return Error::new(Span::call_site(), format!("days for access tier `ARCHIVE_ACCESS` should be between 90 and 730 (was {})", days))
961                .into_compile_error()
962                .into();
963        }
964    } else if &access_tier == "DEEP_ARCHIVE_ACCESS" {
965        if days < 180 || days > 730 {
966            return Error::new(Span::call_site(), format!("days for access tier `DEEP_ARCHIVE_ACCESS` should be between 180 and 730 (was {})", days))
967                .into_compile_error()
968                .into();
969        }
970    }
971
972    quote!(BucketTiering(#access_tier.to_string(), #days)).into()
973}
974
975const LOCATION_URI_TYPES: [&str; 4] = ["hosted", "codepipeline", "secretsmanager", "s3"];
976const LOCATION_URI_CODEPIPELINE_START: &str = "codepipeline://";
977const LOCATION_URI_SECRETS_MANAGER_START: &str = "secretsmanager://";
978const LOCATION_URI_S3_START: &str = "s3://";
979
980/// Creates a validated `LocationUri` wrapper for AppConfig
981///
982/// # Validation Rules
983///
984/// - Must be one of "hosted", "codepipeline", "secretsmanager", "s3"
985/// - Hosted does not need an additional argument
986/// - The other values require a second value, separated from the first by a comma
987#[proc_macro]
988pub fn location_uri(input: TokenStream) -> TokenStream {
989    let LocationUri {
990        location_uri_type,
991        content,
992    } = parse_macro_input!(input);
993    let location_uri_type = location_uri_type.trim();
994
995    #[allow(unused)] // bug? is used at the end for the error?
996    let mut error = None;
997
998    if !LOCATION_URI_TYPES.contains(&location_uri_type) {
999        error = Some(format!(
1000            "unrecognized location uri {}, should be one of {}",
1001            location_uri_type,
1002            LOCATION_URI_TYPES.join(",")
1003        ));
1004    } else {
1005        if location_uri_type == "hosted" {
1006            return quote! {
1007                LocationUri(#location_uri_type.to_string())
1008            }
1009                .into();
1010        } else if content.is_none() {
1011            error = Some(format!("location uri of type {}, should have content", location_uri_type));
1012        } else {
1013            let content = content.expect("just checked that this is present");
1014
1015            if location_uri_type == "codepipeline" && !content.starts_with(LOCATION_URI_CODEPIPELINE_START) {
1016                error = Some(format!(
1017                    "content of type codepipeline should start with {}",
1018                    LOCATION_URI_CODEPIPELINE_START
1019                ));
1020            } else if location_uri_type == "secretsmanager" && !content.starts_with(LOCATION_URI_SECRETS_MANAGER_START) {
1021                error = Some(format!(
1022                    "content of type secretsmanager should start with {}",
1023                    LOCATION_URI_SECRETS_MANAGER_START
1024                ));
1025            } else if location_uri_type == "s3" && !content.starts_with(LOCATION_URI_S3_START) {
1026                error = Some(format!("content of type s3 should start with {}", LOCATION_URI_S3_START));
1027            } else {
1028                return quote! {
1029                    LocationUri(#content.to_string())
1030                }
1031                    .into();
1032            }
1033        }
1034    }
1035
1036    Error::new(
1037        Span::call_site(),
1038        error.unwrap_or_else(|| "unknown error".to_string()),
1039    )
1040        .into_compile_error()
1041        .into()
1042}
1043
1044const RATE_UNITS: [&str; 3] = ["minutes", "hours", "days"];
1045
1046#[proc_macro]
1047pub fn schedule_rate_expression(input: TokenStream) -> TokenStream {
1048    let RateExpression {
1049        value, unit
1050    } = parse_macro_input!(input);
1051
1052    if !RATE_UNITS.contains(&unit.as_str()) {
1053        return Error::new(Span::call_site(), format!("unit of at expression should be one of {} (was {})", RATE_UNITS.join(","), unit))
1054            .into_compile_error()
1055            .into();
1056    }
1057    
1058    if value == 0 {
1059        return Error::new(Span::call_site(), "rate value should be a positive number bigger than 0")
1060            .into_compile_error()
1061            .into();
1062    }
1063
1064    quote!(ScheduleRateExpression(#value, #unit.to_string())).into()
1065}
1066
1067#[proc_macro]
1068pub fn schedule_cron_expression(input: TokenStream) -> TokenStream {
1069    let output: LitStr = syn::parse(input).unwrap();
1070    let value = output.value();
1071
1072    match validate_cron(&value) {
1073        Ok(()) => quote!(
1074            ScheduleCronExpression(#value.to_string())
1075        ),
1076        Err(e) => Error::new(output.span(), e).into_compile_error(),
1077    }.into()
1078}
1079
1080#[proc_macro]
1081pub fn schedule_at_expression(input: TokenStream) -> TokenStream {
1082    let output: LitStr = syn::parse(input).unwrap();
1083    let value = output.value();
1084
1085    match validate_at(&value) {
1086        Ok(()) => quote!(
1087            ScheduleAtExpression(#value.to_string())
1088        ),
1089        Err(e) => Error::new(output.span(), e).into_compile_error(),
1090    }.into()
1091}
1092
1093#[proc_macro]
1094pub fn policy_name(input: TokenStream) -> TokenStream {
1095    let output: LitStr = syn::parse(input).unwrap();
1096    let value = output.value();
1097
1098    let requirements = StringRequirements::not_empty_with_allowed_chars(vec!['_', '+', '=', ',', '.', '@', '-', ';']).with_max_length(128);
1099    
1100    match validate_string(&value, requirements) {
1101        Ok(()) => quote!(
1102            PolicyName(#value.to_string())
1103        ),
1104        Err(e) => Error::new(output.span(), e).into_compile_error()
1105    }.into()
1106}