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.
- {hostctl-0.1.0 → hostctl-0.1.2}/CHANGELOG.md +44 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/PKG-INFO +1 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/api/reference.md +1 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/transfer.md +4 -7
- {hostctl-0.1.0 → hostctl-0.1.2}/pyproject.toml +1 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/__init__.py +2 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/__init__.py +1 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/_common.py +29 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/_ssh.py +2 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/_winrm.py +2 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/qemu.py +2 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/_common.py +1 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_process_contract.py +7 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_composite_path_propagation.py +1 -1
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_api.py +39 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/.gitignore +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/LICENSE +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/README.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/changelog.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/cli.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/contracts.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/extending.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/path.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/providers.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/guide/run.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/docs/index.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/examples/application_provider.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/examples/local_run.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/examples/remote_run.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/mkdocs.yml +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/AGENTS.md +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/__main__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/_async.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/_cli.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/composite_path.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/container.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/host/system.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/container.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/py.typed +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/src/hostctl/sync.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/providers.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_live.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_run_contract.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/conformance/test_sync_contract.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_application_provider.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_cli.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_container_path.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_container_process.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_container.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_local.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_qemu.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_remote.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_host_winrm.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_observability.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_process.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_psrp.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qemu_path.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_qga_transport.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_serial_executor.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_serial_host.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_serial_live.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_shell.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_ssh_process.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.1.0 → hostctl-0.1.2}/tests/test_system_hosts.py +0 -0
- {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.
|
|
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.
|
|
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/
|
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|