Skip to content

Commit f78c332

Browse files
committed
bug: better errors handling
1 parent 8c473ae commit f78c332

3 files changed

Lines changed: 212 additions & 6 deletions

File tree

pkg/config/config_helpers.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,11 @@ func runCommandImpl(ctx context.Context, pctx *ParsingContext, l log.Logger, arg
536536
}
537537
}
538538

539+
// Re-check after option-stripping; otherwise args[0] / args[1:] below panics.
540+
if len(args) == 0 {
541+
return "", errors.New(EmptyStringNotAllowedError("command parameter to the run_cmd function (only option flags were supplied)"))
542+
}
543+
539544
// To avoid re-run of the same run_cmd command, is used in memory cache for command results, with caching key path + arguments
540545
// see: https://github.com/gruntwork-io/terragrunt/issues/1427
541546
cacheKey := fmt.Sprintf("%v-%v", cachePath, args)
@@ -1440,8 +1445,8 @@ func StartsWith(ctx context.Context, pctx *ParsingContext, args []string) (bool,
14401445
var result bool
14411446

14421447
err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_startswith", attrs, func(childCtx context.Context) error {
1443-
if len(args) == 0 {
1444-
return errors.New(EmptyStringNotAllowedError("parameter to the startswith function"))
1448+
if len(args) != matchedPats {
1449+
return errors.New(WrongNumberOfParamsError{Func: FuncNameStartsWith, Expected: "2", Actual: len(args)})
14451450
}
14461451

14471452
str := args[0]
@@ -1474,8 +1479,8 @@ func EndsWith(ctx context.Context, pctx *ParsingContext, args []string) (bool, e
14741479
var result bool
14751480

14761481
err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_endswith", attrs, func(childCtx context.Context) error {
1477-
if len(args) == 0 {
1478-
return errors.New(EmptyStringNotAllowedError("parameter to the endswith function"))
1482+
if len(args) != matchedPats {
1483+
return errors.New(WrongNumberOfParamsError{Func: FuncNameEndsWith, Expected: "2", Actual: len(args)})
14791484
}
14801485

14811486
str := args[0]
@@ -1555,8 +1560,8 @@ func StrContains(ctx context.Context, pctx *ParsingContext, args []string) (bool
15551560
var result bool
15561561

15571562
err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_strcontains", attrs, func(childCtx context.Context) error {
1558-
if len(args) == 0 {
1559-
return errors.New(EmptyStringNotAllowedError("parameter to the strcontains function"))
1563+
if len(args) != matchedPats {
1564+
return errors.New(WrongNumberOfParamsError{Func: FuncNameStrContains, Expected: "2", Actual: len(args)})
15601565
}
15611566

15621567
str := args[0]

pkg/config/config_helpers_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,3 +1401,127 @@ func TestConstraintCheck(t *testing.T) {
14011401
})
14021402
}
14031403
}
1404+
1405+
// TestStartsWithArityRegression: startswith with wrong arity must return WrongNumberOfParamsError, not panic.
1406+
func TestStartsWithArityRegression(t *testing.T) {
1407+
t.Parallel()
1408+
1409+
testCases := []struct {
1410+
name string
1411+
args []string
1412+
}{
1413+
{name: "no args", args: []string{}},
1414+
{name: "one arg (the bug trigger)", args: []string{"foo"}},
1415+
{name: "three args", args: []string{"foo", "bar", "baz"}},
1416+
}
1417+
1418+
for _, tc := range testCases {
1419+
t.Run(tc.name, func(t *testing.T) {
1420+
t.Parallel()
1421+
1422+
ctx, pctx := newTestParsingContext(t, "")
1423+
1424+
require.NotPanics(t, func() {
1425+
_, err := config.StartsWith(ctx, pctx, tc.args)
1426+
require.Error(t, err, "must return error for wrong arity (%d args)", len(tc.args))
1427+
require.True(t, assertErrorType(t, config.WrongNumberOfParamsError{}, err),
1428+
"expected WrongNumberOfParamsError, got %T: %v", err, err)
1429+
}, "startswith with %d args must not panic", len(tc.args))
1430+
})
1431+
}
1432+
}
1433+
1434+
// TestEndsWithArityRegression: endswith with wrong arity must return WrongNumberOfParamsError, not panic.
1435+
func TestEndsWithArityRegression(t *testing.T) {
1436+
t.Parallel()
1437+
1438+
testCases := []struct {
1439+
name string
1440+
args []string
1441+
}{
1442+
{name: "no args", args: []string{}},
1443+
{name: "one arg (the bug trigger)", args: []string{"foo"}},
1444+
{name: "three args", args: []string{"foo", "bar", "baz"}},
1445+
}
1446+
1447+
for _, tc := range testCases {
1448+
t.Run(tc.name, func(t *testing.T) {
1449+
t.Parallel()
1450+
1451+
ctx, pctx := newTestParsingContext(t, "")
1452+
1453+
require.NotPanics(t, func() {
1454+
_, err := config.EndsWith(ctx, pctx, tc.args)
1455+
require.Error(t, err, "must return error for wrong arity (%d args)", len(tc.args))
1456+
require.True(t, assertErrorType(t, config.WrongNumberOfParamsError{}, err),
1457+
"expected WrongNumberOfParamsError, got %T: %v", err, err)
1458+
}, "endswith with %d args must not panic", len(tc.args))
1459+
})
1460+
}
1461+
}
1462+
1463+
// TestStrContainsArityRegression: strcontains with wrong arity must return WrongNumberOfParamsError, not panic.
1464+
func TestStrContainsArityRegression(t *testing.T) {
1465+
t.Parallel()
1466+
1467+
testCases := []struct {
1468+
name string
1469+
args []string
1470+
}{
1471+
{name: "no args", args: []string{}},
1472+
{name: "one arg (the bug trigger)", args: []string{"hello"}},
1473+
{name: "three args", args: []string{"hello", "world", "extra"}},
1474+
}
1475+
1476+
for _, tc := range testCases {
1477+
t.Run(tc.name, func(t *testing.T) {
1478+
t.Parallel()
1479+
1480+
ctx, pctx := newTestParsingContext(t, "")
1481+
1482+
require.NotPanics(t, func() {
1483+
_, err := config.StrContains(ctx, pctx, tc.args)
1484+
require.Error(t, err, "must return error for wrong arity (%d args)", len(tc.args))
1485+
require.True(t, assertErrorType(t, config.WrongNumberOfParamsError{}, err),
1486+
"expected WrongNumberOfParamsError, got %T: %v", err, err)
1487+
}, "strcontains with %d args must not panic", len(tc.args))
1488+
})
1489+
}
1490+
}
1491+
1492+
// TestRunCommandOptionsOnlyArityRegression: run_cmd with only option flags must return EmptyStringNotAllowedError, not panic.
1493+
func TestRunCommandOptionsOnlyArityRegression(t *testing.T) {
1494+
t.Parallel()
1495+
1496+
if runtime.GOOS == "windows" {
1497+
t.Skip("Skipping test on Windows because it doesn't support bash")
1498+
}
1499+
1500+
testCases := []struct {
1501+
name string
1502+
params []string
1503+
}{
1504+
{name: "single quiet flag", params: []string{"--terragrunt-quiet"}},
1505+
{name: "single no-cache flag", params: []string{"--terragrunt-no-cache"}},
1506+
{name: "single global-cache flag", params: []string{"--terragrunt-global-cache"}},
1507+
{name: "two compatible flags", params: []string{"--terragrunt-quiet", "--terragrunt-no-cache"}},
1508+
{name: "two compatible flags reversed", params: []string{"--terragrunt-no-cache", "--terragrunt-quiet"}},
1509+
{name: "duplicate quiet", params: []string{"--terragrunt-quiet", "--terragrunt-quiet"}},
1510+
}
1511+
1512+
for _, tc := range testCases {
1513+
t.Run(tc.name, func(t *testing.T) {
1514+
t.Parallel()
1515+
1516+
l := logger.CreateLogger()
1517+
ctx, pctx := newTestParsingContext(t, "")
1518+
1519+
require.NotPanics(t, func() {
1520+
_, err := config.RunCommand(ctx, pctx, l, tc.params)
1521+
require.Error(t, err, "must return error when only option flags are supplied (%v)", tc.params)
1522+
require.True(t, assertErrorType(t, config.EmptyStringNotAllowedError(""), err),
1523+
"expected EmptyStringNotAllowedError, got %T: %v", err, err)
1524+
}, "run_cmd with options-only %v must not panic", tc.params)
1525+
})
1526+
}
1527+
}

pkg/config/fuzz_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package config_test
2+
3+
import (
4+
"runtime"
5+
"strings"
6+
"testing"
7+
8+
"github.com/gruntwork-io/terragrunt/pkg/config"
9+
"github.com/gruntwork-io/terragrunt/test/helpers/logger"
10+
)
11+
12+
// FuzzHCLStringHelpers: startswith / endswith / strcontains must never panic on any args shape.
13+
func FuzzHCLStringHelpers(f *testing.F) {
14+
seeds := []string{
15+
"",
16+
"foo",
17+
"foo\x00bar",
18+
"foo\x00bar\x00baz",
19+
"\x00",
20+
"\x00\x00",
21+
"a\x00b\x00c\x00d\x00e",
22+
"hello world\x00world",
23+
"hello world\x00hello",
24+
"hello world\x00wor",
25+
}
26+
for _, s := range seeds {
27+
f.Add(s)
28+
}
29+
30+
f.Fuzz(func(t *testing.T, raw string) {
31+
var args []string
32+
if raw != "" {
33+
args = strings.Split(raw, "\x00")
34+
}
35+
36+
ctx, pctx := newTestParsingContext(t, "")
37+
38+
_, _ = config.StartsWith(ctx, pctx, args)
39+
_, _ = config.EndsWith(ctx, pctx, args)
40+
_, _ = config.StrContains(ctx, pctx, args)
41+
})
42+
}
43+
44+
// FuzzHCLRunCommandOptions: run_cmd must never panic on any mix of option flags and commands.
45+
func FuzzHCLRunCommandOptions(f *testing.F) {
46+
if runtime.GOOS == "windows" {
47+
f.Skip("run_cmd happy-path requires bash; skip on Windows")
48+
}
49+
50+
seeds := []string{
51+
"--terragrunt-quiet",
52+
"--terragrunt-no-cache",
53+
"--terragrunt-global-cache",
54+
"--terragrunt-quiet\x00--terragrunt-no-cache",
55+
"--terragrunt-quiet\x00--terragrunt-quiet",
56+
"--terragrunt-quiet\x00/bin/echo\x00hi",
57+
"/bin/echo\x00hi",
58+
"--terragrunt-no-cache\x00--terragrunt-global-cache",
59+
"--unknown-flag",
60+
"",
61+
}
62+
for _, s := range seeds {
63+
f.Add(s)
64+
}
65+
66+
f.Fuzz(func(t *testing.T, raw string) {
67+
var args []string
68+
if raw != "" {
69+
args = strings.Split(raw, "\x00")
70+
}
71+
72+
l := logger.CreateLogger()
73+
ctx, pctx := newTestParsingContext(t, "")
74+
75+
_, _ = config.RunCommand(ctx, pctx, l, args)
76+
})
77+
}

0 commit comments

Comments
 (0)