hostctl 0.3.1__tar.gz → 0.3.2__tar.gz
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.
- {hostctl-0.3.1 → hostctl-0.3.2}/CHANGELOG.md +27 -1
- {hostctl-0.3.1 → hostctl-0.3.2}/PKG-INFO +1 -1
- {hostctl-0.3.1 → hostctl-0.3.2}/pyproject.toml +1 -1
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/AGENTS.md +18 -4
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_common.py +35 -23
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_connection.py +17 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_connection_string.py +36 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_api.py +26 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/.gitignore +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/AGENTS.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/LICENSE +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/README.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/README.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/results/0.2.7-py3.14.json +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/results/0.2.7-py3.9.json +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/results/0.3.0-py3.14.json +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/results/0.3.0-py3.9.json +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/run.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/api/reference.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/changelog.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/cli.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/contracts.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/extending.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/path.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/providers.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/run.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/transfer.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/docs/index.md +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/examples/application_provider.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/examples/local_run.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/examples/remote_run.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/mkdocs.yml +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/__init__.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/__main__.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/_async.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/_cli.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/__init__.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/__init__.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_qga_helper.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_ssh.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_staged_io.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_winrm.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/composite_path.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/container.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/qemu.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/serial.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/system.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/container.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/py.typed +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/_common.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/_grammar.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/sync.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/__init__.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/providers.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_live.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_registry.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_run_contract.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_sync_contract.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/conftest.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_application_provider.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_cli.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_cli_console.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_cmd_live_effects.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_composite_path_backend_kwargs.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_composite_path_inherited_operations.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_container_path.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_container_process.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_docs_snippets.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_exec_command.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_executor_exports.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_container.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_local.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_qemu.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_remote.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_winrm.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_login_shell.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_module_hygiene.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_observability.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_packaging.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_powershell_status_live.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_process.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_psrp.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_guest_semantics.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_lifecycle.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_path.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qga_errors.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qga_path_helper.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qga_transport.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_contract.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_executor.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_framing.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_host.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_live.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_shell.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_shell_rendering.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_ssh_process.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_staged_io.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_system_hosts.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_uri_hygiene.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_winrm_path.py +0 -0
- {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_winrm_powershell_live.py +0 -0
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.2] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`redact_uri` no longer over-redacts a well-formed URI.** The textual
|
|
15
|
+
fallback -- which reads the last `@` in the whole string as the userinfo
|
|
16
|
+
delimiter -- ran whenever `urlsplit` reported no password, including for
|
|
17
|
+
URIs that simply do not have one. `ssh://nas:22/mail/user@example.com`
|
|
18
|
+
carries no credential at all and came back as `ssh://nas@example.com`: a
|
|
19
|
+
diagnostic quietly naming a different host. Guessing is now confined to
|
|
20
|
+
input that does not parse -- when the URI is well formed, `urlsplit`'s
|
|
21
|
+
authority is the authority, so an `@` in a path is left alone. A password
|
|
22
|
+
hidden by an unencoded `/`, `?` or `#` is still redacted: that input is
|
|
23
|
+
malformed (the separator ends the authority, leaving `user:secret` read as
|
|
24
|
+
host:port), and detecting exactly that is what now gates the fallback.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `ConnectionString` now documents the parsing contract it already enforced:
|
|
29
|
+
the input must be a valid URI, and reserved characters in a password must
|
|
30
|
+
be percent-encoded. There is no lenient mode and none is planned -- a
|
|
31
|
+
password may itself contain `@` and `:`, so nothing can recover where an
|
|
32
|
+
unescaped one ended, and a guess in the parser would be a guess about what
|
|
33
|
+
to connect to. Documentation and tests only; behaviour is unchanged.
|
|
34
|
+
|
|
10
35
|
## [0.3.1] - 2026-09-22
|
|
11
36
|
|
|
12
37
|
### Fixed
|
|
@@ -762,7 +787,8 @@ test suite on Python 3.9 through 3.14.
|
|
|
762
787
|
assigned to it; a config-less host now builds its own family configuration
|
|
763
788
|
instead.
|
|
764
789
|
|
|
765
|
-
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.3.
|
|
790
|
+
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.3.2...HEAD
|
|
791
|
+
[0.3.2]: https://github.com/jose-pr/hostctl/compare/v0.3.1...v0.3.2
|
|
766
792
|
[0.3.1]: https://github.com/jose-pr/hostctl/compare/v0.3.0...v0.3.1
|
|
767
793
|
[0.3.0]: https://github.com/jose-pr/hostctl/compare/v0.2.7...v0.3.0
|
|
768
794
|
[0.2.7]: https://github.com/jose-pr/hostctl/compare/v0.2.6...v0.2.7
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hostctl
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Run commands and access files on a host, local or remote, protocol-agnostic
|
|
5
5
|
Project-URL: Homepage, https://github.com/jose-pr/hostctl/
|
|
6
6
|
Project-URL: Documentation, https://jose-pr.github.io/hostctl/
|
|
@@ -156,7 +156,11 @@ and dispatches it with no args, and that script is shell text, not a program.
|
|
|
156
156
|
renders. Supplying a password both in the URI and as an argument raises.
|
|
157
157
|
`redact_uri(uri)` STRIPS a password and returns a valid, reusable URI (not a
|
|
158
158
|
masked one, so a rendered form can never round-trip a wrong credential), for
|
|
159
|
-
logs, reprs, and error messages.
|
|
159
|
+
logs, reprs, and error messages. A WELL-FORMED URI IS NEVER OVER-REDACTED:
|
|
160
|
+
when it parses, `urlsplit`'s authority is the authority, so an `@` elsewhere
|
|
161
|
+
(`ssh://nas:22/mail/user@example.com`) is left alone. Only input that does
|
|
162
|
+
not parse gets the textual last-`@` guess, which may over-redact -- by then
|
|
163
|
+
the text has already failed to say where its authority ended.
|
|
160
164
|
- A connection URI may carry a raw tab, CR, or LF in its **userinfo**: those
|
|
161
165
|
are percent-encoded before `urlsplit` sees them, which would otherwise
|
|
162
166
|
delete them silently (`ssh://u:pw<LF>otp:1@host` would authenticate with
|
|
@@ -189,9 +193,19 @@ path grammar, so `path()` raises.
|
|
|
189
193
|
`ConnectionString(value, *, scheme=None, port=None, ...)` is the parsed form
|
|
190
194
|
of a target -- host, scheme, port, user, password -- with `is_local` answered
|
|
191
195
|
by `netimps` rather than guessed. It exists so a caller never reimplements
|
|
192
|
-
connection-string parsing.
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
connection-string parsing. A bare host parses (a missing scheme is not an
|
|
197
|
+
error), but otherwise the value MUST be a valid URI: an unclosed IPv6 literal,
|
|
198
|
+
a non-numeric port, or an unescaped `/`, `?` or `#` in a password RAISES.
|
|
199
|
+
**Percent-encode reserved characters in a password** -- there is no lenient
|
|
200
|
+
mode and none will be added, because a password may itself contain `@` and
|
|
201
|
+
`:`, so nothing can recover where an unescaped one ended, and guessing would
|
|
202
|
+
be guessing what to connect to. (`@` and `:` do parse: last `@`, first `:`,
|
|
203
|
+
per the URI grammar.) Use `redact_uri` when the text may not parse at all and
|
|
204
|
+
the job is only keeping a secret out of a log.
|
|
205
|
+
|
|
206
|
+
`uri_hostname(value)` returns the bare hostname of a URI authority,
|
|
207
|
+
unbracketing an IPv6 literal; it is the sibling of `uri_host`, `redact_uri`
|
|
208
|
+
and `parse_credentials`.
|
|
195
209
|
|
|
196
210
|
`__version__` is the installed version string.
|
|
197
211
|
|
|
@@ -230,6 +230,12 @@ def redact_uri(uri: str) -> str:
|
|
|
230
230
|
|
|
231
231
|
A URI with no password is returned unchanged.
|
|
232
232
|
|
|
233
|
+
**A well-formed URI is never over-redacted.** When the URI parses, what
|
|
234
|
+
`urlsplit` says is the authority *is* the authority, so an `@` anywhere
|
|
235
|
+
else -- a mailbox in a path, `ssh://nas:22/mail/user@example.com` -- is
|
|
236
|
+
left alone. Guessing is confined to input that does not parse, where
|
|
237
|
+
there is nothing better to go on: see below.
|
|
238
|
+
|
|
233
239
|
This never raises. It is meant for error messages and log records, where
|
|
234
240
|
failing to render a diagnostic would be worse than rendering an odd one.
|
|
235
241
|
Characters `urlsplit` would delete (tab, CR, LF) are percent-encoded first,
|
|
@@ -238,31 +244,37 @@ def redact_uri(uri: str) -> str:
|
|
|
238
244
|
"""
|
|
239
245
|
try:
|
|
240
246
|
parsed = _urlsplit(_encode_stripped_characters(uri))
|
|
241
|
-
|
|
247
|
+
# Not dead code: `.port` is a property that RAISES, and touching it
|
|
248
|
+
# is what decides "well formed" here. It catches both shapes that
|
|
249
|
+
# matter in one place -- a non-numeric or out-of-range port, and an
|
|
250
|
+
# unencoded `/`, `?` or `#` in a password, since the latter ends the
|
|
251
|
+
# authority and leaves `user:secret` being read as host:port. So the
|
|
252
|
+
# bad port IS the symptom of the hidden password.
|
|
253
|
+
parsed.port # noqa: B018
|
|
242
254
|
except ValueError:
|
|
243
|
-
#
|
|
244
|
-
# the userinfo, which is the whole job.
|
|
245
|
-
password = None
|
|
255
|
+
pass # Malformed. The textual fallback below is all that is left.
|
|
246
256
|
else:
|
|
247
|
-
if password is
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
#
|
|
264
|
-
#
|
|
265
|
-
# the
|
|
257
|
+
if parsed.password is None:
|
|
258
|
+
# Well formed and carrying no password: there is nothing to
|
|
259
|
+
# strip, and the fallback must not run. It reads the LAST `@` in
|
|
260
|
+
# the whole string as the userinfo delimiter, which would rewrite
|
|
261
|
+
# `ssh://nas:22/mail/user@example.com` -- a URI with no
|
|
262
|
+
# credentials at all -- into `ssh://nas@example.com`, naming a
|
|
263
|
+
# different host.
|
|
264
|
+
return uri
|
|
265
|
+
try:
|
|
266
|
+
return _without_password(parsed).geturl()
|
|
267
|
+
except ValueError:
|
|
268
|
+
# Not reachable through `.port`, which is already validated above;
|
|
269
|
+
# kept because "never raises" is a contract this function is
|
|
270
|
+
# called from inside exception handlers to honour.
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
# Only a malformed URI reaches here. Read the userinfo textually: the last
|
|
274
|
+
# `@` wins, as it does in a real authority. This can over-redact -- but
|
|
275
|
+
# the text has already failed to say where its authority ended, so there
|
|
276
|
+
# is no reading of it that is known to be right, and an odd diagnostic
|
|
277
|
+
# beats a password in a log.
|
|
266
278
|
head, separator, _rest = uri.partition("://")
|
|
267
279
|
start = len(head) + len(separator)
|
|
268
280
|
authority_end = uri.rfind("@")
|
|
@@ -110,6 +110,23 @@ class ConnectionString:
|
|
|
110
110
|
not an invalid URI -- it is a URI with the scheme left off, which is what
|
|
111
111
|
people type on a command line.
|
|
112
112
|
|
|
113
|
+
Beyond that, **the input must be a valid URI, and a malformed one
|
|
114
|
+
raises** -- an unclosed IPv6 literal, a non-numeric port, or a password
|
|
115
|
+
written with an unescaped `/`, `?` or `#` (each ends the authority, so
|
|
116
|
+
what precedes it is read as a port and fails to cast).
|
|
117
|
+
|
|
118
|
+
**Reserved characters in a password must be percent-encoded, and one
|
|
119
|
+
that is not gets no rescue attempt.** There is no rescue to make: a
|
|
120
|
+
password may itself contain `@` and `:`, so no rule recovers where an
|
|
121
|
+
unescaped one ended. Those two do parse -- the last `@` and the
|
|
122
|
+
first `:` win, as in any authority, giving `pa@ss` and `pa:ss` -- but
|
|
123
|
+
that is the URI grammar being applied, not a guess about the secret.
|
|
124
|
+
Guessing here would be guessing about what to *connect to*, so a raise is
|
|
125
|
+
the answer. :func:`redact_uri` is the other job and keeps its own rules:
|
|
126
|
+
stripping a secret out of text that may not parse at all, for a log line
|
|
127
|
+
nothing connects to. It does not over-redact a well-formed URI either --
|
|
128
|
+
it guesses only once the text has failed to parse.
|
|
129
|
+
|
|
113
130
|
Every field can also be supplied directly, and three layers decide each
|
|
114
131
|
one: **an explicit argument wins, then whatever the string carried, then
|
|
115
132
|
`defaults`.**
|
|
@@ -357,3 +357,39 @@ def test_replace_normalises_what_init_normalises():
|
|
|
357
357
|
|
|
358
358
|
assert parsed.replace(scheme="WSS") == parsed
|
|
359
359
|
assert ConnectionString("ssh://nas").replace(port="2222").port == 2222
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@pytest.mark.parametrize(
|
|
363
|
+
"target",
|
|
364
|
+
[
|
|
365
|
+
"ssh://root:pa/ss@nas",
|
|
366
|
+
"ssh://root:pa?ss@nas",
|
|
367
|
+
"ssh://root:pa#ss@nas",
|
|
368
|
+
"ssh://root:pw@[::1",
|
|
369
|
+
"ssh://root:pw@nas:notaport",
|
|
370
|
+
],
|
|
371
|
+
)
|
|
372
|
+
def test_a_malformed_uri_raises_rather_than_being_guessed_at(target):
|
|
373
|
+
"""The input must be a valid URI, and there is deliberately no lenient
|
|
374
|
+
mode: what this parses is what gets connected to, so a guess here is a
|
|
375
|
+
guess about the target. An unescaped `/`, `?` or `#` ends the authority,
|
|
376
|
+
which is why `root:pa` is then read as a port and fails to cast."""
|
|
377
|
+
with pytest.raises(ValueError):
|
|
378
|
+
ConnectionString(target)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def test_reserved_characters_in_a_password_must_be_percent_encoded():
|
|
382
|
+
value = ConnectionString("ssh://root:pa%2Fss@nas")
|
|
383
|
+
|
|
384
|
+
assert value.username == "root"
|
|
385
|
+
assert value.password == "pa/ss"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_at_and_colon_in_a_password_parse_by_the_uri_grammar():
|
|
389
|
+
"""These two do not raise -- the last `@` and the first `:` delimit, as in
|
|
390
|
+
any authority. They are also why no textual fallback is possible: a
|
|
391
|
+
password may contain both, so nothing recovers where an unescaped one
|
|
392
|
+
ended. `redact_uri` may over-redact, but only once the text has failed
|
|
393
|
+
to parse at all; this may not guess even then."""
|
|
394
|
+
assert ConnectionString("ssh://root:pa@ss@nas").password == "pa@ss"
|
|
395
|
+
assert ConnectionString("ssh://root:pa:ss@nas").password == "pa:ss"
|
|
@@ -638,6 +638,32 @@ def test_redact_uri_leaves_a_credential_free_uri_alone():
|
|
|
638
638
|
assert redact_uri("wss://nas:8443/api?x=1") == "wss://nas:8443/api?x=1"
|
|
639
639
|
|
|
640
640
|
|
|
641
|
+
@pytest.mark.parametrize(
|
|
642
|
+
"uri",
|
|
643
|
+
(
|
|
644
|
+
"ssh://nas:22/mail/user@example.com",
|
|
645
|
+
"ssh://user@nas:22/path/foo@bar",
|
|
646
|
+
"https://host:443/a@b",
|
|
647
|
+
"ssh://nas:22/mail/a@b/more@here",
|
|
648
|
+
),
|
|
649
|
+
)
|
|
650
|
+
def test_redact_uri_never_over_redacts_a_well_formed_uri(uri):
|
|
651
|
+
"""The textual fallback reads the LAST `@` as the userinfo delimiter, and
|
|
652
|
+
it used to run whenever `urlsplit` reported no password -- including for
|
|
653
|
+
URIs that simply do not have one.
|
|
654
|
+
|
|
655
|
+
`ssh://nas:22/mail/user@example.com` carries no credential at all and came
|
|
656
|
+
back as `ssh://nas@example.com`: not merely over-redacted but pointing at
|
|
657
|
+
a different host. Guessing is now confined to input that does not parse.
|
|
658
|
+
"""
|
|
659
|
+
assert redact_uri(uri) == uri
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def test_redact_uri_still_strips_a_password_beside_an_at_sign_in_the_path():
|
|
663
|
+
"""The guard above must not cost the actual redaction."""
|
|
664
|
+
assert redact_uri("ssh://root:pw@nas:22/mail/a@b") == "ssh://root@nas:22/mail/a@b"
|
|
665
|
+
|
|
666
|
+
|
|
641
667
|
def test_an_unsupported_session_raises_not_implemented_not_a_runtime_error():
|
|
642
668
|
"""AGENTS.md: unsupported run()/path()/spawn() raise NotImplementedError.
|
|
643
669
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|