hostctl 0.1.0__tar.gz → 0.1.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 (107) hide show
  1. {hostctl-0.1.0 → hostctl-0.1.2}/CHANGELOG.md +44 -1
  2. {hostctl-0.1.0 → hostctl-0.1.2}/PKG-INFO +1 -1
  3. {hostctl-0.1.0 → hostctl-0.1.2}/docs/api/reference.md +1 -0
  4. {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/transfer.md +4 -7
  5. {hostctl-0.1.0 → hostctl-0.1.2}/pyproject.toml +1 -1
  6. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/__init__.py +2 -0
  7. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/__init__.py +1 -0
  8. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/_common.py +29 -1
  9. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/_ssh.py +2 -1
  10. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/_winrm.py +2 -1
  11. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/qemu.py +2 -1
  12. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/_common.py +1 -1
  13. {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_process_contract.py +7 -1
  14. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_composite_path_propagation.py +1 -1
  15. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_api.py +39 -0
  16. {hostctl-0.1.0 → hostctl-0.1.2}/.gitignore +0 -0
  17. {hostctl-0.1.0 → hostctl-0.1.2}/LICENSE +0 -0
  18. {hostctl-0.1.0 → hostctl-0.1.2}/README.md +0 -0
  19. {hostctl-0.1.0 → hostctl-0.1.2}/docs/changelog.md +0 -0
  20. {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/cli.md +0 -0
  21. {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/contracts.md +0 -0
  22. {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/extending.md +0 -0
  23. {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/path.md +0 -0
  24. {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/providers.md +0 -0
  25. {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/run.md +0 -0
  26. {hostctl-0.1.0 → hostctl-0.1.2}/docs/index.md +0 -0
  27. {hostctl-0.1.0 → hostctl-0.1.2}/examples/application_provider.py +0 -0
  28. {hostctl-0.1.0 → hostctl-0.1.2}/examples/copy_between_hosts.py +0 -0
  29. {hostctl-0.1.0 → hostctl-0.1.2}/examples/local_run.py +0 -0
  30. {hostctl-0.1.0 → hostctl-0.1.2}/examples/remote_run.py +0 -0
  31. {hostctl-0.1.0 → hostctl-0.1.2}/mkdocs.yml +0 -0
  32. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/AGENTS.md +0 -0
  33. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/__main__.py +0 -0
  34. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/_async.py +0 -0
  35. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/_cli.py +0 -0
  36. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/__init__.py +0 -0
  37. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/_common.py +0 -0
  38. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/_qga.py +0 -0
  39. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/container.py +0 -0
  40. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/local.py +0 -0
  41. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/psrp.py +0 -0
  42. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/qemu.py +0 -0
  43. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/serial.py +0 -0
  44. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/ssh.py +0 -0
  45. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/winrm.py +0 -0
  46. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/_local.py +0 -0
  47. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/composite_path.py +0 -0
  48. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/container.py +0 -0
  49. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/container_path.py +0 -0
  50. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/serial.py +0 -0
  51. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/system.py +0 -0
  52. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/__init__.py +0 -0
  53. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/_common.py +0 -0
  54. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/container.py +0 -0
  55. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/psrp.py +0 -0
  56. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/qemu_serial.py +0 -0
  57. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/serial.py +0 -0
  58. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/ssh.py +0 -0
  59. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/provider/__init__.py +0 -0
  60. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/provider/_common.py +0 -0
  61. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/provider/transports.py +0 -0
  62. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/py.typed +0 -0
  63. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/serial/__init__.py +0 -0
  64. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/__init__.py +0 -0
  65. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/cmd.py +0 -0
  66. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/fish.py +0 -0
  67. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/posix.py +0 -0
  68. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/powershell.py +0 -0
  69. {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/sync.py +0 -0
  70. {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/__init__.py +0 -0
  71. {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/path_fakes.py +0 -0
  72. {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/providers.py +0 -0
  73. {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_composite_path_routing.py +0 -0
  74. {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_live.py +0 -0
  75. {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_path_contract.py +0 -0
  76. {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_run_contract.py +0 -0
  77. {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_sync_contract.py +0 -0
  78. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_application_provider.py +0 -0
  79. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_asyncssh_errors.py +0 -0
  80. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_cli.py +0 -0
  81. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_container_path.py +0 -0
  82. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_container_process.py +0 -0
  83. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_container.py +0 -0
  84. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_local.py +0 -0
  85. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_module_layout.py +0 -0
  86. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_qemu.py +0 -0
  87. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_remote.py +0 -0
  88. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_winrm.py +0 -0
  89. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_observability.py +0 -0
  90. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_process.py +0 -0
  91. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_provider_fault_injection.py +0 -0
  92. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_psrp.py +0 -0
  93. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qemu_executor.py +0 -0
  94. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qemu_path.py +0 -0
  95. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qemu_serial.py +0 -0
  96. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qemu_serial_process.py +0 -0
  97. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qga_ssh_transport.py +0 -0
  98. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qga_transport.py +0 -0
  99. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_serial_executor.py +0 -0
  100. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_serial_host.py +0 -0
  101. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_serial_live.py +0 -0
  102. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_shell.py +0 -0
  103. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_ssh_process.py +0 -0
  104. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_sync_helpers.py +0 -0
  105. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_system_host_fidelity.py +0 -0
  106. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_system_hosts.py +0 -0
  107. {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_winrm_path.py +0 -0
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.2] - 2026-07-28
11
+
12
+ Supersedes 0.1.1, which was tagged but never published: a release-gate test
13
+ timed out on the Windows/Python 3.14 runner, so nothing reached PyPI. There is
14
+ no 0.1.1 release; its fix is included here.
15
+
16
+ ### Added
17
+
18
+ - `uri_hostname(parsed)` returns the host as it was written in a URI, rather
19
+ than the case-folded spelling `urlsplit().hostname` produces. Use it in
20
+ `_from_parsed_uri` wherever a host is stored; presence checks can keep using
21
+ `.hostname`, since emptiness does not depend on case.
22
+
23
+ ### Changed
24
+
25
+ - A config built from a URI now stores the hostname as typed, so
26
+ `HostConfig("ssh://nasA")` keeps `nasA` in `.host`, in `connection_uri`, and
27
+ in the `HostInfo.hostname` a system host reports without connecting. It
28
+ previously stored `urlsplit`'s lowercased form, which meant the library
29
+ echoed a spelling the operator never wrote and left downstream code to
30
+ recover the original from the URI itself.
31
+
32
+ Consequently `.host` is the spelling that was given, not a canonical form:
33
+ `HostConfig("ssh://nasA")` and `HostConfig("ssh://nasa")` no longer compare
34
+ equal, so code using a config or its host as a dict key or for
35
+ deduplication should casefold explicitly. Resolution is unaffected — DNS,
36
+ SSH, and WinRM all treat the two spellings as one name.
37
+
38
+ ### Fixed
39
+
40
+ - `redact_uri()` no longer case-folds the hostname. Only the branch that
41
+ rebuilt the authority — the one taken when a password was present — adopted
42
+ `urlsplit`'s lowercased `hostname`, so `nasA` rendered as `nasa` with a
43
+ credential and as `nasA` without one. The same host now renders one way
44
+ whichever branch runs, and log records stay greppable by the name the
45
+ operator typed. Redaction removes a credential; normalizing a host is a
46
+ separate concern and is left to the transport that resolves it.
47
+ - The spawn conformance test no longer fails on a slow runner. It waited 10s
48
+ for a real `powershell.exe -NoProfile` to start, exit, and be reaped, which
49
+ a cold Windows CI runner can exceed. The wait is a hang guard rather than a
50
+ performance assertion, so it is now 60s. Test-only; no library change.
51
+
10
52
  ## [0.1.0] - 2026-07-27
11
53
 
12
54
  First release. Alpha: the public surface is deliberately small (66 exported
@@ -144,5 +186,6 @@ test suite on Python 3.9 through 3.14.
144
186
  assigned to it; a config-less host now builds its own family configuration
145
187
  instead.
146
188
 
147
- [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.1.0...HEAD
189
+ [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.1.2...HEAD
190
+ [0.1.2]: https://github.com/jose-pr/hostctl/compare/v0.1.0...v0.1.2
148
191
  [0.1.0]: https://github.com/jose-pr/hostctl/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hostctl
3
- Version: 0.1.0
3
+ Version: 0.1.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/
@@ -84,6 +84,7 @@ without a deprecation cycle.
84
84
  ::: hostctl.strict_uri_credentials
85
85
  ::: hostctl.strict_uri_query
86
86
  ::: hostctl.uri_host
87
+ ::: hostctl.uri_hostname
87
88
 
88
89
  ## Composing transports
89
90
 
@@ -116,11 +116,8 @@ with ProgressReader(source_path.open("rb"), report, total=size) as reader:
116
116
  though their read sides stream.
117
117
 
118
118
  Three gaps are tracked as `pathlib_next` requests rather than hostctl features,
119
- because each one lives inside machinery hostctl deliberately does not fork.
120
- Drafts are kept in the repository under `.agents/upstream/`:
119
+ because each one lives inside machinery hostctl deliberately does not fork:
121
120
 
122
- | Request | Draft |
123
- | --- | --- |
124
- | Byte-progress hook in `BinaryOpen.copy` / `Path.copy()` | `pathlib_next-copy-progress-hook.md` |
125
- | Symlink handling in `PathSyncer` (plus its unreachable `ignore_error`) | `pathlib_next-symlink-sync.md` |
126
- | Optional backend-native checksum protocol | `pathlib_next-backend-checksum-protocol.md` |
121
+ - Byte-progress hook in `BinaryOpen.copy` / `Path.copy()`
122
+ - Symlink handling in `PathSyncer` (plus its unreachable `ignore_error`)
123
+ - Optional backend-native checksum protocol
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hostctl"
7
- version = "0.1.0"
7
+ version = "0.1.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"
@@ -46,6 +46,7 @@ from .host import (
46
46
  strict_uri_credentials as strict_uri_credentials,
47
47
  strict_uri_query as strict_uri_query,
48
48
  uri_host as uri_host,
49
+ uri_hostname as uri_hostname,
49
50
  winrm_providers as winrm_providers,
50
51
  QemuConfig as QemuConfig,
51
52
  QemuHost as QemuHost,
@@ -125,6 +126,7 @@ __all__ = [
125
126
  "strict_uri_credentials",
126
127
  "strict_uri_query",
127
128
  "uri_host",
129
+ "uri_hostname",
128
130
  "winrm_providers",
129
131
  "SshConfig",
130
132
  "WinRMConfig",
@@ -10,6 +10,7 @@ from ._common import (
10
10
  strict_uri_credentials as strict_uri_credentials,
11
11
  strict_uri_query as strict_uri_query,
12
12
  uri_host as uri_host,
13
+ uri_hostname as uri_hostname,
13
14
  )
14
15
  from ._ssh import ssh_providers as ssh_providers
15
16
  from ._winrm import winrm_providers as winrm_providers
@@ -160,9 +160,37 @@ def _reject_authority_control_characters(parsed: _SplitResult) -> None:
160
160
  )
161
161
 
162
162
 
163
+ def uri_hostname(parsed: _SplitResult) -> str:
164
+ """The host as it was written, not as `urlsplit` case-folded it.
165
+
166
+ `urlsplit().hostname` is lowercased by design -- DNS is case-insensitive,
167
+ so folding is right for *resolution*. It is wrong for text a caller will
168
+ see again. A config built from a URI stores this value and renders it back
169
+ through `connection_uri`, so reading `.hostname` there would make the
170
+ library echo a spelling the operator never typed, and `nasA` would reach
171
+ logs and `HostInfo` as `nasa`.
172
+
173
+ Use this in `_from_parsed_uri` wherever a host is stored. Presence checks
174
+ (`if not parsed.hostname`) can keep using `.hostname` -- emptiness does not
175
+ depend on case. Resolution is unaffected either way: DNS, SSH, and WinRM
176
+ all treat the two spellings as one name.
177
+
178
+ Returns `""` for a URI with no host, matching `hostname or ""`.
179
+
180
+ `hostname` is a case-folded substring of the authority (after any
181
+ userinfo), so locating it recovers the original text.
182
+ """
183
+ hostname = parsed.hostname or ""
184
+ authority = parsed.netloc.rpartition("@")[2]
185
+ index = authority.casefold().find(hostname)
186
+ if index < 0:
187
+ return hostname
188
+ return authority[index : index + len(hostname)]
189
+
190
+
163
191
  def _rebuild_authority(parsed: _SplitResult, password: _ty.Optional[str]) -> str:
164
192
  """Rebuild a URI authority with `password` in place of the original."""
165
- host = uri_host(parsed.hostname or "")
193
+ host = uri_host(uri_hostname(parsed))
166
194
  if parsed.port is not None:
167
195
  host = f"{host}:{parsed.port}"
168
196
  if not parsed.username:
@@ -40,6 +40,7 @@ from ._common import (
40
40
  strict_uri_credentials,
41
41
  strict_uri_query,
42
42
  uri_host,
43
+ uri_hostname,
43
44
  reject_stdin_conflict,
44
45
  )
45
46
  from ..shell import (
@@ -147,7 +148,7 @@ class SshConfig(HostConfig, schemes=("ssh",)):
147
148
  if not parsed.hostname or parsed.path not in ("", "/"):
148
149
  raise ValueError("SSH URI requires a host and no path")
149
150
  return cls(
150
- host=parsed.hostname,
151
+ host=uri_hostname(parsed),
151
152
  port=parsed.port or 22,
152
153
  username=unquote(parsed.username or "") or "root",
153
154
  password=typing.cast(typing.Optional[str], credentials.get("password")),
@@ -50,6 +50,7 @@ from ._common import (
50
50
  strict_uri_credentials,
51
51
  strict_uri_query,
52
52
  uri_host,
53
+ uri_hostname,
53
54
  )
54
55
  from ..shell import POWERSHELL, ShellFlavour
55
56
 
@@ -155,7 +156,7 @@ class WinRMConfig(HostConfig, schemes=("winrm", "winrms")):
155
156
  if not parsed.username:
156
157
  raise ValueError("WinRM URI requires a username")
157
158
  return cls(
158
- host=parsed.hostname,
159
+ host=uri_hostname(parsed),
159
160
  username=unquote(parsed.username),
160
161
  password=typing.cast(typing.Optional[str], credentials.get("password")),
161
162
  port=parsed.port,
@@ -48,6 +48,7 @@ from ._common import (
48
48
  strict_uri_credentials,
49
49
  strict_uri_query,
50
50
  uri_host,
51
+ uri_hostname,
51
52
  )
52
53
  from ._ssh import SshConfig, _SshTransport
53
54
 
@@ -222,7 +223,7 @@ class QemuConfig(HostConfig, schemes=("qemu+libvirt", "qga+unix", "qga+ssh")):
222
223
  if not parsed.hostname or not parsed.username or not parsed.path.strip("/"):
223
224
  raise ValueError("SSH QGA URI requires user, host, and domain")
224
225
  ssh = SshConfig(
225
- parsed.hostname,
226
+ uri_hostname(parsed),
226
227
  port=parsed.port or 22,
227
228
  username=unquote(parsed.username),
228
229
  password=typing.cast(typing.Optional[str], credentials.get("password")),
@@ -386,7 +386,7 @@ class Shell(Executor[_Result], typing.Generic[_Result]):
386
386
  provides on its own -- a login profile, rc files, the service
387
387
  environment -- because nothing is sent to override it. Requesting a
388
388
  genuinely empty environment is a separate feature that does not exist
389
- yet (see `.agents/plans/env_clear_semantics.md`).
389
+ yet.
390
390
  """
391
391
  if env is None:
392
392
  return None
@@ -136,7 +136,13 @@ def test_provider_spawn_runs_a_real_process(provider):
136
136
  # closed, which is exactly what the contract promises.
137
137
  assert process.returncode is None
138
138
  else:
139
- assert process.wait(timeout=10) == 0
139
+ # Generous because this waits on a real interpreter starting
140
+ # cold: `powershell.exe -NoProfile` on a Windows CI runner has
141
+ # taken over 10s. The timeout is a hang guard, not a
142
+ # performance assertion -- a working adapter exits in well
143
+ # under a second, so a larger bound costs nothing and a
144
+ # smaller one fails on runner startup rather than on a defect.
145
+ assert process.wait(timeout=60) == 0
140
146
  assert process.returncode == 0
141
147
  finally:
142
148
  process.close()
@@ -1,4 +1,4 @@
1
- """Provider/pin retention across every path derivation (plan Phase 3).
1
+ """Provider/pin retention across every path derivation.
2
2
 
3
3
  ``pathlib.PurePath`` builds several derivations with ``object.__new__``, which
4
4
  bypasses the composite constructor entirely. Any derivation that loses the
@@ -20,6 +20,7 @@ from hostctl import (
20
20
  WinRMConfig,
21
21
  parse_credentials,
22
22
  redact_uri,
23
+ uri_hostname,
23
24
  )
24
25
  from hostctl.host._common import _encode_stripped_characters
25
26
 
@@ -248,6 +249,44 @@ def test_redact_uri_strips_the_password_leaving_a_reusable_uri():
248
249
  assert HostConfig(redacted).username == "admin"
249
250
 
250
251
 
252
+ def test_redact_uri_preserves_hostname_case():
253
+ # Redaction removes a credential; it must not also normalize the host.
254
+ # The branch that rebuilds the authority used to adopt urlsplit's
255
+ # case-folded hostname, so one host rendered two ways depending on whether
256
+ # a password was supplied -- which breaks log correlation by the name the
257
+ # operator typed.
258
+ assert redact_uri("wss://root:pw@nasA:8443/api") == "wss://root@nasA:8443/api"
259
+ assert redact_uri("wss://root@nasA") == "wss://root@nasA"
260
+ assert redact_uri("wss://nasA") == "wss://nasA"
261
+
262
+
263
+ def test_config_from_uri_keeps_the_hostname_as_written():
264
+ # A config renders its host back out through connection_uri, so storing
265
+ # urlsplit's folded `hostname` would echo a spelling nobody typed.
266
+ config = HostConfig("ssh://admin@nasA.example.com")
267
+
268
+ assert config.host == "nasA.example.com"
269
+ assert "nasA.example.com" in config.connection_uri
270
+ # ...on the credentialed path too, which rebuilds the authority.
271
+ assert HostConfig("ssh://admin:pw@nasA.example.com").host == "nasA.example.com"
272
+
273
+
274
+ def test_config_host_case_is_preserved_not_canonicalized():
275
+ # The deliberate trade-off: `host` is the spelling the operator typed, not
276
+ # a canonical form, so two spellings of one machine are not equal configs.
277
+ # Resolution is unaffected -- DNS, SSH, and WinRM treat them as one name.
278
+ assert HostConfig("ssh://nasA").host != HostConfig("ssh://nasa").host
279
+
280
+
281
+ def test_uri_hostname_recovers_the_written_spelling():
282
+ assert (
283
+ uri_hostname(urlsplit("ssh://user@NasA.Example.COM:22")) == "NasA.Example.COM"
284
+ )
285
+ # No host, and an IPv6 literal (returned unbracketed, as .hostname is).
286
+ assert uri_hostname(urlsplit("local:")) == ""
287
+ assert uri_hostname(urlsplit("ssh://u:p@[2001:DB8::1]:22")) == "2001:DB8::1"
288
+
289
+
251
290
  def test_redact_uri_output_carries_no_credential_back_in():
252
291
  config = HostConfig(redact_uri("ssh://admin:hunter2@nas.example.com"))
253
292
 
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