Skip to content

Quote scp paths in salt-ssh to support paths with spaces - #70085

Open
twangboy wants to merge 5 commits into
saltstack:3008.xfrom
twangboy:fix/61338/3008.x
Open

Quote scp paths in salt-ssh to support paths with spaces#70085
twangboy wants to merge 5 commits into
saltstack:3008.xfrom
twangboy:fix/61338/3008.x

Conversation

@twangboy

Copy link
Copy Markdown
Contributor

What does this PR do?

Shell.send() built the scp command as an unquoted f-string, so any space in the local or remote path (e.g. a master cachedir/root_dir containing a space) caused shlex.split() to break it into bogus extra arguments and fail the transfer. This affected every send() caller: thin/relenv tarball deploy, ext_mods tarball, pre-flight script, shim transfer, and the relenv minion-config transfer.

Quote local and host:remote with shlex.quote() in Shell.send(), and apply the same treatment to the related unquoted "test -d"/"test -f" remote checks in Single.check_thin_dir() and Single.cmd_block().

Add a unit test covering the command string built by Shell.send(), and an integration test that overrides a roster's thin_dir with a space-containing path and drives a real salt-ssh deploy against it.

What issues does this PR fix or reference?

Fixes #61338

Merge requirements satisfied?

[NOTICE] Bug fixes or features added to Salt require tests.

Commits signed with GPG?

Yes

@twangboy twangboy added this to the Argon v3008.3 milestone Aug 18, 2026
@twangboy twangboy self-assigned this Aug 18, 2026
@twangboy
twangboy requested a review from a team as a code owner August 18, 2026 20:51
@twangboy twangboy added the test:full Run the full test suite label Aug 18, 2026
Shell.send() built the scp command as an unquoted f-string, so any
space in the local or remote path (e.g. a master cachedir/root_dir
containing a space) caused shlex.split() to break it into bogus
extra arguments and fail the transfer. This affected every send()
caller: thin/relenv tarball deploy, ext_mods tarball, pre-flight
script, shim transfer, and the relenv minion-config transfer.

Quote local and host:remote with shlex.quote() in Shell.send(), and
apply the same treatment to the related unquoted "test -d"/"test -f"
remote checks in Single.check_thin_dir() and Single.cmd_block().

Add a unit test covering the command string built by Shell.send(),
and an integration test that overrides a roster's thin_dir with a
space-containing path and drives a real salt-ssh deploy against it.

Fixes saltstack#61338
salt.client.ssh.Single.__init__ set
self.minion_opts["__master_opts__"] = self.context["master_opts"] for
relenv targets, embedding the master's entire own config (395 keys in a
typical test master) into the minion config file shipped to and read by
salt-call on the remote target.

__master_opts__ is a master-side-only convention: every other reader of
it (salt/client/ssh/wrapper/cmdmod.py, cp.py, publish.py,
salt/client/ssh/state.py, salt/roster/__init__.py) pulls it from the
Python wrapper opts dict while running on the master; nothing on the
remote target ever reads it back out of its own minion config.

self.context["master_opts"] is an alias for the master's own opts,
which gets mutated as nested Single/wrapper calls restore/adjust the
master cachedir (saltstack#69605, saltstack#68458). Embedding it in the relenv minion
config meant that config grew, unbounded, with every nested Single
created during a single state run, until it exceeded the kernel's
ARG_MAX and the ssh invocation failed with "Argument list too long".

Fixes saltstack#70186.
Single.deploy() and deploy_ext() discarded Shell.send()'s return value
and always reported success, even when the scp transfer failed. A
failed transfer was invisible until the shim's next request for a
redeploy, and cmd_block()'s retry after that redeploy request called
itself again with no guard, recursing indefinitely: each cycle
re-deployed, re-ran the shim, failed again, and recursed again.

What looked like salt-ssh hanging for >90s (see saltstack#70204) was actually
dozens of these deploy/shim cycles running back to back until the
test timeout tripped.

deploy()/deploy_ext() now check Shell.send()'s retcode and report
failure instead of swallowing it, and cmd_block() bails out with an
error on a second consecutive deploy failure instead of recursing
into a third attempt.

Fixes saltstack#70204.
test_thin_dir_with_space put the space in the *remote* thin_dir, which
is a strictly harder property than saltstack#61338 ever asked for: the reporter's
space was in the master's root_dir (a local path), with a space-free
default remote thin_dir. Remote-path spaces can't work through OpenSSH's
legacy (pre-9.0) scp/rcp protocol, which hands the remote path to a
remote shell that word-splits it -- that's a separate, unsupported case,
not this fix.

What actually happened when that test ran on OpenSSH < 9.0 targets
wasn't a hang either: each scp returned in under a second, but the
transfer silently failed to land in the space-containing remote
directory, and salt-ssh's deploy-retry loop spun 40+ times before
pytest's 90s timeout cut it off (now fixed separately). The
'flaky(max_runs=4)' marking added on top of it didn't help, since the
failure was deterministic, not intermittent, on every affected distro.

Replace it with test_send_local_path_with_space, which drives a real
scp transfer through Shell.send() with a space in the *local* path --
the actual saltstack#61338 regression -- against the sshd already used by this
module, rather than mocking _run_cmd() like the existing unit test.
This exercises the real _cmd_str()/_split_cmd()/scp path that the
staging-fix regression (since reverted) slipped through.

Also tighten the existing unit test's assertion to check the exact
trailing scp arguments, so a regression that adds bogus arguments (as
the reverted staging fix did) fails loudly instead of passing on an
'in split_cmd' membership check.
The previous CI run (attempt 2) hit a GitHub Actions startup_failure
before any jobs ran. Attempt 1 of that same run had already completed
successfully for this PR's purposes: all 4 previously-failing jobs
(Photon OS 4 Arm64/fips, Ubuntu 22.04 Arm64 tcp/zeromq) passed, and the
5 jobs the reverted staging fix had broken (Photon OS 5 x4, Debian 12
Arm64) stayed green. The only failures were pre-existing, unrelated
flakiness (a dead upstream apt repo on EOL Debian 11, and the known
event-listener memory-leak test flake). Re-triggering to get a clean
recorded run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:full Run the full test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant