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