sdk run: quote argv elements instead of joining them raw - #201
sdk run: quote argv elements instead of joining them raw#201mobileoverlord wants to merge 4 commits into
Conversation
`sdk run` spliced the user's argv into a shell script inside the container
with a bare `cmd.join(" ")`, so the container shell re-split it. This:
avocado sdk run -- bash -lc 'U=/opt/x; ls $U'
reached the container as:
bash -lc U=/opt/x; ls $U
which the shell reads as two commands: a throwaway `bash -lc U=/opt/x`,
then `ls $U` with `$U` expanded by the *outer* shell to nothing -- i.e.
`ls`. It silently listed the wrong directory instead of failing, which
makes it an actively misleading debugging tool.
Quote each element with the existing utils::runs_on::shell_escape (now
pub(crate)). Extract join_argv so the behavior is unit-testable without a
container.
There was a problem hiding this comment.
Pull request overview
This PR fixes avocado sdk run incorrectly re-splitting user-provided argv inside the container by quoting each argv element before splicing it into the container’s bash -c script, aligning behavior with typical docker run / kubectl exec argv semantics.
Changes:
- Promotes
utils::runs_on::shell_escapetopub(crate)so it can be reused for argv-safe quoting. - Adds
join_argvto shell-escape each argv element (instead ofjoin(" ")) when building the container command string. - Adds unit tests covering argv-boundary preservation and embedded single-quote escaping.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/utils/runs_on.rs | Makes shell_escape available within the crate to support safe argv quoting. |
| src/commands/sdk/run.rs | Uses per-argv-element shell escaping via join_argv and adds targeted unit tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
jetm
left a comment
There was a problem hiding this comment.
The fix is right and I verified the premise rather than taking it on faith: RunConfig.command really is spliced into bash -c at four sites in src/utils/container.rs (944/1153/1543/1841), and the --runs-on path does not double-escape, because build_docker_command already wraps the whole script in shell_escape. Both new tests assert the exact escaped strings rather than truthiness, and main.rs:3404 maps an empty argv to None, so join_argv never sees an empty slice. cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings and cargo test --lib join_argv all pass at e7806cb.
One thing I would add before merging.
This is a user-visible behavior change with no CHANGELOG entry. Anyone who was single-quoting a $VAR or a glob into sdk run and relying on the container shell to expand it loses that silently. I confirmed both halves in bash: bash -c "'bash' '-lc' 'X=1; echo $X'" now prints 1 (the fix), and bash -c "'echo' '\$HOME'" prints a literal $HOME (the loss). The repo keeps a populated Keep-a-Changelog Unreleased block, so there is a natural home for it — I would file it under Fixed with a line about the expansion change, since the people affected will not find it in the diff.
Three nits, all optional:
src/commands/sdk/run.rs:275— stale error text inside the function you touched: "You must either provide a --command (-c)".avocado sdk run --helpshows no such flag; the command is a trailing positional and-cis unassigned (-Cis--config). I checked withcargo run -- sdk run --help.src/commands/sdk/run.rs:22— layering: a generic shell-quoting helper now comes fromutils::runs_on, the remote-execution module, into a local non-remote path, and via an inlinecrate::utils::runs_on::shell_escape(..)rather than ause.utils::shellwould be the honest home.- The same untrusted-argv-into-
bash -csplice is still raw insdk/dnf.rs:115,ext/dnf.rs:358andruntime/dnf.rs:303(self.command.join(" ")). Out of scope here, but the class of bug is not closed by this PR.
…ll_escape - CHANGELOG entry under Fixed. The people this affects are the ones who were relying on the container shell to expand a $VAR or a glob out of a single argument, and that loss is invisible in the diff, so it says so explicitly and names the `bash -lc` form to move to. - The "You must either provide a --command (-c)" error names a flag that does not exist: the command is a trailing positional, and -c is unassigned (-C is --config). Reworded to "a command to run", and the test asserting on that string follows it. - shell_escape moves from utils::runs_on to a new utils::shell. A generic quoting helper living in the remote-execution module meant a local, non-remote path had to reach into runs_on for it. Its four tests move with it, and the import is a `use` rather than an inline path now.
|
All three taken, plus the fourth as a filed follow-up. Pushed in c785a69. CHANGELOG. Added under Stale flag text. Fixed. Worth noting it was wrong in a second way beyond the flag not existing: the message told you to provide Layering. Agreed, and done — The dnf sites. Confirmed all three are the identical
|
jetm
left a comment
There was a problem hiding this comment.
Re-reviewed the increment since e7806cb - the changelog/flag-text/shell_escape commit answering my last pass. The shell_escape move is byte-identical and every call site still resolves; the reworded flag text checks out against the actual flags. One blocking finding inline, on where the changelog files this change rather than on the code.
Six advisory notes were withheld rather than appended here, so the blocking one stays readable.
The entry described a user-visible CLI break that fails silently at runtime, but sat under Fixed, while Breaking held only a change scoped to consumers of the lib target. A maintainer bumping a pinned CLI in CI and reading Breaking for upgrade impact would have concluded lib-only and shipped. Moves the sdk run entry into Breaking, ahead of the lib-target one so the section reads worst-blast-radius first, and leads on the expansion loss rather than the bug, with the fix as the second half. Adds the bit that was missing either way: this break produces no error and no warning, so CI will not catch it.
jetm
left a comment
There was a problem hiding this comment.
Re-reviewed the increment since c785a693 - one commit, 4d54dfa, CHANGELOG only. No source file moved, so the code carries over from the last pass.
Reclassifying this as Breaking is right, and the reason given is the one that matters: the old invocations do not fail, they run something else. A silent behaviour change is worse for an upgrader than a loud one, and Breaking is where they look.
Checked each claim in the new text against the code rather than against the old entry. The base had cmd.join(" ") at src/commands/sdk/run.rs:315; head routes through join_argv, which maps shell_escape over every element, and shell_escape wraps in single quotes with '\'' for embedded quotes. So a $HOME, a && chain and a quoted glob all arrive as one literal word, which is what the entry says. The bash -lc 'ls /foo && ls /bar' recommendation holds too - under the old join that became bash -lc ls /foo && ls /bar, so "only works correctly after this change" is accurate rather than promotional.
Nothing was lost moving it. Every example, the U=/opt/x walkthrough and the docker run / kubectl exec comparison all survive in the new shape, and the section order under Unreleased reads Added / Changed / Breaking / Fixed with no duplicated heading. CI is green on 4d54dfa including the test suite.
No findings.
jetm
left a comment
There was a problem hiding this comment.
Re-review after the head moved. Read the diff and every enclosing function, traced RunConfig.command through all four full_command sinks, ran the escape/sdk::run test suites and clippy, and reproduced the CHANGELOG's own worked example against both code paths. Two findings on the CHANGELOG entry itself, inline below - the code change is sound (escaping is complete POSIX single-quoting, verified against 16 payloads and 6 injection attempts, remote double-escape composes correctly).
| is skipped with nothing to say rather than asserting a version it never read. | ||
|
|
||
| ### Breaking | ||
| - **`sdk run` passes its argv through literally; the container shell no longer |
There was a problem hiding this comment.
Published HIL doc teaches the pattern this breaks. docs/src/docs-guides/hardware-in-the-loop.md:140-146 (peridio/docs) instructs avocado sdk run cd /opt/_avocado \&\& mkdir -p ./qemux86-64/extensions/my-app/usr \&\& echo "hello from host" \> ./qemux86-64/extensions/my-app/usr/hello.txt, followed by an "Escaping SDK run commands" callout stating that && and > are escaped precisely so they "reach the container's shell unchanged." That is exactly the contract this PR retires, and the vendor's own guide isn't in the audit list this entry asks users to check.
Replayed the documented argv against both code paths:
- Before:
cd <dir> && mkdir -p ./ext/usr && echo hello from host > ./ext/usr/hello.txt-> exit 0, file created. - After: each argv element quoted separately ->
bash: line 1: cd: too many arguments, exit 2, nothing created.
The echo/redirect half in isolation is worse: it exits 0, prints hello from host > ./out.txt to stdout, and writes nothing - so someone following this guide sees a success and a missing file. Worth a companion docs fix landing with this change, not after.
| run -- bash -lc 'ls /foo && ls /bar'` — which is the form the flags already | ||
| implied and which only works correctly after this change. | ||
|
|
||
| This one breaks silently at runtime: there is no error and no warning, so a |
There was a problem hiding this comment.
"no error, no warning" doesn't hold for && chains - and the entry's own first example two sentences up falsifies it.
$ bash -c "'ls /foo && ls /bar'"
bash: line 1: ls /foo && ls /bar: No such file or directory
That's the exact avocado sdk run -- 'ls /foo && ls /bar' example from line 58 above, reproduced as what join_argv actually sends the container shell (a single element, shell_escaped into one quoted word since it's already one argv element). It exits 127 with a clear error, not silently. The cd recipe in the first comment on this PR exits 2, also loudly.
Only the expansion cases (echo '$HOME') are genuinely silent - the &&/> chain cases fail loudly. Recommend splitting the claim: expansion breaks silently, but a &&/|/>/; chain passed as one argument now hard-fails at runtime, which is actually the easier case to catch in CI, not a risk to bury under "no error, no warning."
Separately, the audit list ($VAR, a glob, a && chain) omits the assignment-prefix form, which bash only honors on a fully unquoted word: avocado sdk run -- CC=clang make now breaks silently (the one real case) while matching none of the three things this paragraph tells the reader to grep for.
What
sdk runsplices the user's argv into a shell script inside the container using a barecmd.join(" "), so the container shell re-splits it. This:arrives in the container as:
The shell reads that as two commands — a throwaway
bash -lc U=/opt/x, thenls $Uwhere$Uis expanded by the outer shell to nothing, i.e. plainls.It does not error. It prints plausible output for a different directory. I hit this while debugging an SDK sysroot and it produced two confidently wrong readings before I noticed — a debugging tool that lies is worse than one that fails.
Fix
Quote each argv element with the existing
utils::runs_on::shell_escape(promoted topub(crate)— no new helper). Extractedjoin_argvso this is unit-testable without spinning a container.Behavior change
Argv is now treated as argv, matching
docker run/kubectl execconvention. Anyone currently passing a single string of shell code:now gets that string as one command word, and should use the explicit form:
which is what the flag combination already implies, and which only works correctly after this fix. Flagging it since it is user-visible.
Tests
Two unit tests in
src/commands/sdk/run.rs— argv-boundary preservation (the exactbash -lc 'X=1; echo $X'case) and embedded-single-quote escaping.cargo test --bin avocado sdk::run— 11 passed. fmt and clippy clean.Separate from #200; no overlap.