hostctl 0.3.0__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.0 → hostctl-0.3.2}/CHANGELOG.md +50 -1
  2. {hostctl-0.3.0 → hostctl-0.3.2}/PKG-INFO +1 -1
  3. {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/providers.md +9 -1
  4. {hostctl-0.3.0 → hostctl-0.3.2}/pyproject.toml +1 -1
  5. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/AGENTS.md +24 -5
  6. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_common.py +35 -23
  7. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_connection.py +17 -0
  8. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_ssh.py +15 -1
  9. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/composite_path.py +21 -1
  10. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/system.py +5 -5
  11. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/provider/_common.py +40 -6
  12. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_connection_string.py +36 -0
  13. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_api.py +26 -0
  14. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_provider_fault_injection.py +131 -0
  15. {hostctl-0.3.0 → hostctl-0.3.2}/.gitignore +0 -0
  16. {hostctl-0.3.0 → hostctl-0.3.2}/AGENTS.md +0 -0
  17. {hostctl-0.3.0 → hostctl-0.3.2}/LICENSE +0 -0
  18. {hostctl-0.3.0 → hostctl-0.3.2}/README.md +0 -0
  19. {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/README.md +0 -0
  20. {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/results/0.2.7-py3.14.json +0 -0
  21. {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/results/0.2.7-py3.9.json +0 -0
  22. {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/results/0.3.0-py3.14.json +0 -0
  23. {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/results/0.3.0-py3.9.json +0 -0
  24. {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/run.py +0 -0
  25. {hostctl-0.3.0 → hostctl-0.3.2}/docs/api/reference.md +0 -0
  26. {hostctl-0.3.0 → hostctl-0.3.2}/docs/changelog.md +0 -0
  27. {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/cli.md +0 -0
  28. {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/contracts.md +0 -0
  29. {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/extending.md +0 -0
  30. {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/path.md +0 -0
  31. {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/run.md +0 -0
  32. {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/transfer.md +0 -0
  33. {hostctl-0.3.0 → hostctl-0.3.2}/docs/index.md +0 -0
  34. {hostctl-0.3.0 → hostctl-0.3.2}/examples/application_provider.py +0 -0
  35. {hostctl-0.3.0 → hostctl-0.3.2}/examples/copy_between_hosts.py +0 -0
  36. {hostctl-0.3.0 → hostctl-0.3.2}/examples/local_run.py +0 -0
  37. {hostctl-0.3.0 → hostctl-0.3.2}/examples/remote_run.py +0 -0
  38. {hostctl-0.3.0 → hostctl-0.3.2}/mkdocs.yml +0 -0
  39. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/__init__.py +0 -0
  40. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/__main__.py +0 -0
  41. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/_async.py +0 -0
  42. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/_cli.py +0 -0
  43. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/__init__.py +0 -0
  44. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/_common.py +0 -0
  45. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/_qga.py +0 -0
  46. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/container.py +0 -0
  47. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/local.py +0 -0
  48. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/psrp.py +0 -0
  49. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/qemu.py +0 -0
  50. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/serial.py +0 -0
  51. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/ssh.py +0 -0
  52. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/winrm.py +0 -0
  53. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/__init__.py +0 -0
  54. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_local.py +0 -0
  55. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_qga_helper.py +0 -0
  56. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_staged_io.py +0 -0
  57. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_winrm.py +0 -0
  58. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/container.py +0 -0
  59. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/container_path.py +0 -0
  60. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/qemu.py +0 -0
  61. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/serial.py +0 -0
  62. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/__init__.py +0 -0
  63. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/_common.py +0 -0
  64. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/container.py +0 -0
  65. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/psrp.py +0 -0
  66. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/qemu_serial.py +0 -0
  67. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/serial.py +0 -0
  68. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/ssh.py +0 -0
  69. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/provider/__init__.py +0 -0
  70. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/provider/transports.py +0 -0
  71. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/py.typed +0 -0
  72. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/serial/__init__.py +0 -0
  73. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/__init__.py +0 -0
  74. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/_common.py +0 -0
  75. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/_grammar.py +0 -0
  76. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/cmd.py +0 -0
  77. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/fish.py +0 -0
  78. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/posix.py +0 -0
  79. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/powershell.py +0 -0
  80. {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/sync.py +0 -0
  81. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/__init__.py +0 -0
  82. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/path_fakes.py +0 -0
  83. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/providers.py +0 -0
  84. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_composite_path_routing.py +0 -0
  85. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_live.py +0 -0
  86. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_path_contract.py +0 -0
  87. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_process_contract.py +0 -0
  88. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_registry.py +0 -0
  89. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_run_contract.py +0 -0
  90. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_sync_contract.py +0 -0
  91. {hostctl-0.3.0 → hostctl-0.3.2}/tests/conftest.py +0 -0
  92. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_application_provider.py +0 -0
  93. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_asyncssh_errors.py +0 -0
  94. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_cli.py +0 -0
  95. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_cli_console.py +0 -0
  96. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_cmd_live_effects.py +0 -0
  97. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_composite_path_backend_kwargs.py +0 -0
  98. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_composite_path_inherited_operations.py +0 -0
  99. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_composite_path_propagation.py +0 -0
  100. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_container_path.py +0 -0
  101. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_container_process.py +0 -0
  102. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_docs_snippets.py +0 -0
  103. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_exec_command.py +0 -0
  104. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_executor_exports.py +0 -0
  105. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_container.py +0 -0
  106. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_local.py +0 -0
  107. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_module_layout.py +0 -0
  108. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_qemu.py +0 -0
  109. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_remote.py +0 -0
  110. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_winrm.py +0 -0
  111. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_login_shell.py +0 -0
  112. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_module_hygiene.py +0 -0
  113. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_observability.py +0 -0
  114. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_packaging.py +0 -0
  115. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_powershell_status_live.py +0 -0
  116. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_process.py +0 -0
  117. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_psrp.py +0 -0
  118. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_executor.py +0 -0
  119. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_guest_semantics.py +0 -0
  120. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_lifecycle.py +0 -0
  121. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_path.py +0 -0
  122. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_serial.py +0 -0
  123. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_serial_process.py +0 -0
  124. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qga_errors.py +0 -0
  125. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qga_path_helper.py +0 -0
  126. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qga_ssh_transport.py +0 -0
  127. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qga_transport.py +0 -0
  128. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_contract.py +0 -0
  129. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_executor.py +0 -0
  130. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_framing.py +0 -0
  131. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_host.py +0 -0
  132. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_live.py +0 -0
  133. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_shell.py +0 -0
  134. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_shell_rendering.py +0 -0
  135. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_ssh_process.py +0 -0
  136. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_staged_io.py +0 -0
  137. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_sync_helpers.py +0 -0
  138. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_system_host_fidelity.py +0 -0
  139. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_system_hosts.py +0 -0
  140. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_uri_hygiene.py +0 -0
  141. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_winrm_path.py +0 -0
  142. {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_winrm_powershell_live.py +0 -0
@@ -7,6 +7,52 @@ 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
+
35
+ ## [0.3.1] - 2026-09-22
36
+
37
+ ### Fixed
38
+
39
+ - **A composite path falls back when SFTP cannot connect.** Nothing on the
40
+ path route connected the provider first: pathlib_next dialled SFTP inside
41
+ the operation, so an untrusted host key (`HostKeyNotVerifiable`) or a
42
+ refused port escaped raw and the next path provider was never tried --
43
+ while `run()` on the same host fell back. A composite path now calls the
44
+ provider's `connect()` before dispatch, and SFTP maps asyncssh's connect
45
+ errors the way the SSH leg already did. Most likely to fire since 0.3.0
46
+ started verifying the SFTP host key: any host not yet in `known_hosts`.
47
+ - **`run()` no longer re-dials a declined provider on every call.** 0.3.0
48
+ cleared every decline per call, so a dead primary cost a full connect
49
+ attempt (~9 s against a refused port) on each `run()` while the fallback
50
+ served. A decline now lasts for the generation again, and is re-admitted
51
+ only when no other provider can serve -- which keeps a single-provider
52
+ host from bricking on one transient refusal, for paths as well as `run()`.
53
+ - A path refusal with nothing left to try names why ("no path provider
54
+ completed read (...: Host key is not trusted ...)") and keeps the cause.
55
+
10
56
  ## [0.3.0] - 2026-09-22
11
57
 
12
58
  ### Added
@@ -741,7 +787,10 @@ test suite on Python 3.9 through 3.14.
741
787
  assigned to it; a config-less host now builds its own family configuration
742
788
  instead.
743
789
 
744
- [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.7...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
792
+ [0.3.1]: https://github.com/jose-pr/hostctl/compare/v0.3.0...v0.3.1
793
+ [0.3.0]: https://github.com/jose-pr/hostctl/compare/v0.2.7...v0.3.0
745
794
  [0.2.7]: https://github.com/jose-pr/hostctl/compare/v0.2.6...v0.2.7
746
795
  [0.2.6]: https://github.com/jose-pr/hostctl/compare/v0.2.5...v0.2.6
747
796
  [0.2.5]: https://github.com/jose-pr/hostctl/compare/v0.2.4...v0.2.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hostctl
3
- Version: 0.3.0
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/
@@ -100,7 +100,15 @@ the same conservative default.
100
100
 
101
101
  A provider that declines is remembered for the current connection generation, so
102
102
  later operations skip it instead of re-attempting it every time. Reconnecting or
103
- replacing a provider starts a new generation and re-probes everything.
103
+ replacing a provider starts a new generation and re-probes everything. The one
104
+ exception is a host with nothing else left: when every other candidate is
105
+ unavailable, an earlier refusal is tried again rather than failing without
106
+ dialling, so one transient refusal cannot brick a single-provider host.
107
+
108
+ Paths and commands connect the same way: a composite path calls the provider's
109
+ `connect()` before each dispatch, which is where a refused port or an untrusted
110
+ host key becomes `OperationNotStarted`. A path provider's `connect()` must
111
+ therefore be idempotent and cheap once connected.
104
112
 
105
113
  ## Selection traces
106
114
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hostctl"
7
- version = "0.3.0"
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
 
@@ -315,7 +329,12 @@ hostctl applies to whatever you return.
315
329
  falls through to the next provider for **that call only**; it does not take
316
330
  the provider out of service.
317
331
  - `ProviderSelector` holds the ordered providers and the per-generation
318
- declines; `ProviderSelection` is one resolved choice.
332
+ declines; `ProviderSelection` is one resolved choice. `select()` re-admits
333
+ a decline recorded by an earlier operation only when no other provider can
334
+ serve.
335
+ - A provider's optional `connect()` is where a pre-dispatch failure belongs.
336
+ Composite paths call a path provider's `connect()` before **every**
337
+ dispatch, so it must be idempotent and cheap once connected.
319
338
  `ProviderSelector.redact(value)` is the one redaction used in traces --
320
339
  rendered commands routinely carry credentials, so log through it.
321
340
  - `SessionInitializer` is the hook a provider may accept to prepare a session
@@ -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`.**
@@ -706,10 +706,24 @@ class SftpPathProvider(PathProvider):
706
706
  # Closing is owned by SshExecutorProvider for the shared transport;
707
707
  # connecting is not, because the SFTP client has to be established from
708
708
  # the caller's thread before any native recursive operation runs.
709
+ #
710
+ # Composite paths call this before EVERY dispatch, so once connected it
711
+ # must stay cheap: a lock, a liveness check and a cache hit.
709
712
  def connect(self):
713
+ from .. import _async
714
+
710
715
  try:
711
716
  self.transport.connect()
712
- self.transport.warm_sftp()
717
+ try:
718
+ self.transport.warm_sftp()
719
+ except Exception as exc:
720
+ # pathlib_next dials its own SFTP connection and hands back
721
+ # asyncssh's errors raw -- `HostKeyNotVerifiable` is not an
722
+ # OSError -- so map them as the transport's own connect does.
723
+ normalized = _async.normalize_asyncssh_error(exc)
724
+ if normalized is exc:
725
+ raise
726
+ raise normalized from exc
713
727
  except (OSError, TimeoutError) as exc:
714
728
  log.debug(
715
729
  "SFTP provider declining before dispatch: %s: %s",
@@ -498,10 +498,20 @@ class _CompositePathMixin:
498
498
  candidates = self._providers_in_order(operation, pin=pin)
499
499
  attempted = False
500
500
  not_implemented: typing.Optional[NotImplementedError] = None
501
+ not_started: typing.Optional[OperationNotStarted] = None
501
502
  for provider in candidates:
502
503
  attempted = True
503
504
  old = (self._provider, self._backend_path, self._factory, self._pinned)
504
505
  try:
506
+ # Connect first, where a failure is provably pre-dispatch.
507
+ # SFTP used to be dialled lazily inside the backend by the
508
+ # operation itself, so an unreachable server or an untrusted
509
+ # host key escaped as a raw transport error and the next
510
+ # provider was never tried -- while `run()`, which connects
511
+ # before dispatch, fell back on the same host.
512
+ connect = getattr(provider, "connect", None)
513
+ if connect is not None:
514
+ connect()
505
515
  backend_path = self._provider_path(provider)
506
516
  if pin:
507
517
  # A write/open operation owns the provider choice before
@@ -531,6 +541,7 @@ class _CompositePathMixin:
531
541
  # `excluded` already stops us retrying it for this call.
532
542
  continue
533
543
  except OperationNotStarted as exc:
544
+ not_started = exc
534
545
  if pin:
535
546
  self._provider, self._backend_path, self._factory, self._pinned = (
536
547
  old
@@ -554,7 +565,16 @@ class _CompositePathMixin:
554
565
  # visibly with a single provider, where "try the next one" has
555
566
  # nothing to try.
556
567
  raise not_implemented
557
- raise OperationNotStarted(f"no path provider completed {operation}")
568
+ # Carry the last refusal: "no provider completed" alone hid WHY -- a
569
+ # rejected host key read the same as a refused port.
570
+ cause = not_started.cause if not_started is not None else None
571
+ detail = ""
572
+ if not_started is not None:
573
+ reason = f"{not_started}: {cause}" if cause is not None else not_started
574
+ detail = f" ({ProviderSelector.redact(reason)})"
575
+ raise OperationNotStarted(
576
+ f"no path provider completed {operation}{detail}", cause=cause
577
+ ) from (cause or not_started)
558
578
 
559
579
  def via(self, name: str):
560
580
  provider = next((item for item in self._providers if item.name == name), None)
@@ -826,11 +826,11 @@ class SystemHost(Host):
826
826
  raise NotImplementedError(
827
827
  f"{type(self).__name__} does not provide the 'run' capability"
828
828
  )
829
- # A refusal recorded by an earlier call must not decide this one: a
830
- # single-provider SSH host that hit one transient ConnectionError
831
- # never dialled out again until close(). Within this call, `excluded`
832
- # still stops a provider being retried after it declines.
833
- self._executor_selector.retry_declined()
829
+ # No `retry_declined()` here. Clearing every refusal per call re-dialled
830
+ # a dead primary on EVERY run() -- ~9 s each against a refused port --
831
+ # although the fallback was serving. The selector re-admits an earlier
832
+ # refusal only when nothing else is left, which is what keeps a
833
+ # single-provider host from bricking on one transient failure.
834
834
  excluded: list[str] = []
835
835
  while True:
836
836
  provider = self._executor_selector.select(exclude=excluded).provider
@@ -337,13 +337,12 @@ class ProviderSelector:
337
337
  return self._generation
338
338
 
339
339
  def retry_declined(self) -> None:
340
- """Forget refusals recorded during an earlier call.
340
+ """Forget every refusal recorded this generation.
341
341
 
342
- A decline exists to stop one dispatch re-dialling a provider that just
343
- refused; it was never meant to outlive the call. Kept for the life of
344
- the selector it bricked a single-provider host: one transient refusal
345
- (sshd restarting, a network blip) and hostctl never dialled out again
346
- until `close()`.
342
+ Not needed to recover a host: `select()` already re-admits an earlier
343
+ refusal when nothing else can serve. This is for a caller that knows
344
+ the declined provider is back (sshd restarted) and wants it preferred
345
+ over a fallback that is still serving.
347
346
  """
348
347
  with self._lock:
349
348
  self._declined.clear()
@@ -539,6 +538,15 @@ class ProviderSelector:
539
538
  len(self._attempt_trace),
540
539
  )
541
540
  return result
541
+ if self._readmit_earlier_declines(excluded, capability):
542
+ # Nothing else can serve, so a refusal recorded by an EARLIER
543
+ # operation gets another chance rather than failing this one
544
+ # without dialling: one transient refusal (sshd restarting) on a
545
+ # single-provider host otherwise bricked it until close(). A
546
+ # decline this operation made itself is in `excluded` and stays.
547
+ return self.select(
548
+ capability=capability, exclude=excluded, policy=policy, pin=pin
549
+ )
542
550
  log.debug(
543
551
  "no provider available (generation %d); candidates: %s",
544
552
  self._generation,
@@ -563,6 +571,32 @@ class ProviderSelector:
563
571
  cause=cause,
564
572
  ) from cause
565
573
 
574
+ def _readmit_earlier_declines(
575
+ self, excluded: typing.AbstractSet[str], capability: str | None
576
+ ) -> bool:
577
+ """Forget declines that the running operation did not make.
578
+
579
+ A name the caller excluded is one it tried and saw refuse, so only
580
+ declines outside `excluded` came from an earlier operation.
581
+ """
582
+ with self._lock:
583
+ stale = [
584
+ provider.name
585
+ for provider in self.providers
586
+ if provider.name in self._declined
587
+ and provider.name not in excluded
588
+ and (capability is None or capability in provider.capabilities)
589
+ ]
590
+ for name in stale:
591
+ del self._declined[name]
592
+ self._decline_causes.pop(name, None)
593
+ for name in stale:
594
+ log.debug(
595
+ "provider %s re-admitted: every other candidate is unavailable",
596
+ _redacted_name(name),
597
+ )
598
+ return bool(stale)
599
+
566
600
  def _record(self, entry: dict[str, object]) -> None:
567
601
  """Merge one trace entry, letting a later record supersede an earlier.
568
602
 
@@ -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
 
@@ -800,6 +800,137 @@ def test_path_falls_back_over_every_candidate_not_just_one():
800
800
  assert path.provider.name == "third"
801
801
 
802
802
 
803
+ def _served_by_memory(text="served"):
804
+ backend = MemPathBackend()
805
+ MemPath("/etc", backend=backend).mkdir(parents=True)
806
+ MemPath("/etc/hostname", backend=backend).write_text(text)
807
+ return PathProvider("memory", lambda *parts: MemPath(*parts, backend=backend))
808
+
809
+
810
+ def _sftp_that_cannot_connect(*, connect=None, warm=None):
811
+ from hostctl import SshConfig
812
+ from hostctl.host._ssh import SftpPathProvider, _SshTransport
813
+
814
+ transport = _SshTransport(SshConfig("nas.example", username="root"))
815
+ transport.connect = connect or (lambda: None)
816
+ transport.warm_sftp = warm or (lambda: None)
817
+ return SftpPathProvider(transport)
818
+
819
+
820
+ @pytest.mark.parametrize(
821
+ "error",
822
+ (
823
+ # What `_SshTransport.connect()` makes of each: an untrusted host key
824
+ # (`HostKeyNotVerifiable`) and a refused or firewalled port.
825
+ ConnectionError("Host key is not trusted for host nas.example"),
826
+ ConnectionRefusedError(10061, "connection refused"),
827
+ TimeoutError("connect timed out"),
828
+ ),
829
+ ids=("host-key", "refused", "timeout"),
830
+ )
831
+ def test_a_path_falls_back_when_sftp_cannot_connect(error):
832
+ """`run()` connects before dispatch and fell back; `path()` did not.
833
+
834
+ Nothing on the composite-path route called `SftpPathProvider.connect()`:
835
+ pathlib_next dialled SFTP lazily inside the operation, so the transport
836
+ error escaped `_dispatch` and the working next provider was never tried.
837
+ """
838
+
839
+ def refuse():
840
+ raise error
841
+
842
+ host = PosixHost(
843
+ path_providers=(_sftp_that_cannot_connect(connect=refuse), _served_by_memory())
844
+ )
845
+
846
+ path = host.path("/etc/hostname")
847
+
848
+ assert path.read_text() == "served"
849
+
850
+
851
+ def test_a_raw_asyncssh_host_key_error_from_the_sftp_leg_declines():
852
+ """pathlib_next dials its own SFTP connection and raises asyncssh's error
853
+ raw; `HostKeyNotVerifiable` is not an OSError, so it was never taken as
854
+ "nothing started" even where `connect()` did run."""
855
+ import asyncssh
856
+
857
+ def untrusted():
858
+ raise asyncssh.HostKeyNotVerifiable("Host key is not trusted")
859
+
860
+ host = PosixHost(
861
+ path_providers=(_sftp_that_cannot_connect(warm=untrusted), _served_by_memory())
862
+ )
863
+
864
+ assert host.path("/etc/hostname").read_text() == "served"
865
+
866
+
867
+ def test_a_path_refusal_names_its_cause_when_nothing_is_left():
868
+ def refuse():
869
+ raise ConnectionError("Host key is not trusted for host nas.example")
870
+
871
+ host = PosixHost(path_providers=(_sftp_that_cannot_connect(connect=refuse),))
872
+
873
+ with pytest.raises(OperationNotStarted) as raised:
874
+ host.path("/etc/hostname").read_text()
875
+
876
+ assert "Host key is not trusted" in str(raised.value)
877
+ assert isinstance(raised.value.cause, ConnectionError)
878
+
879
+
880
+ def test_a_path_refusal_does_not_brick_a_single_provider_host():
881
+ """Now that a failed SFTP connect declines, the decline must not outlive
882
+ the refusal on a host with nothing to fall back to."""
883
+ state = {"failing": True}
884
+
885
+ def flaky():
886
+ if state["failing"]:
887
+ raise ConnectionRefusedError(10061, "sshd restarting")
888
+
889
+ backend = MemPathBackend()
890
+ MemPath("/etc", backend=backend).mkdir(parents=True)
891
+ MemPath("/etc/hostname", backend=backend).write_text("back")
892
+ provider = _sftp_that_cannot_connect(connect=flaky)
893
+ provider.factory = lambda *parts: MemPath(*parts, backend=backend)
894
+ host = PosixHost(path_providers=(provider,))
895
+
896
+ with pytest.raises(OperationNotStarted):
897
+ host.path("/etc/hostname").read_text()
898
+
899
+ state["failing"] = False
900
+ assert host.path("/etc/hostname").read_text() == "back"
901
+
902
+
903
+ def test_a_declined_primary_is_not_redialled_while_the_fallback_serves():
904
+ """Every `run()` cleared every decline, so a dead primary was dialled
905
+ again on each call -- ~9 s apiece against a refused port -- although the
906
+ fallback had been serving all along."""
907
+ dialled = []
908
+
909
+ def refuse(command, *args, **options):
910
+ dialled.append(command)
911
+ raise OperationNotStarted(
912
+ "connection refused", cause=ConnectionRefusedError("refused")
913
+ )
914
+
915
+ host = PosixHost(
916
+ executor_providers=(
917
+ ExecutorProvider("ssh", refuse, capabilities=()),
918
+ ExecutorProvider(
919
+ "fallback",
920
+ lambda command, *args, **options: subprocess.CompletedProcess(
921
+ (command,), 0, b"ok", b""
922
+ ),
923
+ capabilities=(),
924
+ ),
925
+ )
926
+ )
927
+
928
+ for _ in range(3):
929
+ assert host.run("uptime", check=False).stdout == b"ok"
930
+
931
+ assert len(dialled) == 1
932
+
933
+
803
934
  def test_two_threads_selecting_do_not_share_a_trace():
804
935
  """The per-operation trace and the probe/decline maps were unsynchronised
805
936
  instance state, so concurrent operations on one host mixed trace entries
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