credux 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
credux/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Credux: manage cloud credentials and the profiles that use them."""
credux/app.py ADDED
@@ -0,0 +1,55 @@
1
+ """Wiring: one object that owns config, registry, state, and resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ import click
8
+
9
+ from .config import Config
10
+ from .model import Registry
11
+ from .providers import aws as aws_provider # noqa: F401 registers the aws provider
12
+ from .providers import base as provider_base
13
+ from .providers.base import ResolutionContext
14
+ from .sessions import SessionManager
15
+ from .state import State
16
+
17
+
18
+ def prompt_mfa(serial: str) -> str:
19
+ return click.prompt(f"MFA code for {serial}", hide_input=False, type=str)
20
+
21
+
22
+ class App:
23
+ """Built once per CLI invocation and handed to every command."""
24
+
25
+ def __init__(
26
+ self,
27
+ client_factory: Callable[..., Any] | None = None,
28
+ mfa_code: str | None = None,
29
+ ) -> None:
30
+ self.config = Config.load()
31
+ self.registry = Registry(self.config)
32
+ self.state = State.load()
33
+ if client_factory is None:
34
+ client_factory = provider_base.default_client_factory
35
+ self.client_factory = client_factory
36
+ self._mfa_code = mfa_code
37
+
38
+ def _prompt_mfa(self, serial: str) -> str:
39
+ if self._mfa_code:
40
+ return self._mfa_code
41
+ return prompt_mfa(serial)
42
+
43
+ def context(self) -> ResolutionContext:
44
+ return ResolutionContext(
45
+ self.config,
46
+ self.registry,
47
+ client_factory=self.client_factory,
48
+ prompt_mfa=self._prompt_mfa,
49
+ )
50
+
51
+ def sessions(self, ctx: ResolutionContext | None = None) -> SessionManager:
52
+ return SessionManager(self.config, self.registry, self.state, ctx or self.context())
53
+
54
+ def save(self) -> None:
55
+ self.config.save()
credux/awsfiles.py ADDED
@@ -0,0 +1,276 @@
1
+ """Writing the AWS shared credentials and config files.
2
+
3
+ Credux owns a marker-delimited region. Anything a human wrote outside it is
4
+ copied through untouched.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import configparser
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Iterable
13
+
14
+ from .errors import ConfigError
15
+ from .fsutil import atomic_write_text, backup_once
16
+
17
+ BEGIN_MARKER = "# >>> credux managed block >>> do not edit inside"
18
+ END_MARKER = "# <<< credux managed block <<<"
19
+
20
+
21
+ @dataclass
22
+ class WriteReport:
23
+ """What the caller should tell the user about a write it just made.
24
+
25
+ Both facts are about the file as it was *before* the write, and neither
26
+ can be recovered afterwards: the backup is taken once and never again,
27
+ and credux's own `[default]` section lands inside the managed region on
28
+ every write, so afterwards the file always contains one.
29
+
30
+ Carries no file content, only a path and a flag, because everything this
31
+ module writes is credential material.
32
+ """
33
+
34
+ backup: Path | None = None
35
+ shadowed_default: bool = False
36
+
37
+
38
+ @dataclass
39
+ class ProfileEntry:
40
+ profile: str
41
+ access_key_id: str
42
+ secret_access_key: str
43
+ session_token: str | None = None
44
+ region: str | None = None
45
+
46
+
47
+ def _default_entry(default_profile: dict | None) -> ProfileEntry | None:
48
+ if not default_profile or not default_profile.get("enabled", True):
49
+ return None
50
+ return ProfileEntry(
51
+ profile="default",
52
+ access_key_id=default_profile.get("access_key_id", "test"),
53
+ secret_access_key=default_profile.get("secret_access_key", "test"),
54
+ )
55
+
56
+
57
+ def render_credentials_block(entries: Iterable[ProfileEntry]) -> str:
58
+ """The managed region of the credentials file, markers included."""
59
+ lines = [BEGIN_MARKER]
60
+ for entry in entries:
61
+ lines.append("")
62
+ lines.append(f"[{entry.profile}]")
63
+ lines.append(f"aws_access_key_id = {entry.access_key_id}")
64
+ lines.append(f"aws_secret_access_key = {entry.secret_access_key}")
65
+ if entry.session_token:
66
+ lines.append(f"aws_session_token = {entry.session_token}")
67
+ lines.append("")
68
+ lines.append(END_MARKER)
69
+ return "\n".join(lines) + "\n"
70
+
71
+
72
+ def render_config_block(entries: Iterable[ProfileEntry]) -> str:
73
+ """The managed region of the AWS config file, markers included."""
74
+ lines = [BEGIN_MARKER]
75
+ for entry in entries:
76
+ if not entry.region:
77
+ continue
78
+ lines.append("")
79
+ lines.append(f"[profile {entry.profile}]")
80
+ lines.append(f"region = {entry.region}")
81
+ lines.append("")
82
+ lines.append(END_MARKER)
83
+ return "\n".join(lines) + "\n"
84
+
85
+
86
+ def _locate_managed_region(text: str) -> tuple[int, int] | None:
87
+ """Find the (start, end) string indices of BEGIN_MARKER and END_MARKER.
88
+
89
+ The end marker is searched for starting at the begin marker's index,
90
+ never from the start of the string. Otherwise human-written text that
91
+ happens to contain an END_MARKER-like line before the real BEGIN_MARKER
92
+ would be mistaken for the close of the region: `end` would land before
93
+ `start`, and every caller built on this - replacing the region on write,
94
+ or reading it back - would either treat the region as absent or slice
95
+ out the wrong text as the managed body.
96
+
97
+ This is the single source of truth for "where is the managed region";
98
+ `replace_managed_region` and `read_managed_entries` both call it so the
99
+ two can never drift apart on how they find it.
100
+
101
+ Returns None if no complete BEGIN_MARKER...END_MARKER pair is found.
102
+ """
103
+ start = text.find(BEGIN_MARKER)
104
+ if start == -1:
105
+ return None
106
+ end = text.find(END_MARKER, start)
107
+ if end == -1:
108
+ return None
109
+ return start, end
110
+
111
+
112
+ def replace_managed_region(existing: str, block: str) -> str:
113
+ """Swap the managed region inside existing text, or append it.
114
+
115
+ One asymmetry is intentional rather than a bug: a BEGIN_MARKER left
116
+ dangling without its matching END_MARKER (a human deleted only the
117
+ closing line) is not repaired on this call - the orphaned marker is
118
+ kept as part of the preserved prefix and a fresh block is appended
119
+ after it. On the *next* write, `_locate_managed_region` finds that
120
+ orphaned BEGIN_MARKER first, its search for an END_MARKER lands on the
121
+ newly appended block's closing line, and the two collapse into one
122
+ region. So a half-deleted marker pair self-heals after one further
123
+ write, while a stray END_MARKER-like string before any real
124
+ BEGIN_MARKER can no longer confuse region detection at all (see
125
+ `_locate_managed_region`).
126
+ """
127
+ located = _locate_managed_region(existing)
128
+ if located is None:
129
+ prefix = existing
130
+ if prefix and not prefix.endswith("\n"):
131
+ prefix += "\n"
132
+ if prefix:
133
+ prefix += "\n"
134
+ return prefix + block
135
+ start, end = located
136
+ tail = existing[end + len(END_MARKER) :]
137
+ tail = tail.lstrip("\n")
138
+ if tail:
139
+ tail = "\n" + tail
140
+ return existing[:start] + block + tail
141
+
142
+
143
+ def has_unmanaged_default(existing: str) -> bool:
144
+ """Whether a `[default]` section sits outside the managed region.
145
+
146
+ Only the text a human owns is searched: the managed region is cut out
147
+ first, because credux writes its own `[default]` in there on every write
148
+ and finding that one would report a collision with itself.
149
+
150
+ A file holding two `[default]` sections is not an error credux can fix
151
+ for the user - which set of keys an AWS SDK ends up using is decided by
152
+ that SDK's parser, not by credux - so this only ever feeds a warning.
153
+ """
154
+ located = _locate_managed_region(existing)
155
+ if located is None:
156
+ outside = existing
157
+ else:
158
+ start, end = located
159
+ outside = existing[:start] + existing[end + len(END_MARKER) :]
160
+ return any(line.strip() == "[default]" for line in outside.splitlines())
161
+
162
+
163
+ def _write(path: Path, block: str) -> tuple[str, Path | None]:
164
+ """Replace the managed region. Returns the prior text and any backup.
165
+
166
+ The backup captures the file as it was *before credux ever touched it*,
167
+ so it is taken only when the existing text carries no managed region.
168
+ Backing up a file that already has one would snapshot credux's own
169
+ output - live session tokens included - and, since `backup_once` skips
170
+ an existing backup, a user who deleted the real snapshot would silently
171
+ get a credux-written file in its place under the same name.
172
+ """
173
+ path = Path(path)
174
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
175
+ backup = (
176
+ backup_once(path) if existing and _locate_managed_region(existing) is None else None
177
+ )
178
+ atomic_write_text(path, replace_managed_region(existing, block))
179
+ return existing, backup
180
+
181
+
182
+ def write_credentials(
183
+ path: Path, entries: list[ProfileEntry], default_profile: dict | None
184
+ ) -> WriteReport:
185
+ """Rewrite the managed region with the given active sessions."""
186
+ ordered: list[ProfileEntry] = []
187
+ fake_default = _default_entry(default_profile)
188
+ if fake_default is not None:
189
+ ordered.append(fake_default)
190
+ ordered.extend(entry for entry in entries if entry.profile != "default")
191
+ existing, backup = _write(path, render_credentials_block(ordered))
192
+ # A handwritten `[default]` only collides with something if credux is
193
+ # actually writing a `default` of its own; with the fake default turned
194
+ # off, the user's is simply the only one in the file.
195
+ shadowed = fake_default is not None and has_unmanaged_default(existing)
196
+ return WriteReport(backup=backup, shadowed_default=shadowed)
197
+
198
+
199
+ def write_config(path: Path, entries: list[ProfileEntry]) -> WriteReport:
200
+ """Rewrite the managed region of the AWS config file."""
201
+ filtered = [entry for entry in entries if entry.profile != "default"]
202
+ _, backup = _write(path, render_config_block(filtered))
203
+ return WriteReport(backup=backup)
204
+
205
+
206
+ def _malformed_block_message(path: Path, exc: configparser.Error) -> str:
207
+ """Describe a parse failure without quoting any of the file's content.
208
+
209
+ configparser's own message text is not safe to interpolate here: its
210
+ parse errors embed the offending source line verbatim, and in this file
211
+ every line is either a profile header or a key line carrying real
212
+ credential material, so `{exc}` reliably leaks a secret into an error
213
+ message that `CreduxGroup.invoke` echoes to stderr. Only the *line number*
214
+ is reported, which is enough to find the problem and carries no content.
215
+ """
216
+ lineno = getattr(exc, "lineno", None)
217
+ where = f", line {lineno} of the block" if isinstance(lineno, int) else ""
218
+ return (
219
+ f"{path} has a malformed managed credentials block "
220
+ f"({type(exc).__name__}{where}). "
221
+ "Credux owns everything between the credux-managed markers in "
222
+ "this file; delete that block (the lines between "
223
+ f"{BEGIN_MARKER!r} and {END_MARKER!r}) and credux will "
224
+ "regenerate it on the next write."
225
+ )
226
+
227
+
228
+ def read_managed_entries(path: Path) -> list[ProfileEntry]:
229
+ """Parse the managed region back into entries, skipping the fake default.
230
+
231
+ Reading what is already materialized means starting one profile does not
232
+ require re-resolving every other active profile.
233
+
234
+ Uses `_locate_managed_region`, the same offset-aware search
235
+ `replace_managed_region` uses, so a human-written line that happens to
236
+ look like END_MARKER before the real block cannot make this return an
237
+ empty list and cause a caller (like `SessionManager.start`) to rewrite
238
+ the file believing no profile is active yet.
239
+ """
240
+ path = Path(path)
241
+ if not path.exists():
242
+ return []
243
+ text = path.read_text(encoding="utf-8")
244
+ located = _locate_managed_region(text)
245
+ if located is None:
246
+ return []
247
+ start, end = located
248
+ body = text[start + len(BEGIN_MARKER) : end]
249
+ # interpolation=None: configparser's default BasicInterpolation treats '%'
250
+ # as an escape, so a secret key containing one (AWS generates '+' and '/',
251
+ # but a user-supplied value is not constrained) raises
252
+ # InterpolationSyntaxError - and that exception's message quotes the
253
+ # offending fragment of the value. Credential material is opaque bytes to
254
+ # this parser and must never be interpolated.
255
+ parser = configparser.ConfigParser(interpolation=None)
256
+ entries: list[ProfileEntry] = []
257
+ # The section loop stays inside the try, not after it: one handler over the
258
+ # whole parse is what stops any configparser error from bypassing the
259
+ # content-free wrapper below and reaching the user with the value quoted.
260
+ try:
261
+ parser.read_string(body)
262
+ for name in parser.sections():
263
+ if name == "default":
264
+ continue
265
+ section = parser[name]
266
+ entries.append(
267
+ ProfileEntry(
268
+ profile=name,
269
+ access_key_id=section.get("aws_access_key_id", ""),
270
+ secret_access_key=section.get("aws_secret_access_key", ""),
271
+ session_token=section.get("aws_session_token") or None,
272
+ )
273
+ )
274
+ except configparser.Error as exc:
275
+ raise ConfigError(_malformed_block_message(path, exc)) from exc
276
+ return entries
credux/browser.py ADDED
@@ -0,0 +1,117 @@
1
+ """Launching a browser session isolated per profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import subprocess
7
+ from typing import Any, Callable
8
+ from urllib.parse import quote
9
+
10
+ from .errors import UsageError
11
+
12
+ # The exact vocabularies Firefox's container API accepts. A value outside
13
+ # either list is rejected by the container extension, which refuses the whole
14
+ # link rather than falling back, so these are not suggestions.
15
+ CONTAINER_COLORS = (
16
+ "blue",
17
+ "turquoise",
18
+ "green",
19
+ "yellow",
20
+ "orange",
21
+ "red",
22
+ "pink",
23
+ "purple",
24
+ )
25
+ CONTAINER_ICONS = (
26
+ "fingerprint",
27
+ "briefcase",
28
+ "dollar",
29
+ "cart",
30
+ "circle",
31
+ "gift",
32
+ "vacation",
33
+ "food",
34
+ "fruit",
35
+ "pet",
36
+ "tree",
37
+ "chill",
38
+ )
39
+
40
+
41
+ def _profile_digest(profile: str) -> bytes:
42
+ """A stable digest of a profile name, for deriving its container styling.
43
+
44
+ blake2b, not the built-in hash(): hash() is salted per interpreter process
45
+ unless PYTHONHASHSEED is set, so it would repaint every container on every
46
+ run. The point of deriving these from the name at all is that an account
47
+ keeps the same color and icon forever, on every machine, with nothing
48
+ stored anywhere.
49
+ """
50
+ return hashlib.blake2b(profile.encode("utf-8"), digest_size=8).digest()
51
+
52
+
53
+ def container_color(profile: str) -> str:
54
+ """The container color for a profile: derived, stable, never configured."""
55
+ return CONTAINER_COLORS[_profile_digest(profile)[0] % len(CONTAINER_COLORS)]
56
+
57
+
58
+ def container_icon(profile: str) -> str:
59
+ """The container icon for a profile. Independent of the color: a separate
60
+ digest byte, so two profiles sharing a color rarely also share an icon."""
61
+ return CONTAINER_ICONS[_profile_digest(profile)[1] % len(CONTAINER_ICONS)]
62
+
63
+
64
+ def open_container(
65
+ profile: str,
66
+ url: str,
67
+ command: list[str],
68
+ runner: Callable[[list[str]], Any] | None = None,
69
+ ) -> list[str]:
70
+ """Render the configured browser command and run it. Returns the argv used.
71
+
72
+ Four placeholders are offered, in two pairs, because whether the value
73
+ needs percent-encoding depends entirely on where the command puts it:
74
+
75
+ - `{profile_encoded}` / `{url_encoded}` for a value embedded *inside
76
+ another URL*, which is what the default Firefox container command does
77
+ ("ext+container:name=...&url=..."). Both values can legitimately
78
+ contain '+', '=' or '&', which would otherwise corrupt the container
79
+ URL's own query string.
80
+ - `{profile}` / `{url}` raw, for a value passed as a standalone argv
81
+ element, which is what every non-container browser command does
82
+ ("google-chrome --profile-directory=... <url>"). Encoding there is
83
+ actively wrong: Chrome receives "https%3A%2F%2F..." and treats it as a
84
+ search string rather than a URL.
85
+
86
+ Two more placeholders, `{color}` and `{icon}`, carry the container styling
87
+ derived from the profile name (see `container_color`). They are single
88
+ lowercase words from a fixed vocabulary, so they need no encoded variants.
89
+ A command that omits them leaves the styling to the container extension,
90
+ which applies one fixed pair to everything it creates.
91
+ """
92
+ if runner is None:
93
+ runner = subprocess.Popen
94
+ if not command:
95
+ raise UsageError(
96
+ "settings.browser_command is empty; set it with "
97
+ "'credux config set browser_command'"
98
+ )
99
+ argv = [
100
+ part.format(
101
+ profile=profile,
102
+ url=url,
103
+ profile_encoded=quote(profile, safe=""),
104
+ url_encoded=quote(url, safe=""),
105
+ color=container_color(profile),
106
+ icon=container_icon(profile),
107
+ )
108
+ for part in command
109
+ ]
110
+ try:
111
+ runner(argv)
112
+ except FileNotFoundError as exc:
113
+ raise UsageError(
114
+ f"Could not launch {argv[0]!r}. Install it, or point "
115
+ "settings.browser_command at the browser you use."
116
+ ) from exc
117
+ return argv