1# Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License"). You
4# may not use this file except in compliance with the License. A copy of
5# the License is located at
6#
7# http://aws.amazon.com/apache2.0/
8#
9# or in the "license" file accompanying this file. This file is
10# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11# ANY KIND, either express or implied. See the License for the specific
12# language governing permissions and limitations under the License.
13"""
14NOTE: All classes and functions in this module are considered private and are
15subject to abrupt breaking changes. Please do not use them directly.
16
17To modify the User-Agent header sent by botocore, use one of these
18configuration options:
19* The ``AWS_SDK_UA_APP_ID`` environment variable.
20* The ``sdk_ua_app_id`` setting in the shared AWS config file.
21* The ``user_agent_appid`` field in the :py:class:`botocore.config.Config`.
22* The ``user_agent_extra`` field in the :py:class:`botocore.config.Config`.
23
24"""
25
26import logging
27import os
28import platform
29from copy import copy
30from string import ascii_letters, digits
31from typing import NamedTuple, Optional
32
33from botocore import __version__ as botocore_version
34from botocore.compat import HAS_CRT
35from botocore.context import get_context
36
37logger = logging.getLogger(__name__)
38
39
40_USERAGENT_ALLOWED_CHARACTERS = ascii_letters + digits + "!$%&'*+-.^_`|~,"
41_USERAGENT_ALLOWED_OS_NAMES = (
42 'windows',
43 'linux',
44 'macos',
45 'android',
46 'ios',
47 'watchos',
48 'tvos',
49 'other',
50)
51_USERAGENT_PLATFORM_NAME_MAPPINGS = {'darwin': 'macos'}
52# The name by which botocore is identified in the User-Agent header. While most
53# AWS SDKs follow a naming pattern of "aws-sdk-*", botocore and boto3 continue
54# using their existing values. Uses uppercase "B" with all other characters
55# lowercase.
56_USERAGENT_SDK_NAME = 'Botocore'
57_USERAGENT_FEATURE_MAPPINGS = {
58 'WAITER': 'B',
59 'PAGINATOR': 'C',
60 "RETRY_MODE_LEGACY": "D",
61 "RETRY_MODE_STANDARD": "E",
62 "RETRY_MODE_ADAPTIVE": "F",
63 'S3_TRANSFER': 'G',
64 'GZIP_REQUEST_COMPRESSION': 'L',
65 'PROTOCOL_RPC_V2_CBOR': 'M',
66 'ENDPOINT_OVERRIDE': 'N',
67 'ACCOUNT_ID_MODE_PREFERRED': 'P',
68 'ACCOUNT_ID_MODE_DISABLED': 'Q',
69 'ACCOUNT_ID_MODE_REQUIRED': 'R',
70 'SIGV4A_SIGNING': 'S',
71 'RESOLVED_ACCOUNT_ID': 'T',
72 'FLEXIBLE_CHECKSUMS_REQ_CRC32': 'U',
73 'FLEXIBLE_CHECKSUMS_REQ_CRC32C': 'V',
74 'FLEXIBLE_CHECKSUMS_REQ_CRC64': 'W',
75 'FLEXIBLE_CHECKSUMS_REQ_SHA1': 'X',
76 'FLEXIBLE_CHECKSUMS_REQ_SHA256': 'Y',
77 'FLEXIBLE_CHECKSUMS_REQ_WHEN_SUPPORTED': 'Z',
78 'FLEXIBLE_CHECKSUMS_REQ_WHEN_REQUIRED': 'a',
79 'FLEXIBLE_CHECKSUMS_RES_WHEN_SUPPORTED': 'b',
80 'FLEXIBLE_CHECKSUMS_RES_WHEN_REQUIRED': 'c',
81 'BEARER_SERVICE_ENV_VARS': '3',
82}
83
84
85def register_feature_id(feature_id):
86 """Adds metric value to the current context object's ``features`` set.
87
88 :type feature_id: str
89 :param feature_id: The name of the feature to register. Value must be a key
90 in the ``_USERAGENT_FEATURE_MAPPINGS`` dict.
91 """
92 ctx = get_context()
93 if ctx is None:
94 # Never register features outside the scope of a
95 # ``botocore.context.start_as_current_context`` context manager.
96 # Otherwise, the context variable won't be reset and features will
97 # bleed into all subsequent requests. Return instead of raising an
98 # exception since this function could be invoked in a public interface.
99 return
100 if val := _USERAGENT_FEATURE_MAPPINGS.get(feature_id):
101 ctx.features.add(val)
102
103
104def sanitize_user_agent_string_component(raw_str, allow_hash):
105 """Replaces all not allowed characters in the string with a dash ("-").
106
107 Allowed characters are ASCII alphanumerics and ``!$%&'*+-.^_`|~,``. If
108 ``allow_hash`` is ``True``, "#"``" is also allowed.
109
110 :type raw_str: str
111 :param raw_str: The input string to be sanitized.
112
113 :type allow_hash: bool
114 :param allow_hash: Whether "#" is considered an allowed character.
115 """
116 return ''.join(
117 c
118 if c in _USERAGENT_ALLOWED_CHARACTERS or (allow_hash and c == '#')
119 else '-'
120 for c in raw_str
121 )
122
123
124class UserAgentComponentSizeConfig:
125 """
126 Configures the max size of a built user agent string component and the
127 delimiter used to truncate the string if the size is above the max.
128 """
129
130 def __init__(self, max_size_in_bytes: int, delimiter: str):
131 self.max_size_in_bytes = max_size_in_bytes
132 self.delimiter = delimiter
133 self._validate_input()
134
135 def _validate_input(self):
136 if self.max_size_in_bytes < 1:
137 raise ValueError(
138 f'Invalid `max_size_in_bytes`: {self.max_size_in_bytes}. '
139 'Value must be a positive integer.'
140 )
141
142
143class UserAgentComponent(NamedTuple):
144 """
145 Component of a Botocore User-Agent header string in the standard format.
146
147 Each component consists of a prefix, a name, a value, and a size_config.
148 In the string representation these are combined in the format
149 ``prefix/name#value``.
150
151 ``size_config`` configures the max size and truncation strategy for the
152 built user agent string component.
153
154 This class is considered private and is subject to abrupt breaking changes.
155 """
156
157 prefix: str
158 name: str
159 value: Optional[str] = None
160 size_config: Optional[UserAgentComponentSizeConfig] = None
161
162 def to_string(self):
163 """Create string like 'prefix/name#value' from a UserAgentComponent."""
164 clean_prefix = sanitize_user_agent_string_component(
165 self.prefix, allow_hash=True
166 )
167 clean_name = sanitize_user_agent_string_component(
168 self.name, allow_hash=False
169 )
170 if self.value is None or self.value == '':
171 clean_string = f'{clean_prefix}/{clean_name}'
172 else:
173 clean_value = sanitize_user_agent_string_component(
174 self.value, allow_hash=True
175 )
176 clean_string = f'{clean_prefix}/{clean_name}#{clean_value}'
177 if self.size_config is not None:
178 clean_string = self._truncate_string(
179 clean_string,
180 self.size_config.max_size_in_bytes,
181 self.size_config.delimiter,
182 )
183 return clean_string
184
185 def _truncate_string(self, string, max_size, delimiter):
186 """
187 Pop ``delimiter``-separated values until encoded string is less than or
188 equal to ``max_size``.
189 """
190 orig = string
191 while len(string.encode('utf-8')) > max_size:
192 parts = string.split(delimiter)
193 parts.pop()
194 string = delimiter.join(parts)
195
196 if string == '':
197 logger.debug(
198 f"User agent component `{orig}` could not be truncated to "
199 f"`{max_size}` bytes with delimiter "
200 f"`{delimiter}` without losing all contents. "
201 f"Value will be omitted from user agent string."
202 )
203 return string
204
205
206class RawStringUserAgentComponent:
207 """
208 UserAgentComponent interface wrapper around ``str``.
209
210 Use for User-Agent header components that are not constructed from
211 prefix+name+value but instead are provided as strings. No sanitization is
212 performed.
213 """
214
215 def __init__(self, value):
216 self._value = value
217
218 def to_string(self):
219 return self._value
220
221
222# This is not a public interface and is subject to abrupt breaking changes.
223# Any usage is not advised or supported in external code bases.
224try:
225 from botocore.customizations.useragent import modify_components
226except ImportError:
227 # Default implementation that returns unmodified User-Agent components.
228 def modify_components(components):
229 return components
230
231
232class UserAgentString:
233 """
234 Generator for AWS SDK User-Agent header strings.
235
236 The User-Agent header format contains information from session, client, and
237 request context. ``UserAgentString`` provides methods for collecting the
238 information and ``to_string`` for assembling it into the standardized
239 string format.
240
241 Example usage:
242
243 ua_session = UserAgentString.from_environment()
244 ua_session.set_session_config(...)
245 ua_client = ua_session.with_client_config(Config(...))
246 ua_string = ua_request.to_string()
247
248 For testing or when information from all sources is available at the same
249 time, the methods can be chained:
250
251 ua_string = (
252 UserAgentString
253 .from_environment()
254 .set_session_config(...)
255 .with_client_config(Config(...))
256 .to_string()
257 )
258
259 """
260
261 def __init__(
262 self,
263 platform_name,
264 platform_version,
265 platform_machine,
266 python_version,
267 python_implementation,
268 execution_env,
269 crt_version=None,
270 ):
271 """
272 :type platform_name: str
273 :param platform_name: Name of the operating system or equivalent
274 platform name. Should be sourced from :py:meth:`platform.system`.
275 :type platform_version: str
276 :param platform_version: Version of the operating system or equivalent
277 platform name. Should be sourced from :py:meth:`platform.version`.
278 :type platform_machine: str
279 :param platform_version: Processor architecture or machine type. For
280 example "x86_64". Should be sourced from :py:meth:`platform.machine`.
281 :type python_version: str
282 :param python_version: Version of the python implementation as str.
283 Should be sourced from :py:meth:`platform.python_version`.
284 :type python_implementation: str
285 :param python_implementation: Name of the python implementation.
286 Should be sourced from :py:meth:`platform.python_implementation`.
287 :type execution_env: str
288 :param execution_env: The value of the AWS execution environment.
289 Should be sourced from the ``AWS_EXECUTION_ENV` environment
290 variable.
291 :type crt_version: str
292 :param crt_version: Version string of awscrt package, if installed.
293 """
294 self._platform_name = platform_name
295 self._platform_version = platform_version
296 self._platform_machine = platform_machine
297 self._python_version = python_version
298 self._python_implementation = python_implementation
299 self._execution_env = execution_env
300 self._crt_version = crt_version
301
302 # Components that can be added with ``set_session_config()``
303 self._session_user_agent_name = None
304 self._session_user_agent_version = None
305 self._session_user_agent_extra = None
306
307 self._client_config = None
308
309 # Component that can be set with ``set_client_features()``
310 self._client_features = None
311
312 @classmethod
313 def from_environment(cls):
314 crt_version = None
315 if HAS_CRT:
316 crt_version = _get_crt_version() or 'Unknown'
317 return cls(
318 platform_name=platform.system(),
319 platform_version=platform.release(),
320 platform_machine=platform.machine(),
321 python_version=platform.python_version(),
322 python_implementation=platform.python_implementation(),
323 execution_env=os.environ.get('AWS_EXECUTION_ENV'),
324 crt_version=crt_version,
325 )
326
327 def set_session_config(
328 self,
329 session_user_agent_name,
330 session_user_agent_version,
331 session_user_agent_extra,
332 ):
333 """
334 Set the user agent configuration values that apply at session level.
335
336 :param user_agent_name: The user agent name configured in the
337 :py:class:`botocore.session.Session` object. For backwards
338 compatibility, this will always be at the beginning of the
339 User-Agent string, together with ``user_agent_version``.
340 :param user_agent_version: The user agent version configured in the
341 :py:class:`botocore.session.Session` object.
342 :param user_agent_extra: The user agent "extra" configured in the
343 :py:class:`botocore.session.Session` object.
344 """
345 self._session_user_agent_name = session_user_agent_name
346 self._session_user_agent_version = session_user_agent_version
347 self._session_user_agent_extra = session_user_agent_extra
348 return self
349
350 def set_client_features(self, features):
351 """
352 Persist client-specific features registered before or during client
353 creation.
354
355 :type features: Set[str]
356 :param features: A set of client-specific features.
357 """
358 self._client_features = features
359
360 def with_client_config(self, client_config):
361 """
362 Create a copy with all original values and client-specific values.
363
364 :type client_config: botocore.config.Config
365 :param client_config: The client configuration object.
366 """
367 cp = copy(self)
368 cp._client_config = client_config
369 return cp
370
371 def to_string(self):
372 """
373 Build User-Agent header string from the object's properties.
374 """
375 config_ua_override = None
376 if self._client_config:
377 if hasattr(self._client_config, '_supplied_user_agent'):
378 config_ua_override = self._client_config._supplied_user_agent
379 else:
380 config_ua_override = self._client_config.user_agent
381
382 if config_ua_override is not None:
383 return self._build_legacy_ua_string(config_ua_override)
384
385 components = [
386 *self._build_sdk_metadata(),
387 RawStringUserAgentComponent('ua/2.1'),
388 *self._build_os_metadata(),
389 *self._build_architecture_metadata(),
390 *self._build_language_metadata(),
391 *self._build_execution_env_metadata(),
392 *self._build_feature_metadata(),
393 *self._build_config_metadata(),
394 *self._build_app_id(),
395 *self._build_extra(),
396 ]
397
398 components = modify_components(components)
399
400 return ' '.join(
401 [comp.to_string() for comp in components if comp.to_string()]
402 )
403
404 def _build_sdk_metadata(self):
405 """
406 Build the SDK name and version component of the User-Agent header.
407
408 For backwards-compatibility both session-level and client-level config
409 of custom tool names are honored. If this removes the Botocore
410 information from the start of the string, Botocore's name and version
411 are included as a separate field with "md" prefix.
412 """
413 sdk_md = []
414 if (
415 self._session_user_agent_name
416 and self._session_user_agent_version
417 and (
418 self._session_user_agent_name != _USERAGENT_SDK_NAME
419 or self._session_user_agent_version != botocore_version
420 )
421 ):
422 sdk_md.extend(
423 [
424 UserAgentComponent(
425 self._session_user_agent_name,
426 self._session_user_agent_version,
427 ),
428 UserAgentComponent(
429 'md', _USERAGENT_SDK_NAME, botocore_version
430 ),
431 ]
432 )
433 else:
434 sdk_md.append(
435 UserAgentComponent(_USERAGENT_SDK_NAME, botocore_version)
436 )
437
438 if self._crt_version is not None:
439 sdk_md.append(
440 UserAgentComponent('md', 'awscrt', self._crt_version)
441 )
442
443 return sdk_md
444
445 def _build_os_metadata(self):
446 """
447 Build the OS/platform components of the User-Agent header string.
448
449 For recognized platform names that match or map to an entry in the list
450 of standardized OS names, a single component with prefix "os" is
451 returned. Otherwise, one component "os/other" is returned and a second
452 with prefix "md" and the raw platform name.
453
454 String representations of example return values:
455 * ``os/macos#10.13.6``
456 * ``os/linux``
457 * ``os/other``
458 * ``os/other md/foobar#1.2.3``
459 """
460 if self._platform_name is None:
461 return [UserAgentComponent('os', 'other')]
462
463 plt_name_lower = self._platform_name.lower()
464 if plt_name_lower in _USERAGENT_ALLOWED_OS_NAMES:
465 os_family = plt_name_lower
466 elif plt_name_lower in _USERAGENT_PLATFORM_NAME_MAPPINGS:
467 os_family = _USERAGENT_PLATFORM_NAME_MAPPINGS[plt_name_lower]
468 else:
469 os_family = None
470
471 if os_family is not None:
472 return [
473 UserAgentComponent('os', os_family, self._platform_version)
474 ]
475 else:
476 return [
477 UserAgentComponent('os', 'other'),
478 UserAgentComponent(
479 'md', self._platform_name, self._platform_version
480 ),
481 ]
482
483 def _build_architecture_metadata(self):
484 """
485 Build architecture component of the User-Agent header string.
486
487 Returns the machine type with prefix "md" and name "arch", if one is
488 available. Common values include "x86_64", "arm64", "i386".
489 """
490 if self._platform_machine:
491 return [
492 UserAgentComponent(
493 'md', 'arch', self._platform_machine.lower()
494 )
495 ]
496 return []
497
498 def _build_language_metadata(self):
499 """
500 Build the language components of the User-Agent header string.
501
502 Returns the Python version in a component with prefix "lang" and name
503 "python". The Python implementation (e.g. CPython, PyPy) is returned as
504 separate metadata component with prefix "md" and name "pyimpl".
505
506 String representation of an example return value:
507 ``lang/python#3.10.4 md/pyimpl#CPython``
508 """
509 lang_md = [
510 UserAgentComponent('lang', 'python', self._python_version),
511 ]
512 if self._python_implementation:
513 lang_md.append(
514 UserAgentComponent('md', 'pyimpl', self._python_implementation)
515 )
516 return lang_md
517
518 def _build_execution_env_metadata(self):
519 """
520 Build the execution environment component of the User-Agent header.
521
522 Returns a single component prefixed with "exec-env", usually sourced
523 from the environment variable AWS_EXECUTION_ENV.
524 """
525 if self._execution_env:
526 return [UserAgentComponent('exec-env', self._execution_env)]
527 else:
528 return []
529
530 def _build_feature_metadata(self):
531 """
532 Build the features component of the User-Agent header string.
533
534 Returns a single component with prefix "m" followed by a list of
535 comma-separated metric values.
536 """
537 ctx = get_context()
538 context_features = set() if ctx is None else ctx.features
539 client_features = self._client_features or set()
540 features = client_features.union(context_features)
541 if not features:
542 return []
543 size_config = UserAgentComponentSizeConfig(1024, ',')
544 return [
545 UserAgentComponent(
546 'm', ','.join(features), size_config=size_config
547 )
548 ]
549
550 def _build_config_metadata(self):
551 """
552 Build the configuration components of the User-Agent header string.
553
554 Returns a list of components with prefix "cfg" followed by the config
555 setting name and its value. Tracked configuration settings may be
556 added or removed in future versions.
557 """
558 if not self._client_config or not self._client_config.retries:
559 return []
560 retry_mode = self._client_config.retries.get('mode')
561 cfg_md = [UserAgentComponent('cfg', 'retry-mode', retry_mode)]
562 if self._client_config.endpoint_discovery_enabled:
563 cfg_md.append(UserAgentComponent('cfg', 'endpoint-discovery'))
564 return cfg_md
565
566 def _build_app_id(self):
567 """
568 Build app component of the User-Agent header string.
569
570 Returns a single component with prefix "app" and value sourced from the
571 ``user_agent_appid`` field in :py:class:`botocore.config.Config` or
572 the ``sdk_ua_app_id`` setting in the shared configuration file, or the
573 ``AWS_SDK_UA_APP_ID`` environment variable. These are the recommended
574 ways for apps built with Botocore to insert their identifer into the
575 User-Agent header.
576 """
577 if self._client_config and self._client_config.user_agent_appid:
578 return [
579 UserAgentComponent('app', self._client_config.user_agent_appid)
580 ]
581 else:
582 return []
583
584 def _build_extra(self):
585 """User agent string components based on legacy "extra" settings.
586
587 Creates components from the session-level and client-level
588 ``user_agent_extra`` setting, if present. Both are passed through
589 verbatim and should be appended at the end of the string.
590
591 Preferred ways to inject application-specific information into
592 botocore's User-Agent header string are the ``user_agent_appid` field
593 in :py:class:`botocore.config.Config`. The ``AWS_SDK_UA_APP_ID``
594 environment variable and the ``sdk_ua_app_id`` configuration file
595 setting are alternative ways to set the ``user_agent_appid`` config.
596 """
597 extra = []
598 if self._session_user_agent_extra:
599 extra.append(
600 RawStringUserAgentComponent(self._session_user_agent_extra)
601 )
602 if self._client_config and self._client_config.user_agent_extra:
603 extra.append(
604 RawStringUserAgentComponent(
605 self._client_config.user_agent_extra
606 )
607 )
608 return extra
609
610 def _build_legacy_ua_string(self, config_ua_override):
611 components = [config_ua_override]
612 if self._session_user_agent_extra:
613 components.append(self._session_user_agent_extra)
614 if self._client_config.user_agent_extra:
615 components.append(self._client_config.user_agent_extra)
616 return ' '.join(components)
617
618 def rebuild_and_replace_user_agent_handler(
619 self, operation_name, request, **kwargs
620 ):
621 ua_string = self.to_string()
622 if request.headers.get('User-Agent'):
623 request.headers.replace_header('User-Agent', ua_string)
624
625
626def _get_crt_version():
627 """
628 This function is considered private and is subject to abrupt breaking
629 changes.
630 """
631 try:
632 import awscrt
633
634 return awscrt.__version__
635 except AttributeError:
636 return None