Skip to main content

aws_runtime/user_agent/
interceptor.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use std::borrow::Cow;
7use std::fmt;
8
9use http_1x::header::{HeaderName, HeaderValue, InvalidHeaderValue, USER_AGENT};
10
11use aws_credential_types::credential_feature::AwsCredentialFeature;
12use aws_smithy_runtime::client::sdk_feature::SmithySdkFeature;
13use aws_smithy_runtime_api::box_error::BoxError;
14use aws_smithy_runtime_api::client::http::HttpClient;
15use aws_smithy_runtime_api::client::interceptors::context::{
16    BeforeTransmitInterceptorContextMut, BeforeTransmitInterceptorContextRef,
17};
18use aws_smithy_runtime_api::client::interceptors::{dyn_dispatch_hint, Intercept};
19use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
20use aws_smithy_types::config_bag::ConfigBag;
21use aws_types::app_name::AppName;
22use aws_types::os_shim_internal::Env;
23
24use crate::sdk_feature::AwsSdkFeature;
25use crate::user_agent::metrics::ProvideBusinessMetric;
26use crate::user_agent::{AdditionalMetadata, ApiMetadata, AwsUserAgent, InvalidMetadataValue};
27
28macro_rules! add_metrics_unique {
29    ($features:expr, $ua:expr, $added:expr) => {
30        for feature in $features {
31            if let Some(m) = feature.provide_business_metric() {
32                if !$added.contains(&m) {
33                    $added.insert(m.clone());
34                    $ua.add_business_metric(m);
35                }
36            }
37        }
38    };
39}
40
41macro_rules! add_metrics_unique_reverse {
42    ($features:expr, $ua:expr, $added:expr) => {
43        let mut unique_metrics = Vec::new();
44        for feature in $features {
45            if let Some(m) = feature.provide_business_metric() {
46                if !$added.contains(&m) {
47                    $added.insert(m.clone());
48                    unique_metrics.push(m);
49                }
50            }
51        }
52        for m in unique_metrics.into_iter().rev() {
53            $ua.add_business_metric(m);
54        }
55    };
56}
57
58#[allow(clippy::declare_interior_mutable_const)] // we will never mutate this
59const X_AMZ_USER_AGENT: HeaderName = HeaderName::from_static("x-amz-user-agent");
60
61#[derive(Debug)]
62enum UserAgentInterceptorError {
63    MissingApiMetadata,
64    InvalidHeaderValue(InvalidHeaderValue),
65    InvalidMetadataValue(InvalidMetadataValue),
66}
67
68impl std::error::Error for UserAgentInterceptorError {
69    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
70        match self {
71            Self::InvalidHeaderValue(source) => Some(source),
72            Self::InvalidMetadataValue(source) => Some(source),
73            Self::MissingApiMetadata => None,
74        }
75    }
76}
77
78impl fmt::Display for UserAgentInterceptorError {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.write_str(match self {
81            Self::InvalidHeaderValue(_) => "AwsUserAgent generated an invalid HTTP header value. This is a bug. Please file an issue.",
82            Self::InvalidMetadataValue(_) => "AwsUserAgent generated an invalid metadata value. This is a bug. Please file an issue.",
83            Self::MissingApiMetadata => "The UserAgentInterceptor requires ApiMetadata to be set before the request is made. This is a bug. Please file an issue.",
84        })
85    }
86}
87
88impl From<InvalidHeaderValue> for UserAgentInterceptorError {
89    fn from(err: InvalidHeaderValue) -> Self {
90        UserAgentInterceptorError::InvalidHeaderValue(err)
91    }
92}
93
94impl From<InvalidMetadataValue> for UserAgentInterceptorError {
95    fn from(err: InvalidMetadataValue) -> Self {
96        UserAgentInterceptorError::InvalidMetadataValue(err)
97    }
98}
99
100/// Generates and attaches the AWS SDK's user agent to a HTTP request
101#[non_exhaustive]
102#[derive(Debug, Default)]
103pub struct UserAgentInterceptor;
104
105impl UserAgentInterceptor {
106    /// Creates a new `UserAgentInterceptor`
107    pub fn new() -> Self {
108        UserAgentInterceptor
109    }
110}
111
112fn header_values(
113    ua: &AwsUserAgent,
114) -> Result<(HeaderValue, HeaderValue), UserAgentInterceptorError> {
115    // Pay attention to the extremely subtle difference between ua_header and aws_ua_header below...
116    Ok((
117        HeaderValue::try_from(ua.ua_header())?,
118        HeaderValue::try_from(ua.aws_ua_header())?,
119    ))
120}
121
122#[dyn_dispatch_hint]
123impl Intercept for UserAgentInterceptor {
124    fn name(&self) -> &'static str {
125        "UserAgentInterceptor"
126    }
127
128    fn read_after_serialization(
129        &self,
130        _context: &BeforeTransmitInterceptorContextRef<'_>,
131        _runtime_components: &RuntimeComponents,
132        cfg: &mut ConfigBag,
133    ) -> Result<(), BoxError> {
134        // Allow for overriding the user agent by an earlier interceptor (so, for example,
135        // tests can use `AwsUserAgent::for_tests()`) by attempting to grab one out of the
136        // config bag before creating one.
137        if cfg.load::<AwsUserAgent>().is_some() {
138            return Ok(());
139        }
140
141        let api_metadata = cfg
142            .load::<ApiMetadata>()
143            .ok_or(UserAgentInterceptorError::MissingApiMetadata)?;
144        let mut ua = AwsUserAgent::new_from_environment(Env::real(), api_metadata.clone());
145
146        let maybe_app_name = cfg.load::<AppName>();
147        if let Some(app_name) = maybe_app_name {
148            ua.set_app_name(app_name.clone());
149        }
150
151        // Drain customer-supplied framework metadata appended via `StoreAppend`.
152        //
153        // `ConfigBag::load` over a `StoreAppend` type yields every appended value across ALL config
154        // layers in reverse insertion order (newest-first), e.g. entries copied in from `SdkConfig`
155        // followed by client-level entries come back last-to-first. To present entries to the
156        // customer in deterministic first-seen (insertion) order, we collect and reverse the
157        // iterator. We dedup on the exact (name, version) pair, preserving first-seen order, and
158        // cap the total number of unique entries at 10.
159        const MAX_FRAMEWORK_METADATA: usize = 10;
160        let appended: Vec<&aws_types::sdk_ua_metadata::FrameworkMetadata> = cfg
161            .load::<aws_types::sdk_ua_metadata::FrameworkMetadata>()
162            .collect();
163        let mut seen = std::collections::HashSet::new();
164        let mut kept = 0usize;
165        for md in appended.into_iter().rev() {
166            let key = (md.name().to_owned(), md.version().map(|v| v.to_owned()));
167            if !seen.insert(key) {
168                // Duplicate (name, version) -- skip.
169                continue;
170            }
171            if kept >= MAX_FRAMEWORK_METADATA {
172                tracing::warn!(
173                    "More than {MAX_FRAMEWORK_METADATA} unique framework metadata entries \
174                     were configured; only the first {MAX_FRAMEWORK_METADATA} will be included \
175                     in the user agent."
176                );
177                break;
178            }
179            // `md` is already the canonical `FrameworkMetadata` type, so no conversion is needed.
180            ua.add_framework_metadata(md.clone());
181            kept += 1;
182        }
183
184        cfg.interceptor_state().store_put(ua);
185
186        Ok(())
187    }
188
189    fn modify_before_signing(
190        &self,
191        context: &mut BeforeTransmitInterceptorContextMut<'_>,
192        runtime_components: &RuntimeComponents,
193        cfg: &mut ConfigBag,
194    ) -> Result<(), BoxError> {
195        let mut ua = cfg
196            .load::<AwsUserAgent>()
197            .expect("`AwsUserAgent should have been created in `read_before_execution`")
198            .clone();
199
200        let mut added_metrics = std::collections::HashSet::new();
201
202        add_metrics_unique!(cfg.load::<SmithySdkFeature>(), &mut ua, &mut added_metrics);
203        add_metrics_unique!(cfg.load::<AwsSdkFeature>(), &mut ua, &mut added_metrics);
204        // The order we emit credential features matters.
205        // Reverse to preserve emission order since StoreAppend pops backwards.
206        add_metrics_unique_reverse!(
207            cfg.load::<AwsCredentialFeature>(),
208            &mut ua,
209            &mut added_metrics
210        );
211
212        let maybe_connector_metadata = runtime_components
213            .http_client()
214            .and_then(|c| c.connector_metadata());
215        if let Some(connector_metadata) = maybe_connector_metadata {
216            let am = AdditionalMetadata::new(Cow::Owned(connector_metadata.to_string()))?;
217            ua.add_additional_metadata(am);
218        }
219
220        let headers = context.request_mut().headers_mut();
221        let (user_agent, x_amz_user_agent) = header_values(&ua)?;
222        headers.append(USER_AGENT, user_agent);
223        headers.append(X_AMZ_USER_AGENT, x_amz_user_agent);
224        Ok(())
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use aws_smithy_runtime_api::client::interceptors::context::{Input, InterceptorContext};
232    use aws_smithy_runtime_api::client::interceptors::Intercept;
233    use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
234    use aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder;
235    use aws_smithy_types::config_bag::{ConfigBag, Layer};
236    use aws_smithy_types::error::display::DisplayErrorContext;
237
238    fn expect_header<'a>(context: &'a InterceptorContext, header_name: &str) -> &'a str {
239        context
240            .request()
241            .expect("request is set")
242            .headers()
243            .get(header_name)
244            .unwrap()
245    }
246
247    fn context() -> InterceptorContext {
248        let mut context = InterceptorContext::new(Input::doesnt_matter());
249        context.enter_serialization_phase();
250        context.set_request(HttpRequest::empty());
251        let _ = context.take_input();
252        context.enter_before_transmit_phase();
253        context
254    }
255
256    #[test]
257    fn test_overridden_ua() {
258        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
259        let mut context = context();
260
261        let mut layer = Layer::new("test");
262        layer.store_put(AwsUserAgent::for_tests());
263        layer.store_put(ApiMetadata::new("unused", "unused"));
264        let mut cfg = ConfigBag::of_layers(vec![layer]);
265
266        let interceptor = UserAgentInterceptor::new();
267        let mut ctx = Into::into(&mut context);
268        interceptor
269            .modify_before_signing(&mut ctx, &rc, &mut cfg)
270            .unwrap();
271
272        let ua_header = expect_header(&context, "user-agent");
273        assert_eq!(AwsUserAgent::for_tests().ua_header(), ua_header);
274        assert!(!ua_header.contains("unused"));
275
276        let aws_ua_header = expect_header(&context, "x-amz-user-agent");
277        assert_eq!(AwsUserAgent::for_tests().ua_header(), aws_ua_header);
278        assert!(!aws_ua_header.contains("unused"));
279    }
280
281    #[test]
282    fn test_default_ua() {
283        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
284        let mut context = context();
285
286        let api_metadata = ApiMetadata::new("some-service", "some-version");
287        let mut layer = Layer::new("test");
288        layer.store_put(api_metadata.clone());
289        let mut config = ConfigBag::of_layers(vec![layer]);
290
291        let interceptor = UserAgentInterceptor::new();
292        let ctx = Into::into(&context);
293        interceptor
294            .read_after_serialization(&ctx, &rc, &mut config)
295            .unwrap();
296        let mut ctx = Into::into(&mut context);
297        interceptor
298            .modify_before_signing(&mut ctx, &rc, &mut config)
299            .unwrap();
300
301        let expected_ua = AwsUserAgent::new_from_environment(Env::real(), api_metadata);
302        assert!(
303            expected_ua.aws_ua_header().contains("some-service"),
304            "precondition"
305        );
306        assert_eq!(
307            expected_ua.ua_header(),
308            expect_header(&context, "user-agent")
309        );
310        assert_eq!(
311            expected_ua.aws_ua_header(),
312            expect_header(&context, "x-amz-user-agent")
313        );
314    }
315
316    #[test]
317    fn test_modify_before_signing_no_duplicate_metrics() {
318        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
319        let mut context = context();
320
321        let api_metadata = ApiMetadata::new("test-service", "1.0");
322        let mut layer = Layer::new("test");
323        layer.store_put(api_metadata);
324        // Duplicate features
325        layer.store_append(SmithySdkFeature::Waiter);
326        layer.store_append(SmithySdkFeature::Waiter);
327        layer.store_append(AwsSdkFeature::S3Transfer);
328        layer.store_append(AwsSdkFeature::S3Transfer);
329        layer.store_append(AwsCredentialFeature::CredentialsCode);
330        layer.store_append(AwsCredentialFeature::CredentialsCode);
331        let mut config = ConfigBag::of_layers(vec![layer]);
332
333        let interceptor = UserAgentInterceptor::new();
334        let ctx = Into::into(&context);
335        interceptor
336            .read_after_serialization(&ctx, &rc, &mut config)
337            .unwrap();
338        let mut ctx = Into::into(&mut context);
339        interceptor
340            .modify_before_signing(&mut ctx, &rc, &mut config)
341            .unwrap();
342
343        let ua_header = expect_header(&context, "x-amz-user-agent");
344        let aws_ua_header = expect_header(&context, "x-amz-user-agent");
345        let ua_metrics_section = ua_header.split(" m/").nth(1).unwrap();
346        let aws_ua_metrics_section = aws_ua_header.split(" m/").nth(1).unwrap();
347        assert_eq!(ua_metrics_section, aws_ua_metrics_section);
348
349        let waiter_count = ua_metrics_section.matches("B").count();
350        let s3_transfer_count = ua_metrics_section.matches("G").count();
351        let credentials_code_count = ua_metrics_section.matches("e").count();
352        assert_eq!(
353            1, waiter_count,
354            "Waiter metric should appear only once, but found {waiter_count} occurrences in: {aws_ua_header}",
355        );
356        assert_eq!(1, s3_transfer_count, "S3Transfer metric should appear only once, but found {s3_transfer_count} occurrences in metrics section: {aws_ua_header}");
357        assert_eq!(1, credentials_code_count, "CredentialsCode metric should appear only once, but found {credentials_code_count} occurrences in metrics section: {aws_ua_header}");
358    }
359
360    #[test]
361    fn test_s3_transfer_manager_features_reach_the_header() {
362        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
363        let mut context = context();
364
365        let mut layer = Layer::new("test");
366        layer.store_put(ApiMetadata::new("test-service", "1.0"));
367        for feature in [
368            AwsSdkFeature::S3TransferUploadDirectory,
369            AwsSdkFeature::S3TransferDownloadDirectory,
370            AwsSdkFeature::S3CustomPartSize,
371            AwsSdkFeature::S3CustomThroughput,
372            AwsSdkFeature::S3CustomMemoryLimit,
373            AwsSdkFeature::S3OnEc2,
374            AwsSdkFeature::S3FilePath,
375        ] {
376            layer.store_append(feature);
377        }
378        let mut config = ConfigBag::of_layers(vec![layer]);
379
380        let interceptor = UserAgentInterceptor::new();
381        let ctx = Into::into(&context);
382        interceptor
383            .read_after_serialization(&ctx, &rc, &mut config)
384            .unwrap();
385        let mut ctx = Into::into(&mut context);
386        interceptor
387            .modify_before_signing(&mut ctx, &rc, &mut config)
388            .unwrap();
389
390        let header = expect_header(&context, "x-amz-user-agent");
391        let metrics = header.split(" m/").nth(1).unwrap();
392        let mut ids: Vec<_> = metrics.split(',').collect();
393        ids.sort_unstable();
394        assert_eq!(vec!["+", "9", "AX", "AY", "AZ", "Aa", "Ab"], ids);
395    }
396
397    #[test]
398    fn test_metrics_order_preserved() {
399        use aws_credential_types::credential_feature::AwsCredentialFeature;
400
401        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
402        let mut context = context();
403
404        let api_metadata = ApiMetadata::new("test-service", "1.0");
405        let mut layer = Layer::new("test");
406        layer.store_put(api_metadata);
407        layer.store_append(AwsCredentialFeature::CredentialsCode);
408        layer.store_append(AwsCredentialFeature::CredentialsEnvVars);
409        layer.store_append(AwsCredentialFeature::CredentialsProfile);
410        let mut config = ConfigBag::of_layers(vec![layer]);
411
412        let interceptor = UserAgentInterceptor::new();
413        let ctx = Into::into(&context);
414        interceptor
415            .read_after_serialization(&ctx, &rc, &mut config)
416            .unwrap();
417        let mut ctx = Into::into(&mut context);
418        interceptor
419            .modify_before_signing(&mut ctx, &rc, &mut config)
420            .unwrap();
421
422        let ua_header = expect_header(&context, "user-agent");
423        let ua_metrics_section = ua_header.split(" m/").nth(1).unwrap();
424        assert_eq!(
425            ua_metrics_section, "e,g,n",
426            "AwsCredentialFeature metrics should preserve order"
427        );
428
429        let aws_ua_header = expect_header(&context, "x-amz-user-agent");
430        let aws_ua_metrics_section = aws_ua_header.split(" m/").nth(1).unwrap();
431        assert_eq!(
432            aws_ua_metrics_section, "e,g,n",
433            "AwsCredentialFeature metrics should preserve order"
434        );
435    }
436
437    #[test]
438    fn test_app_name() {
439        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
440        let mut context = context();
441
442        let api_metadata = ApiMetadata::new("some-service", "some-version");
443        let mut layer = Layer::new("test");
444        layer.store_put(api_metadata);
445        layer.store_put(AppName::new("my_awesome_app").unwrap());
446        let mut config = ConfigBag::of_layers(vec![layer]);
447
448        let interceptor = UserAgentInterceptor::new();
449        let ctx = Into::into(&context);
450        interceptor
451            .read_after_serialization(&ctx, &rc, &mut config)
452            .unwrap();
453        let mut ctx = Into::into(&mut context);
454        interceptor
455            .modify_before_signing(&mut ctx, &rc, &mut config)
456            .unwrap();
457
458        let app_value = "app/my_awesome_app";
459        let ua_header = expect_header(&context, "user-agent");
460        assert!(
461            ua_header.contains(app_value),
462            "expected `{ua_header}` to contain `{app_value}`"
463        );
464
465        let aws_ua_header = expect_header(&context, "x-amz-user-agent");
466        assert!(
467            aws_ua_header.contains(app_value),
468            "expected `{aws_ua_header}` to contain `{app_value}`"
469        );
470    }
471
472    fn run_interceptor_with_framework_metadata(
473        entries: Vec<aws_types::sdk_ua_metadata::FrameworkMetadata>,
474    ) -> String {
475        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
476        let mut context = context();
477
478        let api_metadata = ApiMetadata::new("some-service", "some-version");
479        let mut layer = Layer::new("test");
480        layer.store_put(api_metadata);
481        for entry in entries {
482            layer.store_append(entry);
483        }
484        let mut config = ConfigBag::of_layers(vec![layer]);
485
486        let interceptor = UserAgentInterceptor::new();
487        let ctx = Into::into(&context);
488        interceptor
489            .read_after_serialization(&ctx, &rc, &mut config)
490            .unwrap();
491        let mut ctx = Into::into(&mut context);
492        interceptor
493            .modify_before_signing(&mut ctx, &rc, &mut config)
494            .unwrap();
495
496        expect_header(&context, "x-amz-user-agent").to_string()
497    }
498
499    #[test]
500    fn test_framework_metadata() {
501        use aws_types::sdk_ua_metadata::FrameworkMetadata;
502
503        let header = run_interceptor_with_framework_metadata(vec![
504            FrameworkMetadata::new("framework-one", Some("1.0")).unwrap(),
505            FrameworkMetadata::new("framework-two", Some("2.0")).unwrap(),
506        ]);
507
508        assert!(
509            header.contains("lib/framework-one/1.0"),
510            "expected `{header}` to contain `lib/framework-one/1.0`"
511        );
512        assert!(
513            header.contains("lib/framework-two/2.0"),
514            "expected `{header}` to contain `lib/framework-two/2.0`"
515        );
516        // First-seen order must be preserved.
517        let pos_one = header.find("lib/framework-one/1.0").unwrap();
518        let pos_two = header.find("lib/framework-two/2.0").unwrap();
519        assert!(
520            pos_one < pos_two,
521            "expected framework-one before framework-two in `{header}`"
522        );
523    }
524
525    #[test]
526    fn test_framework_metadata_dedup() {
527        use aws_types::sdk_ua_metadata::FrameworkMetadata;
528
529        let header = run_interceptor_with_framework_metadata(vec![
530            FrameworkMetadata::new("dup-framework", Some("1.0")).unwrap(),
531            FrameworkMetadata::new("dup-framework", Some("1.0")).unwrap(),
532        ]);
533
534        assert_eq!(
535            1,
536            header.matches("lib/dup-framework/1.0").count(),
537            "expected `lib/dup-framework/1.0` to appear exactly once in `{header}`"
538        );
539    }
540
541    #[test]
542    fn test_framework_metadata_cap() {
543        use aws_types::sdk_ua_metadata::FrameworkMetadata;
544
545        let entries = (0..15)
546            .map(|i| FrameworkMetadata::new(format!("framework-{i}"), Some("1.0")).unwrap())
547            .collect();
548        let header = run_interceptor_with_framework_metadata(entries);
549
550        let count = header.matches("lib/framework-").count();
551        assert_eq!(
552            10, count,
553            "expected at most 10 framework metadata entries, found {count} in `{header}`"
554        );
555    }
556
557    #[test]
558    fn test_framework_metadata_cap_boundary() {
559        use aws_types::sdk_ua_metadata::FrameworkMetadata;
560
561        // Exactly 10 unique entries: all retained.
562        let ten = (0..10)
563            .map(|i| FrameworkMetadata::new(format!("fw-{i}"), Some("1.0")).unwrap())
564            .collect();
565        let header = run_interceptor_with_framework_metadata(ten);
566        for i in 0..10 {
567            assert!(
568                header.contains(&format!("lib/fw-{i}/1.0")),
569                "expected `lib/fw-{i}/1.0` in `{header}`"
570            );
571        }
572
573        // Eleven unique entries: the first 10 (first-seen) are retained, the 11th is dropped.
574        let eleven = (0..11)
575            .map(|i| FrameworkMetadata::new(format!("gw-{i}"), Some("1.0")).unwrap())
576            .collect();
577        let header = run_interceptor_with_framework_metadata(eleven);
578        assert_eq!(
579            10,
580            header.matches("lib/gw-").count(),
581            "expected exactly 10 entries in `{header}`"
582        );
583        assert!(
584            !header.contains("lib/gw-10/1.0"),
585            "the 11th entry should be dropped in `{header}`"
586        );
587    }
588
589    #[test]
590    fn test_framework_metadata_dedup_preserves_first_occurrence_order() {
591        use aws_types::sdk_ua_metadata::FrameworkMetadata;
592
593        // A duplicate of an earlier entry must not reorder the surviving entries.
594        let header = run_interceptor_with_framework_metadata(vec![
595            FrameworkMetadata::new("a", Some("1.0")).unwrap(),
596            FrameworkMetadata::new("b", Some("1.0")).unwrap(),
597            FrameworkMetadata::new("a", Some("1.0")).unwrap(), // duplicate of the first
598            FrameworkMetadata::new("c", Some("1.0")).unwrap(),
599        ]);
600
601        let pa = header.find("lib/a/1.0").expect("a present");
602        let pb = header.find("lib/b/1.0").expect("b present");
603        let pc = header.find("lib/c/1.0").expect("c present");
604        assert!(
605            pa < pb && pb < pc,
606            "expected first-seen order a, b, c in `{header}`"
607        );
608        assert_eq!(
609            1,
610            header.matches("lib/a/1.0").count(),
611            "duplicate entry should appear once in `{header}`"
612        );
613    }
614
615    #[test]
616    fn test_framework_metadata_no_version() {
617        use aws_types::sdk_ua_metadata::FrameworkMetadata;
618
619        let header = run_interceptor_with_framework_metadata(vec![FrameworkMetadata::new(
620            "noversion-framework",
621            None::<&str>,
622        )
623        .unwrap()]);
624
625        assert!(
626            header.contains("lib/noversion-framework"),
627            "expected `lib/noversion-framework` in `{header}`"
628        );
629        assert!(
630            !header.contains("lib/noversion-framework/"),
631            "expected no trailing slash for a versionless entry in `{header}`"
632        );
633    }
634
635    #[test]
636    fn test_framework_metadata_across_layers_preserves_first_seen_order() {
637        use aws_types::sdk_ua_metadata::FrameworkMetadata;
638
639        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
640        let mut context = context();
641
642        // Simulate the real flow: entries copied in from `SdkConfig` live in an earlier (base)
643        // layer, and client-level entries are appended in a later layer. First-seen order must be
644        // preserved across both layers despite `StoreAppend` loading newest-first.
645        let mut base_layer = Layer::new("from_sdk_config");
646        base_layer.store_put(ApiMetadata::new("some-service", "some-version"));
647        base_layer.store_append(FrameworkMetadata::new("from-sdk-config", Some("1.0")).unwrap());
648
649        let mut client_layer = Layer::new("from_client");
650        client_layer.store_append(FrameworkMetadata::new("from-client", Some("2.0")).unwrap());
651
652        let mut config = ConfigBag::of_layers(vec![base_layer, client_layer]);
653
654        let interceptor = UserAgentInterceptor::new();
655        let ctx = Into::into(&context);
656        interceptor
657            .read_after_serialization(&ctx, &rc, &mut config)
658            .unwrap();
659        let mut ctx = Into::into(&mut context);
660        interceptor
661            .modify_before_signing(&mut ctx, &rc, &mut config)
662            .unwrap();
663        let header = expect_header(&context, "x-amz-user-agent");
664
665        let pos_sdk = header
666            .find("lib/from-sdk-config/1.0")
667            .expect("sdk-config entry present");
668        let pos_client = header
669            .find("lib/from-client/2.0")
670            .expect("client entry present");
671        assert!(
672            pos_sdk < pos_client,
673            "expected the SdkConfig entry before the client entry in `{header}`"
674        );
675    }
676
677    #[test]
678    fn test_api_metadata_missing() {
679        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
680        let context = context();
681        let mut config = ConfigBag::base();
682
683        let interceptor = UserAgentInterceptor::new();
684        let ctx = Into::into(&context);
685
686        let error = format!(
687            "{}",
688            DisplayErrorContext(
689                &*interceptor
690                    .read_after_serialization(&ctx, &rc, &mut config)
691                    .expect_err("it should error")
692            )
693        );
694        assert!(
695            error.contains("This is a bug"),
696            "`{error}` should contain message `This is a bug`"
697        );
698    }
699
700    #[test]
701    fn test_api_metadata_missing_with_ua_override() {
702        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
703        let mut context = context();
704
705        let mut layer = Layer::new("test");
706        layer.store_put(AwsUserAgent::for_tests());
707        let mut config = ConfigBag::of_layers(vec![layer]);
708
709        let interceptor = UserAgentInterceptor::new();
710        let mut ctx = Into::into(&mut context);
711
712        interceptor
713            .modify_before_signing(&mut ctx, &rc, &mut config)
714            .expect("it should succeed");
715
716        let ua_header = expect_header(&context, "user-agent");
717        assert_eq!(AwsUserAgent::for_tests().ua_header(), ua_header);
718        assert!(!ua_header.contains("unused"));
719
720        let aws_ua_header = expect_header(&context, "x-amz-user-agent");
721        assert_eq!(AwsUserAgent::for_tests().ua_header(), aws_ua_header);
722        assert!(!aws_ua_header.contains("unused"));
723    }
724}