diff --git a/docs/configuration.md b/docs/configuration.md index 0485b4c..1adbe82 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,6 +62,18 @@ hacksaws config set history.max_bytes 52428800 The limits use seconds, entries, and bytes. See [Local command history](history.md) for the redaction and retention contract. +## AWS environment values + +Hacksaws treats an unset, empty, or whitespace-only value for any `AWS_*` +environment variable as absent. Literal nonblank values are preserved exactly; +for example, `null` and `none` remain profile names or paths rather than special +sentinels. This normalization does not rewrite the parent shell environment. + +Static region and service metadata is loaded independently of AWS credentials, +while a nonblank `AWS_DATA_PATH` continues to extend Botocore's metadata search +path. Region selection keeps its documented precedence: `AWS_REGION` precedes +`AWS_DEFAULT_REGION`, and blank values at either layer are skipped. + `config export` creates a portable zip excluding temporary caches. `config import` validates the complete archive before replacing state. diff --git a/docs/profiles-and-sessions.md b/docs/profiles-and-sessions.md index 916827d..a141533 100644 --- a/docs/profiles-and-sessions.md +++ b/docs/profiles-and-sessions.md @@ -59,6 +59,13 @@ hacksaws logout --all hacksaws logout --all --except "horizon:prod*" --except "+hacw" ``` +If logout removes a temporary profile still selected by `AWS_PROFILE` or +`AWS_DEFAULT_PROFILE`, Hacksaws warns that the parent shell variable is stale +and shows a PowerShell command that can clear it. A child process cannot modify +its parent shell, but local Hacksaws commands continue to work with that stale +selector. No warning is emitted when the variable is blank, selects another +profile, or logout restores an underlying profile with the same name. + `--except` requires `--all`. Patterns match canonical `location:profile` names and target aliases. Tracked ECR logins are removed unless `--keep-ecr` is used. diff --git a/hacksaws/_aws_env.py b/hacksaws/_aws_env.py new file mode 100644 index 0000000..f8b62b5 --- /dev/null +++ b/hacksaws/_aws_env.py @@ -0,0 +1,42 @@ +"""Pure normalization helpers for AWS environment values.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping + + +def normalize_aws_value(name: str, value: str | None) -> str | None: + """Treat blank AWS values as absent while preserving every other value.""" + if name.startswith("AWS_") and (value is None or not value.strip()): + return None + return value + + +def aws_environment_value( + name: str, environ: Mapping[str, str] | None = None +) -> str | None: + """Read one environment value through the AWS blank-value contract.""" + values = os.environ if environ is None else environ + return normalize_aws_value(name, values.get(name)) + + +def normalized_aws_environment(environ: Mapping[str, str]) -> dict[str, str]: + """Copy an environment, omitting only blank AWS-prefixed entries.""" + return { + name: value + for name, value in environ.items() + if normalize_aws_value(name, value) is not None + } + + +def blank_aws_environment_keys(environ: Mapping[str, str]) -> tuple[str, ...]: + """Return blank AWS-prefixed keys without changing the supplied mapping.""" + return tuple( + name + for name, value in environ.items() + if name.startswith("AWS_") and normalize_aws_value(name, value) is None + ) diff --git a/hacksaws/_cli.py b/hacksaws/_cli.py index 2763818..caccc91 100644 --- a/hacksaws/_cli.py +++ b/hacksaws/_cli.py @@ -1743,7 +1743,7 @@ def _run_mfa(context: _configs.Context) -> _configs.Result: context.args.mfa_code_source = "argument" if not context.args.mfa_code: raise _configs.OperationalError("MFA token code cannot be empty.") - _history.note_mfa_code(source=context.args.mfa_code_source) + _history.call_safely(_history.note_mfa_code, source=context.args.mfa_code_source) return _sessions.mfa_login(context) @@ -1786,14 +1786,18 @@ def _run_logout(context: _configs.Context) -> _configs.Result: legacy_context = _configs.Context(args=legacy_args) try: _aws.logout(legacy_context) - report["outcomes"].append( - { - "profile": item["profile"], - "destination": item["directory"], - "state": "logged-out", - "changed": True, - } + outcome: dict[str, Any] = { + "profile": item["profile"], + "destination": item["directory"], + "state": "logged-out", + "changed": True, + } + warnings = _sessions.stale_profile_environment_warnings( + legacy_context.aws_directory, legacy_context.profile ) + if warnings: + outcome["warnings"] = list(warnings) + report["outcomes"].append(outcome) except _configs.OperationalError as error: report["errors"].append( { @@ -1820,24 +1824,48 @@ def _run_logout(context: _configs.Context) -> _configs.Result: context.args.profile = "default" _sessions.recover_journal() if _sessions.logout(context): + warnings = _sessions.logout_environment_warnings(context) + data: dict[str, Any] = {"profile": context.profile, "changed": True} + if warnings: + data["warnings"] = list(warnings) return _configs.Result( "LOGOUT", - f"Logged out of profile {context.profile}", - data={"profile": context.profile, "changed": True}, + "\n".join( + ( + f"Logged out of profile {context.profile}", + *(f"Warning: {warning}" for warning in warnings), + ) + ), + data=data, ) os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(context.credentials_path) os.environ["AWS_CONFIG_FILE"] = str(context.config_path) legacy_active = context.storage_path.exists() _aws.logout(context) changed = legacy_active + warnings = ( + _sessions.stale_profile_environment_warnings( + context.aws_directory, context.profile + ) + if changed + else () + ) + data = {"profile": context.profile, "changed": changed} + if warnings: + data["warnings"] = list(warnings) return _configs.Result( "MFA_LOGOUT" if changed else "LOGOUT_NO_STATE", ( - f"Logged out of profile {context.profile}" + "\n".join( + ( + f"Logged out of profile {context.profile}", + *(f"Warning: {warning}" for warning in warnings), + ) + ) if changed else f"No Hacksaws-managed login state found for profile {context.profile}." ), - data={"profile": context.profile, "changed": changed}, + data=data, kind="info" if not changed else "success", ) @@ -2252,7 +2280,11 @@ def _logout_report_text(report: dict[str, Any]) -> str: rows.extend( [["-", item["key"], f"error: {item['message']}"] for item in report["errors"]] ) - return _text_table(["PROFILE", "DESTINATION", "RESULT"], rows) + table = _text_table(["PROFILE", "DESTINATION", "RESULT"], rows) + warnings = [ + warning for item in report["outcomes"] for warning in item.get("warnings", []) + ] + return "\n".join((table, *(f"Warning: {warning}" for warning in warnings))) def _cache_list_text(entries: list[dict[str, Any]]) -> str: @@ -3746,9 +3778,9 @@ def _run_history(args: argparse.Namespace) -> _configs.Result: kind="info", ) if args.yes: - _history.note_confirmation("yes-flag", "bypassed") + _history.call_safely(_history.note_confirmation, "yes-flag", "bypassed") elif bool(getattr(args, "json", False)) or not sys.stdin.isatty(): - _history.note_confirmation("exact-yes", "unavailable") + _history.call_safely(_history.note_confirmation, "exact-yes", "unavailable") return _configs.Result( "CONFIRMATION_REQUIRED", "History clear requires an interactive exact 'yes' or --yes.", @@ -3764,8 +3796,10 @@ def _run_history(args: argparse.Namespace) -> _configs.Result: "Type 'yes' exactly to continue: " ) accepted = answer.strip() == "yes" - _history.note_confirmation( - "exact-yes", "accepted" if accepted else "declined" + _history.call_safely( + _history.note_confirmation, + "exact-yes", + "accepted" if accepted else "declined", ) if not accepted: return _configs.Result( @@ -3803,8 +3837,12 @@ def _console_main_invocation( ) except _configs.OperationalError as error: if history_handle is not None: - _history.note_parse_failure( - history_handle, parse_observation, phase="global", kind="invalid-value" + _history.call_safely( + _history.note_parse_failure, + history_handle, + parse_observation, + phase="global", + kind="invalid-value", ) return _configs.Result( "ARGUMENT_ERROR", f"Error: {error}", _configs.EXIT_USAGE, "stderr" @@ -3813,7 +3851,8 @@ def _console_main_invocation( normalized_arguments = _normalize_login_save_options(normalized_arguments) except _configs.OperationalError as error: if history_handle is not None: - _history.note_parse_failure( + _history.call_safely( + _history.note_parse_failure, history_handle, parse_observation, phase="semantic", @@ -3830,7 +3869,8 @@ def _console_main_invocation( _iam_cli.validate_selector_arguments(normalized_arguments) except _configs.OperationalError as error: if history_handle is not None: - _history.note_parse_failure( + _history.call_safely( + _history.note_parse_failure, history_handle, parse_observation, phase="selector", @@ -3856,8 +3896,11 @@ def _console_main_invocation( namespace = parser.parse_args(normalized_arguments) except SystemExit as error: if error.code != 0 and history_handle is not None: - _history.note_parse_failure( - history_handle, parse_observation, phase="argparse" + _history.call_safely( + _history.note_parse_failure, + history_handle, + parse_observation, + phase="argparse", ) result = _configs.Result( "HELP" if error.code == 0 else "ARGUMENT_ERROR", @@ -3899,7 +3942,7 @@ def _console_main_invocation( namespace.mfa_code = namespace.profile namespace.profile = None if history_handle is not None: - _history.enrich(history_handle, namespace) + _history.call_safely(_history.enrich, history_handle, namespace) if namespace.access_type == "mfa" and namespace.action in {"login", "in"}: missing_code = namespace.mfa_code is None and not bool( getattr(namespace, "mfa_code_stdin", False) @@ -3910,7 +3953,8 @@ def _console_main_invocation( if not use_json: parser.print_usage(sys.stderr) if history_handle is not None: - _history.note_parse_failure( + _history.call_safely( + _history.note_parse_failure, history_handle, parse_observation, phase="semantic", @@ -3993,7 +4037,8 @@ def _console_main_invocation( and int(getattr(error, "exit_code", _configs.EXIT_ERROR)) == _configs.EXIT_USAGE ): - _history.note_parse_failure( + _history.call_safely( + _history.note_parse_failure, history_handle, parse_observation, phase="semantic", @@ -4018,17 +4063,28 @@ def _console_main_invocation( def console_main(arguments: Sequence[str] | None = None) -> _configs.Result: """Run one isolated CLI invocation without leaking output mode to callers.""" raw_arguments = list(sys.argv[1:] if arguments is None else arguments) - history_handle = _history.begin( - json_mode=_json_requested(raw_arguments), interactive=sys.stdin.isatty() - ) + json_mode = _json_requested(raw_arguments) + try: + history_handle = _history.begin( + json_mode=json_mode, interactive=sys.stdin.isatty() + ) + except Exception: # noqa: BLE001 - history is an optional diagnostic + _history.call_safely(_history.warn_unavailable, json_mode=json_mode) + history_handle = None _configs.configure_output() try: result = _console_main_invocation(raw_arguments, history_handle=history_handle) except BaseException as error: - _history.fail(history_handle, error) + if history_handle is not None and not _history.call_safely( + _history.fail, history_handle, error + ): + _history.call_safely(_history.warn_unavailable, json_mode=json_mode) raise else: - _history.finish(history_handle, result) + if history_handle is not None and not _history.call_safely( + _history.finish, history_handle, result + ): + _history.call_safely(_history.warn_unavailable, json_mode=json_mode) return result finally: _configs.configure_output() diff --git a/hacksaws/_history.py b/hacksaws/_history.py index e427b2b..748ccc9 100644 --- a/hacksaws/_history.py +++ b/hacksaws/_history.py @@ -8,6 +8,7 @@ import json import re import sqlite3 +import sys import threading import time import uuid @@ -28,6 +29,7 @@ from hacksaws._duration import parse_duration if TYPE_CHECKING: + from collections.abc import Callable from collections.abc import Iterator SCHEMA_VERSION = 2 @@ -828,13 +830,16 @@ def _safe_namespace(args: argparse.Namespace) -> dict[str, object]: def begin(*, json_mode: bool, interactive: bool) -> HistoryHandle: """Start one best-effort invocation without retaining argv.""" - _audit.reset_confirmation() - settings = _settings() - if settings["enabled"] is not True or _suspended.get(): - return HistoryHandle(id=None, started_monotonic=time.monotonic(), enabled=False) - identifier = uuid.uuid4().hex - started = _now() + started_monotonic = time.monotonic() try: + _audit.reset_confirmation() + settings = _settings() + if settings["enabled"] is not True or _suspended.get(): + return HistoryHandle( + id=None, started_monotonic=started_monotonic, enabled=False + ) + identifier = uuid.uuid4().hex + started = _now() with _database() as connection: connection.execute( """ @@ -846,10 +851,38 @@ def begin(*, json_mode: bool, interactive: bool) -> HistoryHandle: ) _current.set(identifier) return HistoryHandle( - id=identifier, started_monotonic=time.monotonic(), enabled=True + id=identifier, started_monotonic=started_monotonic, enabled=True ) - except (OSError, sqlite3.Error, HistoryError): - return HistoryHandle(id=None, started_monotonic=time.monotonic(), enabled=False) + except Exception: # noqa: BLE001 - history must never prevent the command + call_safely(warn_unavailable, json_mode=json_mode) + _current.set(None) + return HistoryHandle( + id=None, started_monotonic=started_monotonic, enabled=False + ) + + +def warn_unavailable(*, json_mode: bool) -> None: + """Warn when history is unavailable without corrupting JSON output.""" + if json_mode: + return + try: + sys.stderr.write( + "Warning: local command history is unavailable; continuing without " + "recording.\n" + ) + except Exception: # noqa: BLE001 - even warning output is best-effort + return + + +def call_safely( + operation: Callable[..., object], /, *args: object, **kwargs: object +) -> bool: + """Run one diagnostic history side effect without affecting its caller.""" + try: + operation(*args, **kwargs) + except Exception: # noqa: BLE001 - history is optional telemetry + return False + return True def enrich(handle: HistoryHandle, args: argparse.Namespace) -> None: diff --git a/hacksaws/_iam_cli.py b/hacksaws/_iam_cli.py index b10f9ef..5560939 100644 --- a/hacksaws/_iam_cli.py +++ b/hacksaws/_iam_cli.py @@ -18,6 +18,7 @@ from botocore.exceptions import BotoCoreError from botocore.exceptions import ClientError +from hacksaws import _aws_env from hacksaws import _configs from hacksaws import _iam_cleanup from hacksaws import _iam_policy_cli @@ -977,7 +978,14 @@ def _dispatch_cleanup(args: argparse.Namespace) -> _configs.Result: @contextlib.contextmanager def credential_environment(config: Path, credentials: Path) -> Iterator[None]: """Temporarily bind Boto3 to exactly one selected shared-config source.""" - keys = _CREDENTIAL_ENVIRONMENT_KEYS + keys = ( + *_CREDENTIAL_ENVIRONMENT_KEYS, + *( + key + for key in _aws_env.blank_aws_environment_keys(os.environ) + if key not in _CREDENTIAL_ENVIRONMENT_KEYS + ), + ) previous = {key: os.environ.get(key) for key in keys} os.environ["AWS_CONFIG_FILE"] = str(config) os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(credentials) @@ -1022,8 +1030,8 @@ def create( selector = _configs.resolve_credential_selector(args) directory, profile, expected_account = _selected_source(selector, args) region_settings = _iam_region_settings(selector, expected_account) - env_region = os.getenv("AWS_REGION") or "" - env_default_region = os.getenv("AWS_DEFAULT_REGION") or "" + env_region = _aws_env.aws_environment_value("AWS_REGION") + env_default_region = _aws_env.aws_environment_value("AWS_DEFAULT_REGION") with credential_environment(directory / "config", directory / "credentials"): try: higher_precedence_region = any( diff --git a/hacksaws/_regions.py b/hacksaws/_regions.py index 63792a8..60d584b 100644 --- a/hacksaws/_regions.py +++ b/hacksaws/_regions.py @@ -3,7 +3,6 @@ from __future__ import annotations import difflib -import os import re import sys from collections.abc import Mapping @@ -11,15 +10,19 @@ from functools import cache from typing import TYPE_CHECKING from typing import Literal +from typing import cast -import botocore.session +from botocore import loaders from botocore.exceptions import UnknownRegionError +from botocore.regions import EndpointResolver +from hacksaws import _aws_env from hacksaws._configs import OperationalError if TYPE_CHECKING: from collections.abc import Callable from collections.abc import Iterable + from typing import Any OPERATIONAL_PARTITIONS = frozenset({"aws", "aws-cn", "aws-us-gov"}) REGION_RE = re.compile(r"^[a-z]{2}(?:-[a-z0-9]+)+-\d+$") @@ -175,9 +178,44 @@ def _compact_candidate(region: str) -> str: @cache -def region_registry(*, all_partitions: bool = False) -> tuple[RegionInfo, ...]: +def _partition_data(data_path: str | None) -> dict[str, object]: + """Load static endpoint metadata without resolving an AWS profile.""" + return cast( + "dict[str, object]", + loaders.create_loader(data_path).load_data("partitions"), + ) + + +@cache +def _endpoint_data(data_path: str | None) -> dict[str, object]: + """Load static service endpoints without resolving an AWS profile.""" + return cast( + "dict[str, object]", + loaders.create_loader(data_path).load_data("endpoints"), + ) + + +def _resolver() -> EndpointResolver: + return EndpointResolver( + _endpoint_data(_aws_env.aws_environment_value("AWS_DATA_PATH")), + uses_builtin_data=True, + ) + + +@cache +def _service_endpoint_prefix(service: str, data_path: str | None) -> str: + """Resolve the endpoints metadata name from one static service model.""" + model = loaders.create_loader(data_path).load_service_model(service, "service-2") + metadata = model.get("metadata", {}) + return str(metadata.get("endpointPrefix") or service) + + +@cache +def _region_registry( + *, all_partitions: bool, data_path: str | None +) -> tuple[RegionInfo, ...]: """Return deterministic Botocore regions with collision-free compact aliases.""" - partitions = botocore.session.get_session().get_data("partitions")["partitions"] + partitions = cast("list[dict[str, Any]]", _partition_data(data_path)["partitions"]) raw: list[tuple[str, str, str]] = [] for partition in partitions: partition_name = str(partition["id"]) @@ -218,6 +256,14 @@ def region_registry(*, all_partitions: bool = False) -> tuple[RegionInfo, ...]: return tuple(result) +def region_registry(*, all_partitions: bool = False) -> tuple[RegionInfo, ...]: + """Return static regions, honoring a meaningful custom AWS data path.""" + return _region_registry( + all_partitions=all_partitions, + data_path=_aws_env.aws_environment_value("AWS_DATA_PATH"), + ) + + def _registry_maps( *, all_partitions: bool, @@ -323,7 +369,7 @@ def validate_custom_aliases(aliases: Mapping[str, object]) -> None: def _infer_partition(value: str) -> str: """Infer an unknown canonical region only through Botocore partition patterns.""" try: - return str(botocore.session.get_session().get_partition_for_region(value)) + return str(_resolver().get_partition_for_region(value)) except UnknownRegionError as error: raise RegionError( "REGION_PARTITION_UNKNOWN", @@ -559,13 +605,15 @@ def resolve_region_preference( # noqa: PLR0913 ("explicit", explicit), ( "aws-region-env", - env_region if env_region is not None else os.getenv("AWS_REGION"), + env_region + if env_region is not None + else _aws_env.aws_environment_value("AWS_REGION"), ), ( "aws-default-region-env", env_default_region if env_default_region is not None - else os.getenv("AWS_DEFAULT_REGION"), + else _aws_env.aws_environment_value("AWS_DEFAULT_REGION"), ), ("target", target), ("destination-profile", destination), @@ -618,11 +666,11 @@ def validate_service_region( if service == "signin": supported = resolution.partition in OPERATIONAL_PARTITIONS else: - supported = ( - resolution.canonical - in botocore.session.get_session().get_available_regions( - service, partition_name=resolution.partition - ) + endpoint_prefix = _service_endpoint_prefix( + service, _aws_env.aws_environment_value("AWS_DATA_PATH") + ) + supported = resolution.canonical in _resolver().get_available_endpoints( + endpoint_prefix, partition_name=resolution.partition ) if not supported: raise RegionError( diff --git a/hacksaws/_sessions.py b/hacksaws/_sessions.py index 24536b5..1630449 100644 --- a/hacksaws/_sessions.py +++ b/hacksaws/_sessions.py @@ -32,6 +32,7 @@ from botocore.exceptions import BotoCoreError from botocore.exceptions import ClientError +from hacksaws import _aws_env from hacksaws import _configs from hacksaws import _duration from hacksaws import _ecr @@ -787,7 +788,8 @@ def _resolve_session_region( interactive=not bool(getattr(args, "json", False)) and sys.stdin.isatty(), ) args._region_preference = preference - _history.note_region( + _history.call_safely( + _history.note_region, region=preference.canonical, partition=preference.resolution.partition, source=preference.source, @@ -1046,12 +1048,17 @@ def _finish_session_save( profile, ) except _configs.OperationalError as error: - _history.note_account_registration( - status="failed", created=0, reused=0, refreshed=0 + _history.call_safely( + _history.note_account_registration, + status="failed", + created=0, + reused=0, + refreshed=0, ) retry = _session_save.retry_command(plan) if plan.requested else None if plan.requested: - _history.note_session_save( + _history.call_safely( + _history.note_session_save, status=( "cancelled" if isinstance(error, _session_save.SaveCancelled) @@ -1095,7 +1102,8 @@ def _finish_session_save( existing = dict(result.data) if isinstance(result.data, dict) else {} summary = _session_save.outcome_data(outcome) registration = cast("dict[str, int]", summary["accountRegistration"]) - _history.note_account_registration( + _history.call_safely( + _history.note_account_registration, status="completed", created=registration["created"], reused=registration["reused"], @@ -1103,7 +1111,8 @@ def _finish_session_save( ) bundle_changed = bool(summary["bundleChanged"]) if plan.requested: - _history.note_session_save( + _history.call_safely( + _history.note_session_save, status="saved" if bundle_changed else "noop", target=outcome.target, boundary=outcome.boundary, @@ -1559,7 +1568,7 @@ def _require_browser_runtime() -> None: def _native_login_cache() -> Path: """Resolve the cache directory external AWS tools will use after native login.""" - configured = os.environ.get("AWS_LOGIN_CACHE_DIRECTORY") + configured = _aws_env.aws_environment_value("AWS_LOGIN_CACHE_DIRECTORY") if configured: return Path(configured).expanduser().absolute() return Path.home() / ".aws" / "login" / "cache" @@ -1571,7 +1580,9 @@ def _clean_env( login_cache: Path | None = None, ) -> dict[str, str]: env = { - key: value for key, value in os.environ.items() if key not in _CONFLICTING_ENV + key: value + for key, value in _aws_env.normalized_aws_environment(os.environ).items() + if key not in _CONFLICTING_ENV } if config: env["AWS_CONFIG_FILE"] = str(config) @@ -1587,8 +1598,9 @@ def _aws_environment( config: Path, credentials: Path, login_cache: Path ) -> Iterator[None]: """Temporarily scrub inherited AWS identity/path variables for one staging area.""" - previous = {key: os.environ.get(key) for key in _CONFLICTING_ENV} - for key in _CONFLICTING_ENV: + keys = _CONFLICTING_ENV | set(_aws_env.blank_aws_environment_keys(os.environ)) + previous = {key: os.environ.get(key) for key in keys} + for key in keys: os.environ.pop(key, None) os.environ["AWS_CONFIG_FILE"] = str(config) os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(credentials) @@ -1596,7 +1608,7 @@ def _aws_environment( try: yield finally: - for key in _CONFLICTING_ENV: + for key in keys: value = previous[key] if value is None: os.environ.pop(key, None) @@ -1941,6 +1953,46 @@ def _profile_exists(directory: Path, profile: str) -> bool: return profile in credentials or _section(profile, config=True) in config +def _ambient_profile_exists(profile: str) -> bool: + """Return whether ambient AWS file selection still resolves one profile.""" + config = Path( + _aws_env.aws_environment_value("AWS_CONFIG_FILE") or Path.home() / ".aws/config" + ).expanduser() + credentials = Path( + _aws_env.aws_environment_value("AWS_SHARED_CREDENTIALS_FILE") + or Path.home() / ".aws/credentials" + ).expanduser() + return profile in _read_ini(credentials) or _section( + profile, config=True + ) in _read_ini(config) + + +def stale_profile_environment_warnings( + directory: Path, profile: str +) -> tuple[str, ...]: + """Explain ambient profile selectors left stale by a successful logout.""" + if _profile_exists(directory, profile) or _ambient_profile_exists(profile): + return () + selected = next( + ( + (variable, value) + for variable in ("AWS_DEFAULT_PROFILE", "AWS_PROFILE") + if (value := _aws_env.aws_environment_value(variable)) is not None + ), + None, + ) + if selected is None or selected[1] != profile: + return () + variable, _value = selected + return ( + ( + f"{variable} still selects removed profile {profile!r} in the parent " + f"shell. Hacksaws cannot clear its parent shell; in PowerShell run " + f"`$env:{variable} = $null`." + ), + ) + + def _legacy_source_backup( directory: Path, profile: str ) -> tuple[Path, dict[str, str]] | None: @@ -3880,10 +3932,15 @@ def save_target_from_session(args: Any) -> _configs.Result: rollback=_rollback, ) except _configs.OperationalError: - _history.note_account_registration( - status="failed", created=0, reused=0, refreshed=0 + _history.call_safely( + _history.note_account_registration, + status="failed", + created=0, + reused=0, + refreshed=0, ) - _history.note_session_save( + _history.call_safely( + _history.note_session_save, status="failed", target=plan.name, boundary=plan.boundary_name, @@ -3891,7 +3948,8 @@ def save_target_from_session(args: Any) -> _configs.Result: credentials_active=True, ) raise - _history.note_account_registration( + _history.call_safely( + _history.note_account_registration, status="completed", created=outcome.accounts_created, reused=outcome.accounts_reused, @@ -3900,7 +3958,8 @@ def save_target_from_session(args: Any) -> _configs.Result: bundle_changed = ( outcome.changed if outcome.bundle_changed is None else outcome.bundle_changed ) - _history.note_session_save( + _history.call_safely( + _history.note_session_save, status="saved" if bundle_changed else "noop", target=outcome.target, boundary=outcome.boundary, @@ -4810,7 +4869,7 @@ def _logout_key(key: str, args: Any) -> dict[str, Any]: else: sessions.pop(key, None) _state.save_sessions(sessions) - return { + outcome: dict[str, Any] = { "key": key, "destination": str(destination), "profile": profile, @@ -4824,6 +4883,10 @@ def _logout_key(key: str, args: Any) -> dict[str, Any]: "residue": cache_residue, "changed": True, } + warnings = stale_profile_environment_warnings(destination, profile) + if warnings: + outcome["warnings"] = list(warnings) + return outcome def logout(context: _configs.Context) -> bool: @@ -4833,6 +4896,12 @@ def logout(context: _configs.Context) -> bool: return bool(_logout_key(key, context.args)["changed"]) +def logout_environment_warnings(context: _configs.Context) -> tuple[str, ...]: + """Return stale ambient-profile warnings for the logout destination.""" + _source, _source_profile, destination, profile = _paths(context.args) + return stale_profile_environment_warnings(destination, profile) + + def logout_all(args: Any) -> dict[str, Any]: """Log out every managed session except explicit profile selectors.""" outcomes = [] @@ -4895,7 +4964,8 @@ def explain_target(value: str) -> dict[str, Any]: def check_config(args: Any) -> dict[str, Any]: """Run config checks without leaking credential environment mutations.""" - previous = {key: os.environ.get(key) for key in _CONFLICTING_ENV} + keys = _CONFLICTING_ENV | set(_aws_env.blank_aws_environment_keys(os.environ)) + previous = {key: os.environ.get(key) for key in keys} try: return _check_config(args) finally: diff --git a/hacksaws/tests/test_aws_environment_resilience.py b/hacksaws/tests/test_aws_environment_resilience.py new file mode 100644 index 0000000..fd2c89d --- /dev/null +++ b/hacksaws/tests/test_aws_environment_resilience.py @@ -0,0 +1,529 @@ +"""Regression coverage for stale and blank ambient AWS environment state.""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest +from botocore import loaders + +from hacksaws import _aws_env +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _history +from hacksaws import _regions +from hacksaws import _sessions +from hacksaws import _state + + +def _clear_region_caches() -> None: + _regions._partition_data.cache_clear() + _regions._endpoint_data.cache_clear() + _regions._region_registry.cache_clear() + _regions._service_endpoint_prefix.cache_clear() + + +def _logout_args(directory: Path, profile: str = "dev") -> argparse.Namespace: + return argparse.Namespace( + target=None, + directory=str(directory), + profile=profile, + aws_account_name=None, + to=None, + to_directory=None, + ecr=False, + podman=False, + keep_ecr=False, + force=False, + except_profiles=[], + ) + + +def _temporary_profile(state_home: Path, directory: Path, profile: str = "dev") -> None: + directory.mkdir(parents=True, exist_ok=True) + credentials = directory / "credentials" + config = directory / "config" + credentials.write_text( + f"[{profile}]\naws_access_key_id = temporary\n" + "aws_secret_access_key = temporary\n", + encoding="utf-8", + ) + config.write_text(f"[profile {profile}]\nregion = us-east-1\n", encoding="utf-8") + key = f"{directory.absolute()}::{profile}" + _state.save_sessions( + { + key: { + "destination": str(directory.absolute()), + "profile": profile, + "auth_method": "browser", + "backup": [], + "section_backup": { + "credentials": { + "path": str(credentials.absolute()), + "section": profile, + "original": {"exists": False, "values": {}}, + "installed": _sessions._section_state(credentials, profile), + }, + "config": { + "path": str(config.absolute()), + "section": f"profile {profile}", + "original": {"exists": False, "values": {}}, + "installed": _sessions._section_state( + config, f"profile {profile}" + ), + }, + }, + "ecr": [], + } + } + ) + assert _state.root() == state_home + + +def test_aws_value_and_mapping_normalization_is_pure_and_lossless() -> None: + source = { + "AWS_PROFILE": " ", + "AWS_DEFAULT_PROFILE": "null", + "AWS_REGION": "none", + "AWS_DATA_PATH": " custom path ", + "NOT_AWS": "", + } + + assert _aws_env.normalize_aws_value("AWS_PROFILE", None) is None + assert _aws_env.normalize_aws_value("AWS_PROFILE", "") is None + assert _aws_env.normalize_aws_value("AWS_PROFILE", " \t") is None + assert _aws_env.normalize_aws_value("AWS_PROFILE", "null") == "null" + assert _aws_env.normalize_aws_value("AWS_PROFILE", "none") == "none" + assert _aws_env.normalize_aws_value("NOT_AWS", "") == "" + assert _aws_env.normalized_aws_environment(source) == { + "AWS_DEFAULT_PROFILE": "null", + "AWS_REGION": "none", + "AWS_DATA_PATH": " custom path ", + "NOT_AWS": "", + } + assert source["AWS_PROFILE"] == " " + + +def test_clean_and_scoped_aws_environments_normalize_blanks( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("AWS_UNKNOWN_BLANK", " ") + monkeypatch.setenv("AWS_DATA_PATH", "custom-data") + monkeypatch.setenv("AWS_PROFILE", "none") + clean = _sessions._clean_env() + assert "AWS_UNKNOWN_BLANK" not in clean + assert clean["AWS_DATA_PATH"] == "custom-data" + assert "AWS_PROFILE" not in clean + + before = dict(os.environ) + with _sessions._aws_environment( + tmp_path / "config", tmp_path / "credentials", tmp_path / "cache" + ): + assert "AWS_UNKNOWN_BLANK" not in os.environ + assert os.environ["AWS_DATA_PATH"] == "custom-data" + assert dict(os.environ) == before + + +def test_blank_login_cache_and_region_environment_follow_existing_precedence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", " \t") + assert _sessions._native_login_cache() == Path.home() / ".aws" / "login" / "cache" + monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", " custom-cache ") + assert _sessions._native_login_cache() == Path(" custom-cache ").absolute() + + monkeypatch.setenv("AWS_REGION", " ") + monkeypatch.setenv("AWS_DEFAULT_REGION", "us-west-2") + preference = _regions.resolve_region_preference(source="us-east-1") + assert preference.canonical == "us-west-2" + assert preference.source == "aws-default-region-env" + + +def test_nonblank_aws_data_path_and_custom_alias_work_with_dangling_profile( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + data = copy.deepcopy(loaders.create_loader().load_data("partitions")) + partition = next(item for item in data["partitions"] if item["id"] == "aws") + partition["regions"]["us-test-1"] = {"description": "Test Region"} + data_path = tmp_path / "data" + data_path.mkdir() + (data_path / "partitions.json").write_text(json.dumps(data), encoding="utf-8") + monkeypatch.setenv("AWS_DATA_PATH", str(data_path)) + monkeypatch.setenv("AWS_PROFILE", "removed-profile") + _clear_region_caches() + + resolution = _regions.resolve_region( + "test-region", custom_aliases={"test-region": {"region": "us-test-1"}} + ) + assert resolution.canonical == "us-test-1" + assert resolution.source == "custom" + assert ( + next( + item for item in _regions.region_registry() if item.name == "us-test-1" + ).description + == "Test Region" + ) + + +@pytest.mark.parametrize("variable", ["AWS_PROFILE", "AWS_DEFAULT_PROFILE"]) +def test_local_commands_ignore_a_dangling_ambient_profile( + variable: str, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + config = tmp_path / "aws-config" + config.write_text("[profile available]\nregion=us-east-1\n", encoding="utf-8") + monkeypatch.setenv("AWS_CONFIG_FILE", str(config)) + monkeypatch.setenv(variable, "removed-profile") + other = "AWS_DEFAULT_PROFILE" if variable == "AWS_PROFILE" else "AWS_PROFILE" + monkeypatch.delenv(other, raising=False) + + for arguments in ( + ["status"], + ["history", "status"], + ["config", "options"], + ["target", "list"], + ["boundary", "list"], + ): + _clear_region_caches() + result = _cli.console_main(arguments) + assert result.exit_code == 0, arguments + + +@pytest.mark.parametrize( + ("value", "expected"), + [("", False), (" ", False), ("other", False), ("dev", True)], +) +@pytest.mark.parametrize("variable", ["AWS_PROFILE", "AWS_DEFAULT_PROFILE"]) +def test_logout_stale_profile_warning_positive_and_negative_cases( + variable: str, + value: str, + expected: bool, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + directory = tmp_path / "aws" + monkeypatch.setenv(variable, value) + other = "AWS_DEFAULT_PROFILE" if variable == "AWS_PROFILE" else "AWS_PROFILE" + monkeypatch.delenv(other, raising=False) + _temporary_profile(_state.root(), directory) + monkeypatch.setenv("AWS_CONFIG_FILE", str(directory / "config")) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(directory / "credentials")) + + outcome = _sessions._logout_key( + f"{directory.absolute()}::dev", _logout_args(directory) + ) + assert bool(outcome.get("warnings")) is expected + if expected: + assert f"$env:{variable} = $null" in outcome["warnings"][0] + + +def test_logout_does_not_warn_when_selected_profile_is_restored( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("AWS_PROFILE", "dev") + directory = tmp_path / "aws" + directory.mkdir() + (directory / "credentials").write_text("[dev]\nkey=value\n", encoding="utf-8") + assert _sessions.stale_profile_environment_warnings(directory, "dev") == () + + +@pytest.mark.parametrize( + ("default_profile", "profile", "warning_variable"), + [ + ("other", "dev", None), + ("dev", "other", "AWS_DEFAULT_PROFILE"), + (" ", "dev", "AWS_PROFILE"), + ], +) +def test_stale_profile_warning_uses_effective_selector_precedence( + default_profile: str, + profile: str, + warning_variable: str | None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + directory = tmp_path / "aws" + directory.mkdir() + config = directory / "config" + credentials = directory / "credentials" + config.write_text("", encoding="utf-8") + credentials.write_text("", encoding="utf-8") + monkeypatch.setenv("AWS_CONFIG_FILE", str(config)) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(credentials)) + monkeypatch.setenv("AWS_DEFAULT_PROFILE", default_profile) + monkeypatch.setenv("AWS_PROFILE", profile) + + warnings = _sessions.stale_profile_environment_warnings(directory, "dev") + if warning_variable is None: + assert warnings == () + else: + assert len(warnings) == 1 + assert warnings[0].startswith(f"{warning_variable} still selects") + + +@pytest.mark.parametrize("warnings", [(), ("stale selector",)]) +def test_single_logout_json_omits_empty_warnings_and_includes_present_warnings( + warnings: tuple[str, ...], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + with ( + patch("hacksaws._sessions.recover_journal"), + patch("hacksaws._sessions.logout", return_value=True), + patch("hacksaws._sessions.logout_environment_warnings", return_value=warnings), + ): + result = _cli.console_main(["logout", "dev", "--json"]) + + assert isinstance(result.data, dict) + envelope = json.loads(capsys.readouterr().out) + if warnings: + assert result.data["warnings"] == list(warnings) + assert envelope["data"]["warnings"] == list(warnings) + else: + assert "warnings" not in result.data + assert "warnings" not in envelope["data"] + + +def test_history_settings_begin_and_finish_failures_preserve_command_result( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + sentinel = _configs.Result("SENTINEL", "", 7) + with ( + patch("hacksaws._history._settings", side_effect=RuntimeError("config")), + patch("hacksaws._cli._console_main_invocation", return_value=sentinel), + ): + assert _cli.console_main(["status"]) is sentinel + assert "continuing without recording" in capsys.readouterr().err + + handle = _history.HistoryHandle(id="id", started_monotonic=0.0, enabled=True) + with ( + patch("hacksaws._history.begin", side_effect=RuntimeError("begin")), + patch("hacksaws._cli._console_main_invocation", return_value=sentinel), + ): + assert _cli.console_main(["status"]) is sentinel + assert "continuing without recording" in capsys.readouterr().err + + with ( + patch("hacksaws._history.begin", return_value=handle), + patch("hacksaws._history.finish", side_effect=RuntimeError("finish")), + patch( + "hacksaws._history.warn_unavailable", + side_effect=RuntimeError("warning-output"), + ), + patch("hacksaws._cli._console_main_invocation", return_value=sentinel), + ): + assert _cli.console_main(["status"]) is sentinel + + with ( + patch("hacksaws._history.begin", return_value=handle), + patch("hacksaws._history.finish", side_effect=RuntimeError("finish")), + patch("hacksaws._cli._console_main_invocation", return_value=sentinel), + ): + assert _cli.console_main(["status"]) is sentinel + assert "continuing without recording" in capsys.readouterr().err + + +def test_history_enrich_and_note_failures_do_not_block_requested_handlers( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + status_data: dict[str, object] = { + "sessions": [], + "counts": {}, + "warnings": [], + } + with ( + patch("hacksaws._history.enrich", side_effect=RuntimeError("enrich")), + patch("hacksaws._sessions.status_report", return_value=status_data) as handler, + patch("hacksaws._cli._status_text", return_value="status survived"), + ): + status = _cli.console_main(["status"]) + assert status.exit_code == 0 + assert status.message == "status survived" + assert "status survived" in capsys.readouterr().out + handler.assert_called_once() + + mfa_args = argparse.Namespace( + action="login", + profile="dev", + target=None, + mfa_code="123456", + mfa_code_stdin=False, + json=False, + ) + expected = _configs.Result("MFA_SENTINEL", "mfa survived", 9) + with ( + patch("hacksaws._history.note_mfa_code", side_effect=RuntimeError("note")), + patch("hacksaws._sessions.mfa_login", return_value=expected) as mfa_handler, + ): + assert _cli._run_mfa(_configs.Context(mfa_args)) is expected + mfa_handler.assert_called_once() + + +def test_history_parse_and_failure_notes_preserve_real_errors_and_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + with patch( + "hacksaws._history.note_parse_failure", side_effect=RuntimeError("parse-note") + ): + result = _cli.console_main(["not-a-command"]) + assert result.exit_code == _configs.EXIT_USAGE + assert "invalid choice" in capsys.readouterr().err + + with ( + patch("hacksaws._history.fail", side_effect=RuntimeError("history-fail")), + patch( + "hacksaws._cli._console_main_invocation", + side_effect=RuntimeError("real-command-failure"), + ), + pytest.raises(RuntimeError, match="real-command-failure"), + ): + _cli.console_main(["status"]) + + +def test_integrated_logout_stale_environment_then_status_succeeds( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + directory = tmp_path / "aws" + _temporary_profile(_state.root(), directory) + monkeypatch.setenv("AWS_PROFILE", "dev") + monkeypatch.setenv("AWS_CONFIG_FILE", str(directory / "config")) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(directory / "credentials")) + + logout = _cli.console_main(["logout", "dev", "--directory", str(directory)]) + assert logout.exit_code == 0 + assert "AWS_PROFILE still selects removed profile" in logout.message + assert os.environ["AWS_PROFILE"] == "dev" + _clear_region_caches() + status = _cli.console_main(["status"]) + assert status.exit_code == 0 + + +def _legacy_logout_inventory(directory: Path) -> dict[str, object]: + return { + "profiles": [ + { + "auth_method": "legacy-mfa", + "location": None, + "profile": "dev", + "directory": str(directory), + } + ] + } + + +@pytest.mark.parametrize( + ("variable", "json_mode"), + [("AWS_PROFILE", False), ("AWS_DEFAULT_PROFILE", True)], +) +def test_legacy_logout_all_attaches_stale_selector_warning( + variable: str, + json_mode: bool, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + directory = tmp_path / "aws" + directory.mkdir() + config = directory / "config" + credentials = directory / "credentials" + config.write_text("[profile dev]\nregion=us-east-1\n", encoding="utf-8") + credentials.write_text("[dev]\nkey=value\n", encoding="utf-8") + monkeypatch.setenv(variable, "dev") + other = "AWS_DEFAULT_PROFILE" if variable == "AWS_PROFILE" else "AWS_PROFILE" + monkeypatch.delenv(other, raising=False) + monkeypatch.setenv("AWS_CONFIG_FILE", str(config)) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(credentials)) + + def remove_profile(_context: _configs.Context) -> None: + config.write_text("", encoding="utf-8") + credentials.write_text("", encoding="utf-8") + + arguments = ["logout", "--all", *(["--json"] if json_mode else [])] + with ( + patch( + "hacksaws._sessions.logout_all", + return_value={"outcomes": [], "errors": []}, + ), + patch( + "hacksaws._sessions.profile_inventory", + return_value=_legacy_logout_inventory(directory), + ), + patch("hacksaws._aws.logout", side_effect=remove_profile), + ): + result = _cli.console_main(arguments) + + assert isinstance(result.data, dict) + outcome = result.data["outcomes"][0] + assert f"$env:{variable} = $null" in outcome["warnings"][0] + assert f"Warning: {variable} still selects removed profile" in result.message + captured = capsys.readouterr() + if json_mode: + envelope = json.loads(captured.out) + assert envelope["data"]["outcomes"][0]["warnings"] == outcome["warnings"] + + +@pytest.mark.parametrize( + ("selector", "remove_profile"), + [("", True), (" ", True), ("other", True), ("dev", False)], +) +def test_legacy_logout_all_omits_warning_for_nonstale_selector_or_restored_profile( + selector: str, + remove_profile: bool, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + directory = tmp_path / "aws" + directory.mkdir() + config = directory / "config" + credentials = directory / "credentials" + config.write_text("[profile dev]\nregion=us-east-1\n", encoding="utf-8") + credentials.write_text("[dev]\nkey=value\n", encoding="utf-8") + monkeypatch.setenv("AWS_PROFILE", selector) + monkeypatch.delenv("AWS_DEFAULT_PROFILE", raising=False) + monkeypatch.setenv("AWS_CONFIG_FILE", str(config)) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(credentials)) + + def finish_logout(_context: _configs.Context) -> None: + if remove_profile: + config.write_text("", encoding="utf-8") + credentials.write_text("", encoding="utf-8") + + with ( + patch( + "hacksaws._sessions.logout_all", + return_value={"outcomes": [], "errors": []}, + ), + patch( + "hacksaws._sessions.profile_inventory", + return_value=_legacy_logout_inventory(directory), + ), + patch("hacksaws._aws.logout", side_effect=finish_logout), + ): + result = _cli.console_main(["logout", "--all"]) + + assert isinstance(result.data, dict) + assert "warnings" not in result.data["outcomes"][0] + assert "Warning:" not in result.message diff --git a/hacksaws/tests/test_coverage_closure.py b/hacksaws/tests/test_coverage_closure.py index 87a6edf..2a129c6 100644 --- a/hacksaws/tests/test_coverage_closure.py +++ b/hacksaws/tests/test_coverage_closure.py @@ -79,7 +79,7 @@ def _target_config() -> dict[str, Any]: def test_package_version_falls_back_to_pyproject() -> None: with patch("importlib.metadata.version", side_effect=metadata.PackageNotFoundError): reloaded = importlib.reload(hacksaws) - assert reloaded.__version__ == "0.4.0" + assert reloaded.__version__ == "0.4.1" def test_coverage_gate_uses_two_decimal_precision() -> None: diff --git a/hacksaws/tests/test_hacksaws.py b/hacksaws/tests/test_hacksaws.py index ea44312..3a083d9 100644 --- a/hacksaws/tests/test_hacksaws.py +++ b/hacksaws/tests/test_hacksaws.py @@ -156,8 +156,8 @@ def _temporary_credentials() -> dict[str, object]: def test_version_and_main_exit_status() -> None: """Expose the project version and pass the result status to the shell.""" with Path(__file__).parents[2].joinpath("pyproject.toml").open("rb") as stream: - assert tomllib.load(stream)["project"]["version"] == "0.4.0" - assert hacksaws.__version__ == "0.4.0" + assert tomllib.load(stream)["project"]["version"] == "0.4.1" + assert hacksaws.__version__ == "0.4.1" with patch( "hacksaws.console_main", return_value=_configs.Result("ERROR", "", exit_code=7), diff --git a/pyproject.toml b/pyproject.toml index 658ab8c..f9f2093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hacksaws" -version = "0.4.0" +version = "0.4.1" description = "A command-line utility for AWS profiles using dynamic authentication methods such as MFA." authors = [ { name = "Scott Ernst", email = "swernst@gmail.com" }, diff --git a/uv.lock b/uv.lock index be9e419..484d391 100644 --- a/uv.lock +++ b/uv.lock @@ -211,7 +211,7 @@ wheels = [ [[package]] name = "hacksaws" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "boto3", extra = ["crt"] },