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.
Files changed (142) hide show
  1. {hostctl-0.3.1 → hostctl-0.3.2}/CHANGELOG.md +27 -1
  2. {hostctl-0.3.1 → hostctl-0.3.2}/PKG-INFO +1 -1
  3. {hostctl-0.3.1 → hostctl-0.3.2}/pyproject.toml +1 -1
  4. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/AGENTS.md +18 -4
  5. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_common.py +35 -23
  6. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_connection.py +17 -0
  7. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_connection_string.py +36 -0
  8. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_api.py +26 -0
  9. {hostctl-0.3.1 → hostctl-0.3.2}/.gitignore +0 -0
  10. {hostctl-0.3.1 → hostctl-0.3.2}/AGENTS.md +0 -0
  11. {hostctl-0.3.1 → hostctl-0.3.2}/LICENSE +0 -0
  12. {hostctl-0.3.1 → hostctl-0.3.2}/README.md +0 -0
  13. {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/README.md +0 -0
  14. {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/results/0.2.7-py3.14.json +0 -0
  15. {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/results/0.2.7-py3.9.json +0 -0
  16. {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/results/0.3.0-py3.14.json +0 -0
  17. {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/results/0.3.0-py3.9.json +0 -0
  18. {hostctl-0.3.1 → hostctl-0.3.2}/benchmarks/run.py +0 -0
  19. {hostctl-0.3.1 → hostctl-0.3.2}/docs/api/reference.md +0 -0
  20. {hostctl-0.3.1 → hostctl-0.3.2}/docs/changelog.md +0 -0
  21. {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/cli.md +0 -0
  22. {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/contracts.md +0 -0
  23. {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/extending.md +0 -0
  24. {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/path.md +0 -0
  25. {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/providers.md +0 -0
  26. {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/run.md +0 -0
  27. {hostctl-0.3.1 → hostctl-0.3.2}/docs/guide/transfer.md +0 -0
  28. {hostctl-0.3.1 → hostctl-0.3.2}/docs/index.md +0 -0
  29. {hostctl-0.3.1 → hostctl-0.3.2}/examples/application_provider.py +0 -0
  30. {hostctl-0.3.1 → hostctl-0.3.2}/examples/copy_between_hosts.py +0 -0
  31. {hostctl-0.3.1 → hostctl-0.3.2}/examples/local_run.py +0 -0
  32. {hostctl-0.3.1 → hostctl-0.3.2}/examples/remote_run.py +0 -0
  33. {hostctl-0.3.1 → hostctl-0.3.2}/mkdocs.yml +0 -0
  34. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/__init__.py +0 -0
  35. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/__main__.py +0 -0
  36. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/_async.py +0 -0
  37. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/_cli.py +0 -0
  38. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/__init__.py +0 -0
  39. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/_common.py +0 -0
  40. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/_qga.py +0 -0
  41. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/container.py +0 -0
  42. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/local.py +0 -0
  43. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/psrp.py +0 -0
  44. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/qemu.py +0 -0
  45. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/serial.py +0 -0
  46. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/ssh.py +0 -0
  47. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/executor/winrm.py +0 -0
  48. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/__init__.py +0 -0
  49. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_local.py +0 -0
  50. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_qga_helper.py +0 -0
  51. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_ssh.py +0 -0
  52. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_staged_io.py +0 -0
  53. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/_winrm.py +0 -0
  54. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/composite_path.py +0 -0
  55. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/container.py +0 -0
  56. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/container_path.py +0 -0
  57. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/qemu.py +0 -0
  58. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/serial.py +0 -0
  59. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/host/system.py +0 -0
  60. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/__init__.py +0 -0
  61. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/_common.py +0 -0
  62. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/container.py +0 -0
  63. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/psrp.py +0 -0
  64. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/qemu_serial.py +0 -0
  65. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/serial.py +0 -0
  66. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/process/ssh.py +0 -0
  67. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/provider/__init__.py +0 -0
  68. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/provider/_common.py +0 -0
  69. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/provider/transports.py +0 -0
  70. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/py.typed +0 -0
  71. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/serial/__init__.py +0 -0
  72. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/__init__.py +0 -0
  73. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/_common.py +0 -0
  74. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/_grammar.py +0 -0
  75. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/cmd.py +0 -0
  76. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/fish.py +0 -0
  77. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/posix.py +0 -0
  78. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/shell/powershell.py +0 -0
  79. {hostctl-0.3.1 → hostctl-0.3.2}/src/hostctl/sync.py +0 -0
  80. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/__init__.py +0 -0
  81. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/path_fakes.py +0 -0
  82. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/providers.py +0 -0
  83. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_composite_path_routing.py +0 -0
  84. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_live.py +0 -0
  85. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_path_contract.py +0 -0
  86. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_process_contract.py +0 -0
  87. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_registry.py +0 -0
  88. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_run_contract.py +0 -0
  89. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conformance/test_sync_contract.py +0 -0
  90. {hostctl-0.3.1 → hostctl-0.3.2}/tests/conftest.py +0 -0
  91. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_application_provider.py +0 -0
  92. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_asyncssh_errors.py +0 -0
  93. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_cli.py +0 -0
  94. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_cli_console.py +0 -0
  95. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_cmd_live_effects.py +0 -0
  96. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_composite_path_backend_kwargs.py +0 -0
  97. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_composite_path_inherited_operations.py +0 -0
  98. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_composite_path_propagation.py +0 -0
  99. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_container_path.py +0 -0
  100. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_container_process.py +0 -0
  101. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_docs_snippets.py +0 -0
  102. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_exec_command.py +0 -0
  103. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_executor_exports.py +0 -0
  104. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_container.py +0 -0
  105. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_local.py +0 -0
  106. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_module_layout.py +0 -0
  107. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_qemu.py +0 -0
  108. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_remote.py +0 -0
  109. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_host_winrm.py +0 -0
  110. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_login_shell.py +0 -0
  111. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_module_hygiene.py +0 -0
  112. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_observability.py +0 -0
  113. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_packaging.py +0 -0
  114. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_powershell_status_live.py +0 -0
  115. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_process.py +0 -0
  116. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_provider_fault_injection.py +0 -0
  117. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_psrp.py +0 -0
  118. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_executor.py +0 -0
  119. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_guest_semantics.py +0 -0
  120. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_lifecycle.py +0 -0
  121. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_path.py +0 -0
  122. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_serial.py +0 -0
  123. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qemu_serial_process.py +0 -0
  124. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qga_errors.py +0 -0
  125. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qga_path_helper.py +0 -0
  126. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qga_ssh_transport.py +0 -0
  127. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_qga_transport.py +0 -0
  128. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_contract.py +0 -0
  129. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_executor.py +0 -0
  130. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_framing.py +0 -0
  131. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_host.py +0 -0
  132. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_serial_live.py +0 -0
  133. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_shell.py +0 -0
  134. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_shell_rendering.py +0 -0
  135. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_ssh_process.py +0 -0
  136. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_staged_io.py +0 -0
  137. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_sync_helpers.py +0 -0
  138. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_system_host_fidelity.py +0 -0
  139. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_system_hosts.py +0 -0
  140. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_uri_hygiene.py +0 -0
  141. {hostctl-0.3.1 → hostctl-0.3.2}/tests/test_winrm_path.py +0 -0
  142. {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.1...HEAD
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.1
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/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hostctl"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  authors = [{ name = "Jose A." }]
9
9
  description = "Run commands and access files on a host, local or remote, protocol-agnostic"
10
10
  readme = "README.md"
@@ -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. `uri_hostname(value)` returns the bare hostname of
193
- a URI authority, unbracketing an IPv6 literal; it is the sibling of
194
- `uri_host`, `redact_uri` and `parse_credentials`.
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
- password = parsed.password
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
- # A malformed IPv6 authority. The textual fallback below still strips
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 not None:
248
- try:
249
- return _without_password(parsed).geturl()
250
- except ValueError:
251
- # `_rebuild_authority` reads `parsed.port`, which raises for a
252
- # non-numeric or out-of-range one. This function is called
253
- # from inside exception handlers to format a diagnostic, so
254
- # raising here would replace the real error with this one.
255
- pass
256
-
257
- # `urlsplit` finds no password when one contains an unencoded `/`, `?` or
258
- # `#`: those end the authority, so the secret lands in the path or query
259
- # and the "redacted" form used to be the input in full. Randomly generated
260
- # passwords routinely contain `/`.
261
- #
262
- # Fall back to reading the userinfo textually. The last `@` wins, as it
263
- # does in a real authority. This can over-redact a path that contains `@`
264
- # -- deliberately: an odd diagnostic beats a password in a log, which is
265
- # the trade-off this function already documents.
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