Skip to main content

featherbit/plugins/native/
aws_lambda.rs

1//! AWS Lambda serverless-upstream plugin (`aws-lambda`).
2//!
3//! Port of APISIX's `aws-lambda` plugin (3.17). Invokes an AWS Lambda function
4//! through its function URL (or an API Gateway endpoint) and returns the
5//! function's reply as the gateway response — it **replaces the upstream**, so
6//! the node's `success` port should be wired straight to `client.in`.
7//!
8//! Two authorization modes are supported:
9//!
10//! - **API key** (`authorization.apikey`) — the key is sent as the `x-api-key`
11//!   request header, no signing.
12//! - **IAM / SigV4** (`authorization.iam`) — the request is signed with AWS
13//!   Signature Version 4 (`AWS4-HMAC-SHA256`). The signing itself is factored
14//!   into the pure [`sign_v4`] function, unit-tested against the published AWS
15//!   SigV4 `get-vanilla` test vector.
16//!
17//! On a callout failure the node rejects through its `error` port with
18//! `AWS_LAMBDA_CALLOUT_ERROR` (a 502/503 depending on the failure kind).
19//!
20//! # Deviations from APISIX
21//!
22//! - Only the minimal header set `host`, `x-amz-date` and (when present)
23//!   `x-amz-security-token` is covered by the SigV4 signature. AWS permits
24//!   signing a subset of headers, and the remaining forwarded headers are sent
25//!   unsigned; APISIX signs every forwarded header.
26//! - `aws_service` defaults to `lambda` (APISIX defaults to `execute-api`).
27//! - `ssl_verify` defaults to `false`, matching APISIX's documented behavior
28//!   for this plugin.
29
30use async_trait::async_trait;
31use ring::{digest, hmac};
32use std::collections::HashMap;
33use std::sync::Arc;
34use std::time::{Duration, SystemTime, UNIX_EPOCH};
35
36use crate::context::{Context, GatewayError};
37use crate::outbound::{OutboundClient, OutboundError, OutboundRequest, OutboundResponse};
38use crate::plugins::resources::PluginResources;
39use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
40
41/// IAM credentials driving AWS SigV4 request signing.
42#[derive(Clone)]
43struct IamAuth {
44    access_key: String,
45    secret_key: String,
46    region: String,
47    service: String,
48    session_token: Option<String>,
49}
50
51/// The plugin's resolved authorization strategy.
52#[derive(Clone)]
53enum Authorization {
54    /// No credentials configured — forward as-is.
55    None,
56    /// Static API key sent as `x-api-key`.
57    ApiKey(String),
58    /// IAM role signing via AWS SigV4.
59    Iam(IamAuth),
60}
61
62/// Invokes an AWS Lambda function and maps its reply into `Context.response`.
63pub struct AwsLambdaPlugin {
64    /// Full Lambda function URL / API endpoint.
65    function_uri: String,
66    authorization: Authorization,
67    ssl_verify: bool,
68    timeout: Duration,
69    client: Arc<OutboundClient>,
70}
71
72impl AwsLambdaPlugin {
73    /// Builds the plugin from node config.
74    ///
75    /// Accepted keys:
76    /// - `function_uri` (string, **required**): the Lambda function URL or API
77    ///   endpoint to invoke. A missing/empty value is a config-load error.
78    /// - `authorization` (object, optional): either
79    ///   - `apikey` (string) — sent as the `x-api-key` header (no signing), or
80    ///   - `iam` (object) — SigV4 credentials:
81    ///     - `accesskey` (string, **required**)
82    ///     - `secretkey` (string, **required**)
83    ///     - `aws_region` (string, default `us-east-1`)
84    ///     - `aws_service` (string, default `lambda`)
85    ///     - `session_token` (string, optional) — adds `x-amz-security-token`.
86    ///
87    ///   An `iam` block missing `accesskey`/`secretkey` is a config error.
88    /// - `ssl_verify` (bool, default `false`): verify TLS certificates.
89    /// - `timeout` (integer ms, default `3000`): whole-call deadline.
90    ///
91    /// ```yaml
92    /// type: aws-lambda
93    /// config:
94    ///   function_uri: https://xyz.lambda-url.us-east-1.on.aws/
95    ///   authorization:
96    ///     iam:
97    ///       accesskey: AKIDEXAMPLE
98    ///       secretkey: ${AWS_SECRET_KEY}
99    ///       aws_region: us-east-1
100    ///       aws_service: lambda
101    ///   ssl_verify: false
102    ///   timeout: 3000
103    /// ```
104    pub fn from_config(
105        config: &HashMap<String, serde_json::Value>,
106        resources: &Arc<PluginResources>,
107    ) -> Result<Self, String> {
108        let function_uri = config
109            .get("function_uri")
110            .and_then(|v| v.as_str())
111            .filter(|s| !s.is_empty())
112            .ok_or_else(|| "aws-lambda plugin requires 'function_uri'".to_string())?
113            .to_string();
114
115        let authorization = match config.get("authorization").and_then(|v| v.as_object()) {
116            None => Authorization::None,
117            Some(authz) => {
118                if let Some(iam) = authz.get("iam").and_then(|v| v.as_object()) {
119                    let access_key = iam
120                        .get("accesskey")
121                        .and_then(|v| v.as_str())
122                        .filter(|s| !s.is_empty())
123                        .ok_or_else(|| {
124                            "aws-lambda plugin: authorization.iam requires 'accesskey'".to_string()
125                        })?
126                        .to_string();
127                    let secret_key = iam
128                        .get("secretkey")
129                        .and_then(|v| v.as_str())
130                        .filter(|s| !s.is_empty())
131                        .ok_or_else(|| {
132                            "aws-lambda plugin: authorization.iam requires 'secretkey'".to_string()
133                        })?
134                        .to_string();
135                    let region = iam
136                        .get("aws_region")
137                        .and_then(|v| v.as_str())
138                        .filter(|s| !s.is_empty())
139                        .unwrap_or("us-east-1")
140                        .to_string();
141                    let service = iam
142                        .get("aws_service")
143                        .and_then(|v| v.as_str())
144                        .filter(|s| !s.is_empty())
145                        .unwrap_or("lambda")
146                        .to_string();
147                    let session_token = iam
148                        .get("session_token")
149                        .and_then(|v| v.as_str())
150                        .filter(|s| !s.is_empty())
151                        .map(String::from);
152                    Authorization::Iam(IamAuth {
153                        access_key,
154                        secret_key,
155                        region,
156                        service,
157                        session_token,
158                    })
159                } else if let Some(apikey) = authz.get("apikey").and_then(|v| v.as_str()) {
160                    Authorization::ApiKey(apikey.to_string())
161                } else {
162                    Authorization::None
163                }
164            }
165        };
166
167        let ssl_verify = config
168            .get("ssl_verify")
169            .and_then(|v| v.as_bool())
170            .unwrap_or(false);
171
172        let timeout = Duration::from_millis(
173            config
174                .get("timeout")
175                .and_then(|v| v.as_u64())
176                .unwrap_or(3000),
177        );
178
179        Ok(Self {
180            function_uri,
181            authorization,
182            ssl_verify,
183            timeout,
184            client: resources.outbound.clone(),
185        })
186    }
187
188    /// Assembles the outbound headers, canonical query string, and target URL
189    /// for the request, applying the configured authorization (API key header
190    /// or SigV4 signing). `amz_time` is the signing timestamp (seconds since
191    /// epoch), threaded in so tests are deterministic.
192    fn build_request(&self, ctx: &Context, amz_time: u64) -> Result<OutboundRequest, String> {
193        let parsed: http::Uri = self
194            .function_uri
195            .parse()
196            .map_err(|e| format!("aws-lambda: invalid function_uri: {}", e))?;
197        let host = parsed
198            .authority()
199            .map(|a| a.as_str().to_string())
200            .ok_or_else(|| "aws-lambda: function_uri has no host".to_string())?;
201        let canonical_uri = normalize_path(parsed.path());
202        let canonical_query = canonical_query_string(&ctx.request.query_params);
203
204        // Forward the client headers, excluding hop-by-hop and headers we set
205        // ourselves; override Host with the function endpoint's authority.
206        let mut headers: Vec<(String, String)> = Vec::new();
207        for (name, values) in &ctx.request.headers {
208            let lname = name.to_ascii_lowercase();
209            if matches!(
210                lname.as_str(),
211                "host" | "connection" | "content-length" | "x-amz-date" | "authorization"
212            ) {
213                continue;
214            }
215            for value in values {
216                headers.push((lname.clone(), value.clone()));
217            }
218        }
219        headers.push(("host".to_string(), host.clone()));
220
221        match &self.authorization {
222            Authorization::None => {}
223            Authorization::ApiKey(key) => {
224                if !ctx
225                    .request
226                    .headers
227                    .keys()
228                    .any(|k| k.eq_ignore_ascii_case("x-api-key"))
229                {
230                    headers.push(("x-api-key".to_string(), key.clone()));
231                }
232            }
233            Authorization::Iam(iam) => {
234                let amz_date = format_amz_date(amz_time);
235                let datestamp = amz_date[..8].to_string();
236                headers.push(("x-amz-date".to_string(), amz_date.clone()));
237                if let Some(token) = &iam.session_token {
238                    headers.push(("x-amz-security-token".to_string(), token.clone()));
239                }
240                // Sign the minimal, gateway-controlled header set.
241                let mut signed: Vec<(String, String)> = vec![
242                    ("host".to_string(), host.clone()),
243                    ("x-amz-date".to_string(), amz_date.clone()),
244                ];
245                if let Some(token) = &iam.session_token {
246                    signed.push(("x-amz-security-token".to_string(), token.clone()));
247                }
248                let sig = sign_v4(&SigV4Input {
249                    method: &ctx.request.method,
250                    canonical_uri: &canonical_uri,
251                    canonical_query: &canonical_query,
252                    headers: &signed,
253                    payload: &ctx.request.body,
254                    access_key: &iam.access_key,
255                    secret_key: &iam.secret_key,
256                    region: &iam.region,
257                    service: &iam.service,
258                    amz_date: &amz_date,
259                    datestamp: &datestamp,
260                });
261                headers.push(("authorization".to_string(), sig.authorization));
262            }
263        }
264
265        let scheme = parsed.scheme_str().unwrap_or("https");
266        let url = if canonical_query.is_empty() {
267            format!("{}://{}{}", scheme, host, canonical_uri)
268        } else {
269            format!("{}://{}{}?{}", scheme, host, canonical_uri, canonical_query)
270        };
271
272        let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::POST);
273
274        Ok(OutboundRequest {
275            method,
276            url,
277            headers,
278            body: ctx.request.body.clone(),
279            timeout: self.timeout,
280            ssl_verify: self.ssl_verify,
281            tls: None,
282        })
283    }
284}
285
286/// Copies the FaaS reply into `Context.response` (status, headers, body).
287fn apply_response(ctx: &mut Context, response: OutboundResponse) {
288    ctx.response.status_code = response.status;
289    ctx.response.headers = response.headers;
290    ctx.response.body = response.body;
291}
292
293#[async_trait]
294impl Plugin for AwsLambdaPlugin {
295    fn plugin_type(&self) -> &str {
296        "aws-lambda"
297    }
298
299    async fn execute(
300        &self,
301        mut ctx: Context,
302        _named_inputs: &HashMap<String, serde_json::Value>,
303    ) -> PluginResult {
304        let now = SystemTime::now()
305            .duration_since(UNIX_EPOCH)
306            .map(|d| d.as_secs())
307            .unwrap_or(0);
308        let request = match self.build_request(&ctx, now) {
309            Ok(req) => req,
310            Err(message) => {
311                return Err(reject(ctx, 502, message));
312            }
313        };
314
315        match self.client.request(request).await {
316            Ok(response) => {
317                apply_response(&mut ctx, response);
318                Ok(PluginOutput {
319                    context: ctx,
320                    named_outputs: HashMap::new(),
321                })
322            }
323            Err(e) => {
324                let (status, message) = match &e {
325                    OutboundError::Timeout(d) => {
326                        (504, format!("aws-lambda callout timed out after {:?}", d))
327                    }
328                    OutboundError::InvalidRequest(m) => {
329                        (502, format!("aws-lambda request build error: {}", m))
330                    }
331                    OutboundError::Transport(m) => {
332                        (503, format!("aws-lambda callout failed: {}", m))
333                    }
334                };
335                Err(reject(ctx, status, message))
336            }
337        }
338    }
339}
340
341/// Builds the `AWS_LAMBDA_CALLOUT_ERROR` rejection carrying the context.
342fn reject(mut ctx: Context, status: u16, message: String) -> PluginExecutionError {
343    ctx.response.status_code = status;
344    PluginExecutionError {
345        context: ctx,
346        error: GatewayError {
347            node_id: String::new(),
348            code: "AWS_LAMBDA_CALLOUT_ERROR".to_string(),
349            message,
350            metadata: HashMap::new(),
351        },
352    }
353}
354
355// ---------------------------------------------------------------------------
356// AWS Signature Version 4 (pure helpers)
357// ---------------------------------------------------------------------------
358
359/// Inputs to [`sign_v4`]. `canonical_query` is a pre-built, sorted, encoded
360/// query string; `headers` is the exact set of headers to cover by the
361/// signature (names in any case, values already trimmed of outer whitespace).
362struct SigV4Input<'a> {
363    method: &'a str,
364    canonical_uri: &'a str,
365    canonical_query: &'a str,
366    headers: &'a [(String, String)],
367    payload: &'a [u8],
368    access_key: &'a str,
369    secret_key: &'a str,
370    region: &'a str,
371    service: &'a str,
372    amz_date: &'a str,
373    datestamp: &'a str,
374}
375
376/// Result of a SigV4 signing pass.
377struct SigV4Output {
378    /// The `Authorization` header value.
379    authorization: String,
380    /// The `SignedHeaders` list (`;`-joined).
381    #[allow(dead_code)]
382    signed_headers: String,
383    /// The lowercase-hex signature.
384    #[allow(dead_code)]
385    signature: String,
386    /// The canonical request (retained for testing).
387    #[allow(dead_code)]
388    canonical_request: String,
389    /// The string-to-sign (retained for testing).
390    #[allow(dead_code)]
391    string_to_sign: String,
392}
393
394const ALGO: &str = "AWS4-HMAC-SHA256";
395
396/// Computes an AWS Signature Version 4 for the given request.
397///
398/// Pure function: given identical inputs it always produces the same output.
399/// The four SigV4 steps are: build the canonical request, form the
400/// string-to-sign, derive the signing key via the HMAC date/region/service
401/// chain, and HMAC the string-to-sign.
402fn sign_v4(input: &SigV4Input) -> SigV4Output {
403    // Canonical headers: lowercase names, trimmed values, sorted by name.
404    let mut headers: Vec<(String, String)> = input
405        .headers
406        .iter()
407        .map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string()))
408        .collect();
409    headers.sort_by(|a, b| a.0.cmp(&b.0));
410
411    let canonical_headers: String = headers
412        .iter()
413        .map(|(k, v)| format!("{}:{}\n", k, v))
414        .collect();
415    let signed_headers = headers
416        .iter()
417        .map(|(k, _)| k.as_str())
418        .collect::<Vec<_>>()
419        .join(";");
420
421    let payload_hash = hex(sha256(input.payload).as_ref());
422
423    let canonical_request = format!(
424        "{}\n{}\n{}\n{}\n{}\n{}",
425        input.method.to_uppercase(),
426        input.canonical_uri,
427        input.canonical_query,
428        canonical_headers,
429        signed_headers,
430        payload_hash
431    );
432
433    let credential_scope = format!(
434        "{}/{}/{}/aws4_request",
435        input.datestamp, input.region, input.service
436    );
437    let string_to_sign = format!(
438        "{}\n{}\n{}\n{}",
439        ALGO,
440        input.amz_date,
441        credential_scope,
442        hex(sha256(canonical_request.as_bytes()).as_ref())
443    );
444
445    let signing_key = derive_signing_key(
446        input.secret_key,
447        input.datestamp,
448        input.region,
449        input.service,
450    );
451    let signature = hex(hmac_sha256(&signing_key, string_to_sign.as_bytes()).as_ref());
452
453    let authorization = format!(
454        "{} Credential={}/{}, SignedHeaders={}, Signature={}",
455        ALGO, input.access_key, credential_scope, signed_headers, signature
456    );
457
458    SigV4Output {
459        authorization,
460        signed_headers,
461        signature,
462        canonical_request,
463        string_to_sign,
464    }
465}
466
467/// Derives the SigV4 signing key via the HMAC date → region → service chain.
468fn derive_signing_key(secret: &str, datestamp: &str, region: &str, service: &str) -> Vec<u8> {
469    let k_secret = format!("AWS4{}", secret);
470    let k_date = hmac_sha256(k_secret.as_bytes(), datestamp.as_bytes());
471    let k_region = hmac_sha256(k_date.as_ref(), region.as_bytes());
472    let k_service = hmac_sha256(k_region.as_ref(), service.as_bytes());
473    let k_signing = hmac_sha256(k_service.as_ref(), b"aws4_request");
474    k_signing.as_ref().to_vec()
475}
476
477/// HMAC-SHA256 over `msg` with `key`.
478fn hmac_sha256(key: &[u8], msg: &[u8]) -> hmac::Tag {
479    let key = hmac::Key::new(hmac::HMAC_SHA256, key);
480    hmac::sign(&key, msg)
481}
482
483/// SHA-256 digest of `data`.
484fn sha256(data: &[u8]) -> digest::Digest {
485    digest::digest(&digest::SHA256, data)
486}
487
488/// Lowercase-hex encoding of a byte slice.
489fn hex(bytes: &[u8]) -> String {
490    let mut s = String::with_capacity(bytes.len() * 2);
491    for b in bytes {
492        s.push_str(&format!("{:02x}", b));
493    }
494    s
495}
496
497/// Percent-encodes a string per RFC 3986, leaving the SigV4 unreserved set
498/// (`A-Z a-z 0-9 - _ . ~`) intact.
499fn uri_encode(s: &str) -> String {
500    let mut out = String::with_capacity(s.len());
501    for b in s.bytes() {
502        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
503            out.push(b as char);
504        } else {
505            out.push_str(&format!("%{:02X}", b));
506        }
507    }
508    out
509}
510
511/// Normalizes a request path for the SigV4 canonical URI: an empty path
512/// becomes `/`, and a trailing slash on a non-root path is dropped.
513fn normalize_path(path: &str) -> String {
514    if path.is_empty() {
515        return "/".to_string();
516    }
517    if path != "/" && path.ends_with('/') {
518        return path.trim_end_matches('/').to_string();
519    }
520    path.to_string()
521}
522
523/// Builds the SigV4 canonical query string: URI-encode every name and value,
524/// then sort the pairs by encoded name and value.
525fn canonical_query_string(query: &HashMap<String, Vec<String>>) -> String {
526    let mut pairs: Vec<(String, String)> = Vec::new();
527    for (name, values) in query {
528        let name = uri_encode(name);
529        for value in values {
530            pairs.push((name.clone(), uri_encode(value)));
531        }
532    }
533    pairs.sort();
534    pairs
535        .iter()
536        .map(|(k, v)| format!("{}={}", k, v))
537        .collect::<Vec<_>>()
538        .join("&")
539}
540
541/// Formats a unix timestamp as an AWS `amz-date` (`YYYYMMDDTHHMMSSZ`, UTC).
542fn format_amz_date(secs: u64) -> String {
543    let days = (secs / 86400) as i64;
544    let rem = secs % 86400;
545    let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
546    let (year, month, day) = civil_from_days(days);
547    format!(
548        "{:04}{:02}{:02}T{:02}{:02}{:02}Z",
549        year, month, day, hour, minute, second
550    )
551}
552
553/// Converts a count of days since the unix epoch into `(year, month, day)`
554/// using Howard Hinnant's civil-date algorithm.
555fn civil_from_days(days: i64) -> (i64, u32, u32) {
556    let z = days + 719468;
557    let era = if z >= 0 { z } else { z - 146096 } / 146097;
558    let doe = z - era * 146097;
559    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
560    let y = yoe + era * 400;
561    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
562    let mp = (5 * doy + 2) / 153;
563    let d = doy - (153 * mp + 2) / 5 + 1;
564    let m = if mp < 10 { mp + 3 } else { mp - 9 };
565    let year = if m <= 2 { y + 1 } else { y };
566    (year, m as u32, d as u32)
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
573    use bytes::Bytes;
574
575    fn ctx_with(method: &str, path: &str) -> Context {
576        Context {
577            request: GatewayRequest {
578                method: method.to_string(),
579                path: path.to_string(),
580                host: "gw".to_string(),
581                scheme: "http".to_string(),
582                headers: HashMap::new(),
583                query_params: HashMap::new(),
584                body: Bytes::new(),
585                remote_addr: "1.2.3.4:5".to_string(),
586                protocol: Protocol::Http1,
587            },
588            response: GatewayResponse {
589                status_code: 0,
590                headers: HashMap::new(),
591                body: Bytes::new(),
592            },
593            message: HashMap::new(),
594            errors: Vec::new(),
595        }
596    }
597
598    fn plugin(config: serde_json::Value) -> AwsLambdaPlugin {
599        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
600        AwsLambdaPlugin::from_config(&map, &PluginResources::empty()).unwrap()
601    }
602
603    #[test]
604    fn test_requires_function_uri() {
605        assert!(AwsLambdaPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
606    }
607
608    #[test]
609    fn test_iam_requires_keys() {
610        let map: HashMap<String, serde_json::Value> = serde_json::from_value(serde_json::json!({
611            "function_uri": "https://x.on.aws/",
612            "authorization": { "iam": { "accesskey": "AK" } }
613        }))
614        .unwrap();
615        assert!(AwsLambdaPlugin::from_config(&map, &PluginResources::empty()).is_err());
616    }
617
618    #[test]
619    fn test_iam_defaults() {
620        let p = plugin(serde_json::json!({
621            "function_uri": "https://x.on.aws/",
622            "authorization": { "iam": { "accesskey": "AK", "secretkey": "SK" } }
623        }));
624        match p.authorization {
625            Authorization::Iam(ref iam) => {
626                assert_eq!(iam.region, "us-east-1");
627                assert_eq!(iam.service, "lambda");
628            }
629            _ => panic!("expected IAM auth"),
630        }
631        // ssl_verify defaults to false for aws-lambda
632        assert!(!p.ssl_verify);
633    }
634
635    /// The published AWS SigV4 `get-vanilla` test vector.
636    /// See https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
637    #[test]
638    fn test_sigv4_get_vanilla_vector() {
639        let headers = vec![
640            ("host".to_string(), "example.amazonaws.com".to_string()),
641            ("x-amz-date".to_string(), "20150830T123600Z".to_string()),
642        ];
643        let out = sign_v4(&SigV4Input {
644            method: "GET",
645            canonical_uri: "/",
646            canonical_query: "",
647            headers: &headers,
648            payload: b"",
649            access_key: "AKIDEXAMPLE",
650            secret_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
651            region: "us-east-1",
652            service: "service",
653            amz_date: "20150830T123600Z",
654            datestamp: "20150830",
655        });
656        assert_eq!(
657            out.signature,
658            "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
659        );
660        assert_eq!(out.signed_headers, "host;x-amz-date");
661        assert_eq!(
662            out.authorization,
663            "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
664             SignedHeaders=host;x-amz-date, \
665             Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
666        );
667        // The string-to-sign carries the algorithm and credential scope; the
668        // signature above already pins the (correct) canonical-request hash.
669        assert!(out.string_to_sign.starts_with(
670            "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\n"
671        ));
672        // Canonical request has the expected first line and payload hash.
673        assert!(out.canonical_request.starts_with("GET\n/\n\n"));
674        assert!(out
675            .canonical_request
676            .ends_with("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
677    }
678
679    #[test]
680    fn test_sigv4_is_deterministic() {
681        let headers = vec![("host".to_string(), "h".to_string())];
682        let mk = || {
683            sign_v4(&SigV4Input {
684                method: "POST",
685                canonical_uri: "/f",
686                canonical_query: "a=1&b=2",
687                headers: &headers,
688                payload: b"body",
689                access_key: "AK",
690                secret_key: "SK",
691                region: "us-east-1",
692                service: "lambda",
693                amz_date: "20240101T000000Z",
694                datestamp: "20240101",
695            })
696            .signature
697        };
698        assert_eq!(mk(), mk());
699    }
700
701    #[test]
702    fn test_canonical_query_sorted_and_encoded() {
703        let mut q = HashMap::new();
704        q.insert("b".to_string(), vec!["2".to_string()]);
705        q.insert("a".to_string(), vec!["hello world".to_string()]);
706        assert_eq!(canonical_query_string(&q), "a=hello%20world&b=2");
707    }
708
709    #[test]
710    fn test_format_amz_date_vector() {
711        // 20150830T123600Z corresponds to unix 1440938160.
712        assert_eq!(format_amz_date(1440938160), "20150830T123600Z");
713    }
714
715    #[test]
716    fn test_apikey_sets_header() {
717        let p = plugin(serde_json::json!({
718            "function_uri": "https://x.on.aws/fn",
719            "authorization": { "apikey": "secret-key" }
720        }));
721        let req = p
722            .build_request(&ctx_with("POST", "/ignored"), 1440938160)
723            .unwrap();
724        let hdr = req
725            .headers
726            .iter()
727            .find(|(k, _)| k == "x-api-key")
728            .map(|(_, v)| v.as_str());
729        assert_eq!(hdr, Some("secret-key"));
730        assert!(req.url.starts_with("https://x.on.aws/fn"));
731    }
732
733    #[test]
734    fn test_iam_build_request_adds_signature_headers() {
735        let p = plugin(serde_json::json!({
736            "function_uri": "https://example.amazonaws.com/",
737            "authorization": {
738                "iam": { "accesskey": "AK", "secretkey": "SK", "session_token": "TOK" }
739            }
740        }));
741        let req = p.build_request(&ctx_with("GET", "/"), 1440938160).unwrap();
742        let has = |name: &str| req.headers.iter().any(|(k, _)| k == name);
743        assert!(has("x-amz-date"));
744        assert!(has("x-amz-security-token"));
745        let authz = req
746            .headers
747            .iter()
748            .find(|(k, _)| k == "authorization")
749            .map(|(_, v)| v.clone())
750            .unwrap();
751        assert!(authz.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
752        // session token is covered by the signature
753        assert!(authz.contains("x-amz-security-token"));
754    }
755
756    #[tokio::test]
757    async fn test_callout_failure_routes_error() {
758        // Unresolvable host -> transport error -> error port.
759        let p = plugin(serde_json::json!({
760            "function_uri": "http://127.0.0.1:1/fn",
761            "timeout": 200
762        }));
763        let err = p
764            .execute(ctx_with("POST", "/"), &HashMap::new())
765            .await
766            .unwrap_err();
767        assert_eq!(err.error.code, "AWS_LAMBDA_CALLOUT_ERROR");
768        assert!(err.context.response.status_code >= 502);
769    }
770
771    #[test]
772    fn test_apply_response_maps_fields() {
773        let mut ctx = ctx_with("GET", "/");
774        let mut headers = HashMap::new();
775        headers.insert(
776            "content-type".to_string(),
777            vec!["application/json".to_string()],
778        );
779        apply_response(
780            &mut ctx,
781            OutboundResponse {
782                status: 201,
783                headers,
784                body: Bytes::from_static(b"{\"ok\":true}"),
785            },
786        );
787        assert_eq!(ctx.response.status_code, 201);
788        assert_eq!(ctx.response.body, Bytes::from_static(b"{\"ok\":true}"));
789        assert_eq!(
790            ctx.response.headers.get("content-type"),
791            Some(&vec!["application/json".to_string()])
792        );
793    }
794}