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