From f883f71d9a1c524a7e50313230801f9dcac026e1 Mon Sep 17 00:00:00 2001 From: Mikhail Kuznetsov Date: Fri, 10 Jul 2026 13:26:54 +0400 Subject: [PATCH] fix: validate set and shopt options when reading a Taskfile --- task_test.go | 61 +++++++++++++++ taskfile/ast/cmd.go | 6 ++ taskfile/ast/shellopts.go | 75 +++++++++++++++++++ taskfile/ast/task.go | 6 ++ taskfile/ast/taskfile.go | 6 ++ .../shopts/invalid_command_shopt/Taskfile.yml | 9 +++ .../shopts/invalid_global_shopt/Taskfile.yml | 9 +++ testdata/shopts/invalid_task_set/Taskfile.yml | 9 +++ testdata/shopts/set_via_shopt/Taskfile.yml | 9 +++ website/src/docs/reference/schema.md | 11 ++- website/src/public/schema.json | 9 ++- 11 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 taskfile/ast/shellopts.go create mode 100644 testdata/shopts/invalid_command_shopt/Taskfile.yml create mode 100644 testdata/shopts/invalid_global_shopt/Taskfile.yml create mode 100644 testdata/shopts/invalid_task_set/Taskfile.yml create mode 100644 testdata/shopts/set_via_shopt/Taskfile.yml diff --git a/task_test.go b/task_test.go index dd92f8eef5..d3362af375 100644 --- a/task_test.go +++ b/task_test.go @@ -2758,6 +2758,67 @@ func TestBashShellOptsCommandLevel(t *testing.T) { assert.Equal(t, "globstar\ton\n", buff.String()) } +func TestShellOptsInvalidShoptGlobalLevel(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/shopts/invalid_global_shopt"), + task.WithStdout(&buff), + task.WithStderr(&buff), + ) + + err := e.Setup() + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid shopt option "pipefail" (did you mean to put it in "set"?)`) +} + +func TestShellOptsInvalidSetTaskLevel(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/shopts/invalid_task_set"), + task.WithStdout(&buff), + task.WithStderr(&buff), + ) + + err := e.Setup() + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid set option "globstar" (did you mean to put it in "shopt"?)`) +} + +func TestShellOptsInvalidShoptCommandLevel(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/shopts/invalid_command_shopt"), + task.WithStdout(&buff), + task.WithStderr(&buff), + ) + + err := e.Setup() + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid shopt option "not_a_real_option"`) +} + +func TestShellOptsSetOptionsViaShopt(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/shopts/set_via_shopt"), + task.WithStdout(&buff), + task.WithStderr(&buff), + ) + require.NoError(t, e.Setup()) + + err := e.Run(t.Context(), &task.Call{Task: "default"}) + require.NoError(t, err) + assert.Equal(t, "hello\n", buff.String()) +} + func TestSplitArgs(t *testing.T) { t.Parallel() diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index 840234807f..3b822e1369 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -71,6 +71,12 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { if err := node.Decode(&cmdStruct); err != nil { return errors.NewTaskfileDecodeError(err, node) } + if err := checkSetOptions(node, cmdStruct.Set); err != nil { + return err + } + if err := checkShoptOptions(node, cmdStruct.Shopt); err != nil { + return err + } if cmdStruct.Defer != nil { // A deferred command diff --git a/taskfile/ast/shellopts.go b/taskfile/ast/shellopts.go new file mode 100644 index 0000000000..5c16a2b89e --- /dev/null +++ b/taskfile/ast/shellopts.go @@ -0,0 +1,75 @@ +package ast + +import ( + "slices" + + "go.yaml.in/yaml/v3" + + "github.com/go-task/task/v3/errors" +) + +// The tables below mirror the unexported posixOptsTable and bashOptsTable in +// mvdan.cc/sh/v3/interp (see interp/api.go: +// https://github.com/mvdan/sh/blob/master/interp/api.go). They must be kept +// in sync whenever the mvdan.cc/sh dependency is updated. + +// validSetOptions contains the one-character flag and full name forms of the +// POSIX shell options accepted by "set" (posixOptsTable). +var validSetOptions = []string{ + "a", "allexport", + "e", "errexit", + "n", "noexec", + "f", "noglob", + "u", "nounset", + "x", "xtrace", + "pipefail", +} + +// validShoptOptions contains the Bash shell options accepted by "shopt" (the +// entries of bashOptsTable with supported: true). +var validShoptOptions = []string{ + "dotglob", + "expand_aliases", + "extglob", + "globstar", + "nocaseglob", + "nullglob", +} + +// checkSetOptions checks that every entry of a "set" list is a POSIX shell +// option supported by the interpreter so that typos and unsupported options +// are reported when the Taskfile is parsed instead of when a command runs. +func checkSetOptions(node *yaml.Node, opts []string) error { + for _, opt := range opts { + if slices.Contains(validSetOptions, opt) { + continue + } + if slices.Contains(validShoptOptions, opt) { + return errors.NewTaskfileDecodeError(nil, node).WithMessage(`invalid set option %q (did you mean to put it in "shopt"?)`, opt) + } + return errors.NewTaskfileDecodeError(nil, node).WithMessage("invalid set option %q", opt) + } + return nil +} + +// checkShoptOptions checks that every entry of a "shopt" list is a Bash shell +// option supported by the interpreter so that typos and unsupported options +// are reported when the Taskfile is parsed instead of when a command runs. +func checkShoptOptions(node *yaml.Node, opts []string) error { + for i, opt := range opts { + // A "-o" entry makes shopt address the "set" builtin options instead + // (e.g. `shopt: ["-o", "pipefail"]` runs `shopt -s -o pipefail`), so + // the remaining entries are validated as set options. + if opt == "-o" { + return checkSetOptions(node, opts[i+1:]) + } + if slices.Contains(validShoptOptions, opt) { + continue + } + if slices.Contains(validSetOptions, opt) { + return errors.NewTaskfileDecodeError(nil, node).WithMessage(`invalid shopt option %q (did you mean to put it in "set"?)`, opt) + } + return errors.NewTaskfileDecodeError(nil, node).WithMessage("invalid shopt option %q", opt) + } + return nil +} diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 9465c77770..ba000558e7 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -168,6 +168,12 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { if err := node.Decode(&task); err != nil { return errors.NewTaskfileDecodeError(err, node) } + if err := checkSetOptions(node, task.Set); err != nil { + return err + } + if err := checkShoptOptions(node, task.Shopt); err != nil { + return err + } if task.Cmd != nil { if task.Cmds != nil { return errors.NewTaskfileDecodeError(nil, node).WithMessage("task cannot have both cmd and cmds") diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go index 20b4476cd5..82a10feee1 100644 --- a/taskfile/ast/taskfile.go +++ b/taskfile/ast/taskfile.go @@ -103,6 +103,12 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { if err := node.Decode(&taskfile); err != nil { return errors.NewTaskfileDecodeError(err, node) } + if err := checkSetOptions(node, taskfile.Set); err != nil { + return err + } + if err := checkShoptOptions(node, taskfile.Shopt); err != nil { + return err + } tf.Version = taskfile.Version tf.Output = taskfile.Output tf.Method = taskfile.Method diff --git a/testdata/shopts/invalid_command_shopt/Taskfile.yml b/testdata/shopts/invalid_command_shopt/Taskfile.yml new file mode 100644 index 0000000000..b3232150b4 --- /dev/null +++ b/testdata/shopts/invalid_command_shopt/Taskfile.yml @@ -0,0 +1,9 @@ +version: '3' + +silent: true + +tasks: + default: + cmds: + - cmd: echo hello + shopt: [not_a_real_option] diff --git a/testdata/shopts/invalid_global_shopt/Taskfile.yml b/testdata/shopts/invalid_global_shopt/Taskfile.yml new file mode 100644 index 0000000000..b06445b83e --- /dev/null +++ b/testdata/shopts/invalid_global_shopt/Taskfile.yml @@ -0,0 +1,9 @@ +version: '3' + +silent: true +shopt: [pipefail] + +tasks: + default: + cmds: + - echo hello diff --git a/testdata/shopts/invalid_task_set/Taskfile.yml b/testdata/shopts/invalid_task_set/Taskfile.yml new file mode 100644 index 0000000000..caa295ac65 --- /dev/null +++ b/testdata/shopts/invalid_task_set/Taskfile.yml @@ -0,0 +1,9 @@ +version: '3' + +silent: true + +tasks: + default: + set: [globstar] + cmds: + - echo hello diff --git a/testdata/shopts/set_via_shopt/Taskfile.yml b/testdata/shopts/set_via_shopt/Taskfile.yml new file mode 100644 index 0000000000..634fe31e9e --- /dev/null +++ b/testdata/shopts/set_via_shopt/Taskfile.yml @@ -0,0 +1,9 @@ +version: '3' + +silent: true +shopt: ['-o', 'pipefail'] + +tasks: + default: + cmds: + - echo hello diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index 71ead0757e..f2d025443d 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -201,13 +201,17 @@ set: [errexit, nounset, pipefail] ### `shopt` - **Type**: `[]string` -- **Options**: `expand_aliases`, `globstar`, `nullglob` +- **Options**: `dotglob`, `expand_aliases`, `extglob`, `globstar`, + `nocaseglob`, `nullglob` - **Description**: Bash shell options for all commands ```yaml shopt: [globstar] ``` +Options given to `set` and `shopt` are validated when the Taskfile is parsed +and an error is returned if an option is not in the lists above. + ## Include Configuration for including external Taskfiles. @@ -946,10 +950,15 @@ tasks: Available `shopt` options for Bash features: +- `dotglob` - Include hidden files in glob expansion - `expand_aliases` - Enable alias expansion +- `extglob` - Enable extended pattern matching - `globstar` - Enable `**` recursive globbing +- `nocaseglob` - Case-insensitive glob expansion - `nullglob` - Null glob expansion +Any other option is rejected with an error when the Taskfile is parsed. + ```yaml # Global level shopt: [globstar] diff --git a/website/src/public/schema.json b/website/src/public/schema.json index df0637b7ed..85c9c853d6 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -285,7 +285,14 @@ }, "shopt": { "type": "string", - "enum": ["expand_aliases", "globstar", "nullglob"] + "enum": [ + "dotglob", + "expand_aliases", + "extglob", + "globstar", + "nocaseglob", + "nullglob" + ] }, "vars": { "type": "object",