fix: prevent completions from mutating os.Args via append side effect#2356
fix: prevent completions from mutating os.Args via append side effect#2356veeceey wants to merge 3 commits intospf13:mainfrom
Conversation
When getCompletions() checks for interspersed flags, it calls append(finalArgs, "--") to temporarily add a "--" sentinel. However, finalArgs is a sub-slice of the original args (ultimately derived from os.Args[1:] or SetArgs), and if the sub-slice has spare capacity in its backing array, append writes "--" into the shared array, corrupting the caller's data. This is particularly visible when TraverseChildren is enabled: the Traverse method returns sub-slices that share the original backing array. A user's ValidArgsFunction inspecting os.Args would then see "--" where a real argument should be. Fix this by using a three-index slice expression (finalArgs[:len:len]) to limit the capacity, forcing append to allocate a new backing array instead of writing into the shared one. Fixes spf13#2257 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Wow that’s interesting! Would it be doable to have a test that actually checks that os.Args is unchanged (but wrongly changed without the fix)? |
Rework TestCompletionDoesNotMutateArgs into TestCompletionDoesNotMutateOsArgs that overrides os.Args and verifies it is unchanged after shell completion, instead of using SetArgs with a crafted slice. This exercises the real os.Args[1:] code path in ExecuteC and makes the regression more obvious.
|
Good call! I reworked the test so it checks The new Verified locally:
|
|
Friendly ping - just checking if the updated test approach looks good. Let me know if you'd like any further changes! |
completions.go
Outdated
| // the extra added -- is counted as arg. | ||
| flagCompletion := true | ||
| _ = finalCmd.ParseFlags(append(finalArgs, "--")) | ||
| _ = finalCmd.ParseFlags(append(finalArgs[:len(finalArgs):len(finalArgs)], "--")) |
There was a problem hiding this comment.
This is about that:
Fixes a bug where getCompletions() accidentally mutates os.Args by writing "--" into the shared backing array via an append with spare capacity
Uses a three-index slice expression (finalArgs[:len:len]) to cap the slice capacity, forcing append to allocate a new array
This sounds complicated
This could be used
argsCopy := make([]string, len(finalArgs))
copy(argsCopy, finalArgs)
_ = finalCmd.ParseFlags(append(argsCopy, "--"))Or this, that's less obvious, but pretty simple to read.
_ = finalCmd.ParseFlags(append([]string{}, finalArgs, "--"))I feel like your solution is the most performant but it sounds a bit cryptic
There was a problem hiding this comment.
Yeah fair point, it is a bit cryptic if you're not used to the three-index slice idiom. The reason I went with finalArgs[:len(finalArgs):len(finalArgs)] is that it caps the capacity to the length, so when append adds "--" it's forced to allocate a new backing array instead of writing into the original one (which is os.Args). The make+copy approach works too but it's an extra allocation + copy of the whole slice when we really just need to prevent the append from mutating the original. The three-index slice is a single expression that does exactly that. That said, if the maintainers find it less readable I'm happy to switch to make+copy -- either way the bug fix is the same.
There was a problem hiding this comment.
@veeceey Agreed on it being a bit too cryptic.
I like @ccoVeille second suggestion, but the syntax is not quite right.
What do you guys think of:
_ = finalCmd.ParseFlags(append(append([]string{}, finalArgs...), "--"))
If you don't like this, the explicit array copy is also fine.
Shell completion needs to be fast but remains a user-facing operation so does not need to be optimized perfectly: copying a two or three element array is fine.
Pease put a comment before it mentioning we first copy the finalArgs slice to a new array to avoid risking modifying os.Args which currently backs finalArgs.
There was a problem hiding this comment.
How about copying the trimmedArgs slice at the beginning of the function getCompletions?
Instead of
trimmedArgs := args[:len(args)-1]use
trimmedArgs := make([]string, len(args)-1)
copy(trimmedArgs, args[:len(args)-1])In the future, we can change to slices.Clone.
completions_test.go
Outdated
| // We do NOT use SetArgs so the code falls through to os.Args[1:]. | ||
| // The program name must not be "cobra.test" to bypass the test guard | ||
| // in ExecuteC(). | ||
| os.Args = []string{"prog", ShellCompNoDescRequestCmd, "x"} |
There was a problem hiding this comment.
Nit: please use "root" instead of "prog" just to be consistent with the name of the root command below. I has no impact but may just slow down someone looking at it.
Copy trimmedArgs at the top of getCompletions instead of using the three-index slice expression at the append site. This is cleaner and prevents any future append from accidentally mutating the caller's backing array. Renamed "prog" to "root" in the test for consistency with the root command variable name.
|
@alexandear good call, that's much cleaner — copying Also renamed "prog" to "root" per @marckhouzam's nit. |
|
@alexandear good idea — using slices.Clone (or the copy approach for now) is much safer since it prevents any future mutation of the original args. I'll update it. Thanks! |
Summary
getCompletions()accidentally mutatesos.Argsby writing"--"into the shared backing array via anappendwith spare capacityfinalArgs[:len:len]) to cap the slice capacity, forcingappendto allocate a new arrayDetails
When shell completions are requested (e.g.,
prog __complete x),getCompletions()calls:finalArgsis a sub-slice of the original args (derived fromos.Args[1:]), and if it has spare capacity in its backing array,appendwrites"--"directly into the shared memory rather than allocating a new slice. This corruptsos.Args, which users may be inspecting in theirValidArgsFunction.The fix uses
finalArgs[:len(finalArgs):len(finalArgs)]to limit the capacity to the length, guaranteeing thatappendallocates a fresh backing array.The bug is most visible with
TraverseChildren: true, sinceTraverse()returns sub-slices that preserve the original backing array's capacity.Fixes #2257
Test plan
TestCompletionDoesNotMutateArgsthat fails without the fix and passes with itgo test ./...)