Skip to content

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 22 Sep 12:13

Added

  • QGA paths have metadata and namespace operations. QemuHost.path()
    now supports stat, iterdir, mkdir, unlink, rename and chmod
    through a guest-side helper built during discovery -- hostctl previously
    shipped none, so a caller had to write one and every QGA path reported
    degraded. The helper is POSITIVELY PROBED: GNU stat -c/find -printf
    on a POSIX guest, PowerShell on a Windows one. A guest that fails the
    probe keeps content access and loses only what genuinely needs a helper.
    QemuConfig(path_helper=...) still overrides.
  • SshConfig(login_shell="auto"|"cmd"|"powershell"|"posix") says what
    parses the command string on arrival. Windows OpenSSH's stock server
    hands it to cmd.exe /c, so a cmd-dialect command line is parsed by cmd
    twice; unescaped, 4 of 8 adversarial values survived and two of the
    failures were command injection. auto reads the dialect.
  • ShellFlavour.argument(value, *, target=ShellTarget...) separates
    quoting for the shell's parser from quoting for whatever reads the token
    next -- a cmd builtin, a child's argv parser, PowerShell's binder, or the
    program slot. quote() remains the syntactic layer.

Removed

  • SerialTransport and its hostctl.executor export. It duplicated
    SerialProcess's locked, deadline-clamping byte path and had no caller
    outside its own tests, so the two could drift with nothing to notice.
    SerialProcess keeps the job -- it is what every console profile reads
    through -- and gained the write(timeout=) it lacked.
  • SerialConfig.username and SerialConfig.password. They were stored,
    whitelisted for URI dispatch, and read by nothing: console credentials live
    in the profile's login= steps. An explicit one is now refused rather than
    silently ignored, and HOSTCTL_PASSWORD is no longer offered to a serial:
    URI.

Changed

  • The pathlib_next floor moves to >=0.9.10 in both dependencies and
    the ssh extra. 0.9.4 through 0.9.9 cannot be used: those releases
    decide "same file" and "overlapping trees" by falling back to path
    equality, which compares (type, segments) and knows nothing about
    hosts, so copying /etc/app.conf from one host to the same path on
    another was refused with OSError [Errno 22] Source and target are the same file (and PathSyncer with "source and target overlap"). 0.9.10
    adds the _same_filesystem() hook this release answers.
  • LocalExecutor merges env over the inherited environment instead of
    replacing it, so the documented cross-transport rule -- "env is additive
    to the provider's environment" -- now holds locally too. A child no longer
    starts without PATH, HOME or SystemRoot because one variable was set.
  • A serial run() frames each command as its own profile exchange and
    refuses a structured (argv) command: a serial host names no shell flavour,
    so it has no quoting rule to apply, and joining commands with ; put two
    on one line where most device consoles read a single malformed one.
    stdin= and bufsize= raise instead of being ignored, as input= already
    did.
  • A serial spawn(encoding=...) reads text, matching the SSH and QEMU
    console adapters; without either text keyword it still reads bytes.
  • QEMU dialect="auto"/path_flavor="auto" need positive evidence of the
    guest's family -- guest-get-osinfo, or a family-exclusive command in
    guest-info -- and raise naming the explicit settings when neither is
    available. An agent with guest-get-osinfo blocklisted used to be read as
    POSIX, silently, including on Windows.
  • QemuHost.info().os_family is a family (linux, windows,
    freebsd), taken from kernel-name. It carried guest-get-osinfo's id,
    so the same machine reported linux over SSH and ubuntu over QGA.
  • Each guest-agent request is bounded by agent_timeout, separately from
    a command's own timeout=: an agent that stops answering is a
    ConnectionError saying so, not a command timeout ten minutes later.
  • A truncated QGA run() result warns. stdout_truncated/
    stderr_truncated were set and documented nowhere, so a shortened
    transcript looked complete.
  • hostctl shell opens a console that cannot allocate a terminal, so a
    serial: URI works; hostctl cp local:/tmp/x is accepted, the
    spelling every other subcommand documents; and hostctl --help carries the
    URI forms, the URI:PATH grammar, the HOSTCTL_PASSWORD rule and the exit
    codes.
  • asyncssh and libvirt-python are bounded (>=2.14,<3, >=9.0,<12);
    the dev extra now pulls every transport, so pip install -e ".[dev]" is
    the whole development environment.
  • LocalHost is a SystemHost. It duplicated the provider selection,
    failover loop, capability computation and dispatch ladder, and the two
    copies diverged. Two consequences for callers:
    LocalHost().path() with no segments no longer returns the absolute
    working directory
    -- it returns a path with no segments added, like
    every other transport, which still resolves against the process's working
    directory. A local path is still a plain pathlib_next path, not a
    composite.

Fixed

  • A transfer between two hosts is no longer mistaken for a file copied
    onto itself.
    CompositePosixPath/CompositeWindowsPath now answer
    pathlib_next's _same_filesystem() with the host's provider set, so
    two hosts are two namespaces even when a path is spelled identically on
    both, while two paths on one host still are one — which keeps the guard
    that refuses copy() of a file onto itself. A .via() pin selects a
    route to the host, not a different host, and answers accordingly.
  • The SFTP leg verifies the server's host key. SshConfig.connect_opts()
    omitted known_hosts whenever it held its () default. asyncssh reads a
    missing key as "resolve known_hosts the usual way", so run() verified the
    server — but pathlib_next's SFTP connect seeds known_hosts=None
    (verification off) for options it is not handed, so path() accepted any
    host key, including an actively substituted one, and sent it the configured
    password. Opting out is still possible and now explicit: known_hosts=None.
  • ConnectionString parses user:password@host. A scheme-shaped username
    made urlsplit read root:hunter2@nas as the scheme root, so the password
    was never recognised: it stayed in the path, where str() and repr()
    rendered it verbatim, the host came out empty, and is_local reported a
    remote machine as local. Without a scheme to assume, a target carrying
    credentials is now refused rather than guessed at, and both error messages
    redact (root:<redacted>@nas).
  • An args-capable executor gets one shell layer, not two. SystemHost
    fed a finished shell command line back into flavour.invocation(), which
    takes a script. On Windows the outer PowerShell re-parsed the inner
    -Command string, so windows://node?executor=local reported 0 for
    cmd /c exit 3 — check=True passed for a failing command — and quoted
    text and variables came back mangled or empty.
  • cmd.exe builtin operands are quoted, and set no longer escapes inside
    quotes.
    cmd splits a builtin's operands on ,, ; and = as well as
    whitespace, so del /q a b.txt deleted a and b.txt and left the named
    file. Separately, set "KEY=VALUE" caret-escaped inside a quoted span,
    where cmd does not process carets: 100% reached the child as 100^%,
    %OS% still expanded, and a " in the value ended the assignment early.
    An empty value remains inexpressible in cmd, and is now documented as such.
  • PowerShell 5 native arguments survive the C runtime. PS 5.1 rebuilds the
    command line for a native program and leaves embedded quotes alone, so
    run(("robocopy", src, dst, name)) with name = 'my file" /MIR "z' handed
    robocopy /MIR — mirror mode, which deletes files in the destination. Empty
    arguments were dropped and a trailing backslash swallowed the next argument.
    PowerShell 7 is unaffected and keeps the plain literal.
  • A transfer to another backend no longer renames inside the source host.
    ssh_host.path('/srv/export.csv').move(LocalPath('/home/op/export.csv'))
    issued an SFTP rename on the server: the file left /srv, never arrived,
    and no error was raised. A str destination — documented by pathlib_next
    — also raised TypeError on every transport, and a cross-provider move()
    aborted instead of falling back to copy + remove. All three now work.
  • Docker file modes are translated. The archive stat header carries Go's
    os.FileMode, used verbatim as a POSIX st_mode: is_file() was False for
    every ordinary file and is_dir() raised OverflowError: mode out of range
    for a directory, which also killed any recursive copy.
  • An unreachable QGA socket is reported as a transport failure. It escaped
    as a bare FileNotFoundError, indistinguishable from the guest file being
    absent — so an append across a guest reboot staged an empty buffer and
    truncated the guest file when the agent came back.
  • Native WinRM reports the remote exit status. The provider did not
    declare manages_status, so PowerShell's ; exit $LASTEXITCODE epilogue was
    appended and its exit ended the remote pipeline before the
    __HOSTCTL_LASTEXITCODE__ marker could be emitted. A command that failed by
    exit status reported 0 and check=True passed.
  • One unsupported operation no longer takes a path provider out of
    service.
    A NotImplementedError from a retry-safe call was recorded as
    a decline on the host's shared provider selector, which every later
    operation then consulted: after a backend refused samefile() — it needs
    st_dev/st_ino, which many remote stats lack — the next read on any
    path of that host failed with no path provider supports open_read. The
    refusal is now scoped to the call that raised it. OperationNotStarted
    still declines the provider for the generation, as before.
  • A cmd.exe at a path containing a space is launchable. The program
    slot of a Windows command line is read by CreateProcess, not by cmd, and
    rendering it with cmd's caret quoting made Windows look for a program named
    ^ -- measured as FileNotFoundError: [WinError 2].
  • A structured PowerShell command can bind a named parameter.
    ["Remove-Item", "-LiteralPath", path] rendered every token as a quoted
    string, which PowerShell's binder reads positionally: measured against a
    real powershell.exe, it returned 1 and left the file in place. A token
    that is exactly a parameter name is now unquoted.
  • ShellSession.send() does not append a separator the text already has,
    so send("echo hi;") is not echo hi;; and send("sleep 5 &") is not
    sleep 5 &;.
  • zsh quoting covers a leading =, which zsh expands to a program path
    where POSIX sh leaves it alone.
  • PowerShellFlavour(7, executable=...) is named pwsh. The name was
    derived only when no executable was given, so an explicit path left the
    flavour identifying as powershell.
  • Shell(..., env={}) forwards no environment, rather than an empty one
    that starts a child with nothing.
  • normalize_input covers bytearray and memoryview -- the deadlock
    it exists to prevent, for the two buffer types it did not check.
  • Container archive reads no longer lie about links, sizes or errors: a
    single-member hardlink archive raises OSError(ENOENT) instead of
    tarfile.StreamError, a hardlinked file in a listing reports its real
    size, the archive root (".") is listable, and every Docker status code
    maps through one helper that populates errno.
  • A container exec stream no longer returns '' mid-character, and a
    closed container process reads EOF instead of raising the operating
    system's raw socket error.
  • QGA failures speak one error vocabulary: a guest error is classified on
    the strerror tail (a file named notfound.log was reported missing
    whatever actually failed), host.run() of a missing guest program raises
    FileNotFoundError, libvirt-relayed guest errors stay guest errors, and a
    guest error no longer tears down and re-handshakes the connection.
  • One transport per QEMU host. Lazy initialisation was unguarded:
    measured with six threads, six transports were built where one was wanted,
    each SSH one opening a connection nobody owned. A path handed out before
    close() now refuses instead of silently reconnecting.
  • A serial login failure raises ConsoleProtocolError, not a bare
    TimeoutError that slipped past every documented handler, and a transcript
    attached to an error has secret=True login values redacted -- consoles
    echo what they are sent.
  • A QGA reader is seekable where the agent advertises guest-file-seek,
    so zipfile and tarfile work against a guest path, and an exclusive
    open("x") is refused at open() rather than after the whole payload has
    been staged.
  • A cmd-dialect command sent over SSH to Windows is escaped for the
    login shell that receives it.
    Two of the eight adversarial values were
    command injection, not corruption.
  • SshConfig("ssh://host") raises instead of making the URI the
    hostname, which surfaced much later as an unresolvable name. The
    constructor takes a host; HostConfig(uri) takes a URI.

Full Changelog: v0.2.7...v0.3.0