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 "User agent component `%s` could not be truncated to "
199 "`%s` bytes with delimiter "
200 "`%s` without losing all contents. "
201 "Value will be omitted from user agent string.",
202 orig,
203 max_size,
204 delimiter,
205 )
206 return string
207
208
209class RawStringUserAgentComponent:
210 """
211 UserAgentComponent interface wrapper around ``str``.
212
213 Use for User-Agent header components that are not constructed from
214 prefix+name+value but instead are provided as strings. No sanitization is
215 performed.
216 """
217
218 def __init__(self, value):
219 self._value = value
220
221 def to_string(self):
222 return self._value
223
224
225# This is not a public interface and is subject to abrupt breaking changes.
226# Any usage is not advised or supported in external code bases.
227try:
228 from botocore.customizations.useragent import modify_components
229except ImportError:
230 # Default implementation that returns unmodified User-Agent components.
231 def modify_components(components):
232 return components
233
234
235class UserAgentString:
236 """
237 Generator for AWS SDK User-Agent header strings.
238
239 The User-Agent header format contains information from session, client, and
240 request context. ``UserAgentString`` provides methods for collecting the
241 information and ``to_string`` for assembling it into the standardized
242 string format.
243
244 Example usage:
245
246 ua_session = UserAgentString.from_environment()
247 ua_session.set_session_config(...)
248 ua_client = ua_session.with_client_config(Config(...))
249 ua_string = ua_request.to_string()
250
251 For testing or when information from all sources is available at the same
252 time, the methods can be chained:
253
254 ua_string = (
255 UserAgentString
256 .from_environment()
257 .set_session_config(...)
258 .with_client_config(Config(...))
259 .to_string()
260 )
261
262 """
263
264 def __init__(
265 self,
266 platform_name,
267 platform_version,
268 platform_machine,
269 python_version,
270 python_implementation,
271 execution_env,
272 crt_version=None,
273 ):
274 """
275 :type platform_name: str
276 :param platform_name: Name of the operating system or equivalent
277 platform name. Should be sourced from :py:meth:`platform.system`.
278 :type platform_version: str
279 :param platform_version: Version of the operating system or equivalent
280 platform name. Should be sourced from :py:meth:`platform.version`.
281 :type platform_machine: str
282 :param platform_version: Processor architecture or machine type. For
283 example "x86_64". Should be sourced from :py:meth:`platform.machine`.
284 :type python_version: str
285 :param python_version: Version of the python implementation as str.
286 Should be sourced from :py:meth:`platform.python_version`.
287 :type python_implementation: str
288 :param python_implementation: Name of the python implementation.
289 Should be sourced from :py:meth:`platform.python_implementation`.
290 :type execution_env: str
291 :param execution_env: The value of the AWS execution environment.
292 Should be sourced from the ``AWS_EXECUTION_ENV` environment
293 variable.
294 :type crt_version: str
295 :param crt_version: Version string of awscrt package, if installed.
296 """
297 self._platform_name = platform_name
298 self._platform_version = platform_version
299 self._platform_machine = platform_machine
300 self._python_version = python_version
301 self._python_implementation = python_implementation
302 self._execution_env = execution_env
303 self._crt_version = crt_version
304
305 # Components that can be added with ``set_session_config()``
306 self._session_user_agent_name = None
307 self._session_user_agent_version = None
308 self._session_user_agent_extra = None
309
310 self._client_config = None
311
312 # Component that can be set with ``set_client_features()``
313 self._client_features = None
314
315 @classmethod
316 def from_environment(cls):
317 crt_version = None
318 if HAS_CRT:
319 crt_version = _get_crt_version() or 'Unknown'
320 return cls(
321 platform_name=platform.system(),
322 platform_version=platform.release(),
323 platform_machine=platform.machine(),
324 python_version=platform.python_version(),
325 python_implementation=platform.python_implementation(),
326 execution_env=os.environ.get('AWS_EXECUTION_ENV'),
327 crt_version=crt_version,
328 )
329
330 def set_session_config(
331 self,
332 session_user_agent_name,
333 session_user_agent_version,
334 session_user_agent_extra,
335 ):
336 """
337 Set the user agent configuration values that apply at session level.
338
339 :param user_agent_name: The user agent name configured in the
340 :py:class:`botocore.session.Session` object. For backwards
341 compatibility, this will always be at the beginning of the
342 User-Agent string, together with ``user_agent_version``.
343 :param user_agent_version: The user agent version configured in the
344 :py:class:`botocore.session.Session` object.
345 :param user_agent_extra: The user agent "extra" configured in the
346 :py:class:`botocore.session.Session` object.
347 """
348 self._session_user_agent_name = session_user_agent_name
349 self._session_user_agent_version = session_user_agent_version
350 self._session_user_agent_extra = session_user_agent_extra
351 return self
352
353 def set_client_features(self, features):
354 """
355 Persist client-specific features registered before or during client
356 creation.
357
358 :type features: Set[str]
359 :param features: A set of client-specific features.
360 """
361 self._client_features = features
362
363 def with_client_config(self, client_config):
364 """
365 Create a copy with all original values and client-specific values.
366
367 :type client_config: botocore.config.Config
368 :param client_config: The client configuration object.
369 """
370 cp = copy(self)
371 cp._client_config = client_config
372 return cp
373
374 def to_string(self):
375 """
376 Build User-Agent header string from the object's properties.
377 """
378 config_ua_override = None
379 if self._client_config:
380 if hasattr(self._client_config, '_supplied_user_agent'):
381 config_ua_override = self._client_config._supplied_user_agent
382 else:
383 config_ua_override = self._client_config.user_agent
384
385 if config_ua_override is not None:
386 return self._build_legacy_ua_string(config_ua_override)
387
388 components = [
389 *self._build_sdk_metadata(),
390 RawStringUserAgentComponent('ua/2.1'),
391 *self._build_os_metadata(),
392 *self._build_architecture_metadata(),
393 *self._build_language_metadata(),
394 *self._build_execution_env_metadata(),
395 *self._build_feature_metadata(),
396 *self._build_config_metadata(),
397 *self._build_app_id(),
398 *self._build_extra(),
399 ]
400
401 components = modify_components(components)
402
403 return ' '.join(
404 [comp.to_string() for comp in components if comp.to_string()]
405 )
406
407 def _build_sdk_metadata(self):
408 """
409 Build the SDK name and version component of the User-Agent header.
410
411 For backwards-compatibility both session-level and client-level config
412 of custom tool names are honored. If this removes the Botocore
413 information from the start of the string, Botocore's name and version
414 are included as a separate field with "md" prefix.
415 """
416 sdk_md = []
417 if (
418 self._session_user_agent_name
419 and self._session_user_agent_version
420 and (
421 self._session_user_agent_name != _USERAGENT_SDK_NAME
422 or self._session_user_agent_version != botocore_version
423 )
424 ):
425 sdk_md.extend(
426 [
427 UserAgentComponent(
428 self._session_user_agent_name,
429 self._session_user_agent_version,
430 ),
431 UserAgentComponent(
432 'md', _USERAGENT_SDK_NAME, botocore_version
433 ),
434 ]
435 )
436 else:
437 sdk_md.append(
438 UserAgentComponent(_USERAGENT_SDK_NAME, botocore_version)
439 )
440
441 if self._crt_version is not None:
442 sdk_md.append(
443 UserAgentComponent('md', 'awscrt', self._crt_version)
444 )
445
446 return sdk_md
447
448 def _build_os_metadata(self):
449 """
450 Build the OS/platform components of the User-Agent header string.
451
452 For recognized platform names that match or map to an entry in the list
453 of standardized OS names, a single component with prefix "os" is
454 returned. Otherwise, one component "os/other" is returned and a second
455 with prefix "md" and the raw platform name.
456
457 String representations of example return values:
458 * ``os/macos#10.13.6``
459 * ``os/linux``
460 * ``os/other``
461 * ``os/other md/foobar#1.2.3``
462 """
463 if self._platform_name is None:
464 return [UserAgentComponent('os', 'other')]
465
466 plt_name_lower = self._platform_name.lower()
467 if plt_name_lower in _USERAGENT_ALLOWED_OS_NAMES:
468 os_family = plt_name_lower
469 elif plt_name_lower in _USERAGENT_PLATFORM_NAME_MAPPINGS:
470 os_family = _USERAGENT_PLATFORM_NAME_MAPPINGS[plt_name_lower]
471 else:
472 os_family = None
473
474 if os_family is not None:
475 return [
476 UserAgentComponent('os', os_family, self._platform_version)
477 ]
478 else:
479 return [
480 UserAgentComponent('os', 'other'),
481 UserAgentComponent(
482 'md', self._platform_name, self._platform_version
483 ),
484 ]
485
486 def _build_architecture_metadata(self):
487 """
488 Build architecture component of the User-Agent header string.
489
490 Returns the machine type with prefix "md" and name "arch", if one is
491 available. Common values include "x86_64", "arm64", "i386".
492 """
493 if self._platform_machine:
494 return [
495 UserAgentComponent(
496 'md', 'arch', self._platform_machine.lower()
497 )
498 ]
499 return []
500
501 def _build_language_metadata(self):
502 """
503 Build the language components of the User-Agent header string.
504
505 Returns the Python version in a component with prefix "lang" and name
506 "python". The Python implementation (e.g. CPython, PyPy) is returned as
507 separate metadata component with prefix "md" and name "pyimpl".
508
509 String representation of an example return value:
510 ``lang/python#3.10.4 md/pyimpl#CPython``
511 """
512 lang_md = [
513 UserAgentComponent('lang', 'python', self._python_version),
514 ]
515 if self._python_implementation:
516 lang_md.append(
517 UserAgentComponent('md', 'pyimpl', self._python_implementation)
518 )
519 return lang_md
520
521 def _build_execution_env_metadata(self):
522 """
523 Build the execution environment component of the User-Agent header.
524
525 Returns a single component prefixed with "exec-env", usually sourced
526 from the environment variable AWS_EXECUTION_ENV.
527 """
528 if self._execution_env:
529 return [UserAgentComponent('exec-env', self._execution_env)]
530 else:
531 return []
532
533 def _build_feature_metadata(self):
534 """
535 Build the features component of the User-Agent header string.
536
537 Returns a single component with prefix "m" followed by a list of
538 comma-separated metric values.
539 """
540 ctx = get_context()
541 context_features = set() if ctx is None else ctx.features
542 client_features = self._client_features or set()
543 features = client_features.union(context_features)
544 if not features:
545 return []
546 size_config = UserAgentComponentSizeConfig(1024, ',')
547 return [
548 UserAgentComponent(
549 'm', ','.join(features), size_config=size_config
550 )
551 ]
552
553 def _build_config_metadata(self):
554 """
555 Build the configuration components of the User-Agent header string.
556
557 Returns a list of components with prefix "cfg" followed by the config
558 setting name and its value. Tracked configuration settings may be
559 added or removed in future versions.
560 """
561 if not self._client_config or not self._client_config.retries:
562 return []
563 retry_mode = self._client_config.retries.get('mode')
564 cfg_md = [UserAgentComponent('cfg', 'retry-mode', retry_mode)]
565 if self._client_config.endpoint_discovery_enabled:
566 cfg_md.append(UserAgentComponent('cfg', 'endpoint-discovery'))
567 return cfg_md
568
569 def _build_app_id(self):
570 """
571 Build app component of the User-Agent header string.
572
573 Returns a single component with prefix "app" and value sourced from the
574 ``user_agent_appid`` field in :py:class:`botocore.config.Config` or
575 the ``sdk_ua_app_id`` setting in the shared configuration file, or the
576 ``AWS_SDK_UA_APP_ID`` environment variable. These are the recommended
577 ways for apps built with Botocore to insert their identifer into the
578 User-Agent header.
579 """
580 if self._client_config and self._client_config.user_agent_appid:
581 return [
582 UserAgentComponent('app', self._client_config.user_agent_appid)
583 ]
584 else:
585 return []
586
587 def _build_extra(self):
588 """User agent string components based on legacy "extra" settings.
589
590 Creates components from the session-level and client-level
591 ``user_agent_extra`` setting, if present. Both are passed through
592 verbatim and should be appended at the end of the string.
593
594 Preferred ways to inject application-specific information into
595 botocore's User-Agent header string are the ``user_agent_appid` field
596 in :py:class:`botocore.config.Config`. The ``AWS_SDK_UA_APP_ID``
597 environment variable and the ``sdk_ua_app_id`` configuration file
598 setting are alternative ways to set the ``user_agent_appid`` config.
599 """
600 extra = []
601 if self._session_user_agent_extra:
602 extra.append(
603 RawStringUserAgentComponent(self._session_user_agent_extra)
604 )
605 if self._client_config and self._client_config.user_agent_extra:
606 extra.append(
607 RawStringUserAgentComponent(
608 self._client_config.user_agent_extra
609 )
610 )
611 return extra
612
613 def _build_legacy_ua_string(self, config_ua_override):
614 components = [config_ua_override]
615 if self._session_user_agent_extra:
616 components.append(self._session_user_agent_extra)
617 if self._client_config.user_agent_extra:
618 components.append(self._client_config.user_agent_extra)
619 return ' '.join(components)
620
621 def rebuild_and_replace_user_agent_handler(
622 self, operation_name, request, **kwargs
623 ):
624 ua_string = self.to_string()
625 if request.headers.get('User-Agent'):
626 request.headers.replace_header('User-Agent', ua_string)
627
628
629def _get_crt_version():
630 """
631 This function is considered private and is subject to abrupt breaking
632 changes.
633 """
634 try:
635 import awscrt
636
637 return awscrt.__version__
638 except AttributeError:
639 return None