From 1f8a814047cf0fcb6a6f16011ec5c19f1b533114 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 24 Sep 2026 01:24:27 -0400 Subject: [PATCH] fix: respect quoting in procfile commands Procfile commands for the `docker-local` scheduler were split differently on deploy and on `dokku run`. Deploys failed on escaped quotes, and `dokku run` ignored quotes entirely. Both now split commands using shell quoting rules, and commands containing bare shell operators fail with a clear error, matching the `k3s` scheduler. --- docs/processes/one-off-tasks.md | 2 + docs/processes/process-management.md | 6 + plugins/common/functions | 6 + plugins/common/go.mod | 1 + plugins/common/go.sum | 10 ++ plugins/common/shellwords/shellwords.go | 91 +++++++++++++++ plugins/common/shellwords/shellwords_test.go | 107 ++++++++++++++++++ plugins/common/src/common/common.go | 11 ++ plugins/docker-options/dockeroptions.go | 94 +-------------- plugins/docker-options/go.mod | 2 +- .../bin/scheduler-deploy-process-container | 5 +- plugins/scheduler-docker-local/scheduler-run | 6 +- tests/unit/scheduler-docker-local.bats | 68 +++++++++++ 13 files changed, 317 insertions(+), 92 deletions(-) create mode 100644 plugins/common/shellwords/shellwords.go create mode 100644 plugins/common/shellwords/shellwords_test.go diff --git a/docs/processes/one-off-tasks.md b/docs/processes/one-off-tasks.md index f3f699111..dd221faa7 100644 --- a/docs/processes/one-off-tasks.md +++ b/docs/processes/one-off-tasks.md @@ -52,6 +52,8 @@ console: bundle exec racksh dokku run my-app console ``` +The Procfile command is split into arguments using the same shell quoting rules used when deploying the app. See the [Procfile documentation](/docs/processes/process-management.md#procfile) for more details. + #### Specifying container labels Containers may have specific labels attached. In order to avoid issues with dokku internals, do not use any labels beginning with either `com.dokku` or `org.label-schema`. diff --git a/docs/processes/process-management.md b/docs/processes/process-management.md index 87526890b..190cd1f81 100644 --- a/docs/processes/process-management.md +++ b/docs/processes/process-management.md @@ -179,6 +179,12 @@ importantworker: env QUEUE=important bundle exec rake resque:work If the app build declares an `ENTRYPOINT`, the command defined in the `Procfile` is passed as an argument to that entrypoint. This is the case for all Dockerfile-based, Docker Image, and Cloud Native Buildpack deployments. +When deploying via the `docker-local` scheduler, for all builders other than Herokuish, the command is split into arguments using shell quoting rules, but is not otherwise interpreted by a shell. Environment variable references such as `$PORT` are substituted before the command is split. Single quotes, double quotes, and backslash escapes then group and escape arguments, and any other special characters are passed to the container verbatim. Shell operators such as `&&`, `|`, `;`, and redirections are not supported and will fail the deploy, and a trailing `#` comment is ignored. If shell features are required, invoke a shell explicitly: + +```Procfile +web: sh -c "bin/migrate && exec bin/server --port $PORT" +``` + The `web` process type holds some significance in that it is the only process type that is automatically scaled to `1` on the initial application deploy. See the [web process scaling documentation](/docs/processes/process-management.md#the-web-process) for more details around scaling individual processes. See the [Procfile location documentation](/docs/processes/process-management.md#changing-the-procfile-location) for more information on where to place your `Procfile` file. diff --git a/plugins/common/functions b/plugins/common/functions index 2b13b9416..bb00b5016 100755 --- a/plugins/common/functions +++ b/plugins/common/functions @@ -251,6 +251,12 @@ fn-docker-args-split() { printf "%s" "$DOCKER_ARGS" | xargs -r printf "%s\0" } +fn-split-command() { + declare desc="tokenizes a command string into NUL-delimited fields using shell quoting rules without shell expansion" + declare COMMAND="$1" + "$PLUGIN_CORE_AVAILABLE_PATH/common/common" split-command -- "$COMMAND" +} + is_valid_app_name() { declare desc="verify that the app name matches naming restrictions" local APP="$1" diff --git a/plugins/common/go.mod b/plugins/common/go.mod index 10bbef298..81e8001b7 100644 --- a/plugins/common/go.mod +++ b/plugins/common/go.mod @@ -13,6 +13,7 @@ require ( github.com/spf13/pflag v1.0.10 golang.org/x/crypto v0.57.0 golang.org/x/sync v0.23.0 + mvdan.cc/sh/v3 v3.14.1 ) require ( diff --git a/plugins/common/go.sum b/plugins/common/go.sum index dc1b5b4e3..01f9cd7d2 100644 --- a/plugins/common/go.sum +++ b/plugins/common/go.sum @@ -2,6 +2,8 @@ github.com/alexellis/go-execute/v2 v2.2.1 h1:4Ye3jiCKQarstODOEmqDSRCqxMHLkC92Bhs github.com/alexellis/go-execute/v2 v2.2.1/go.mod h1:FMdRnUTiFAmYXcv23txrp3VYZfLo24nMpiIneWgKHTQ= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= +github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -11,6 +13,10 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -27,6 +33,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= @@ -47,3 +55,5 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI= +mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo= diff --git a/plugins/common/shellwords/shellwords.go b/plugins/common/shellwords/shellwords.go new file mode 100644 index 000000000..93091ec7a --- /dev/null +++ b/plugins/common/shellwords/shellwords.go @@ -0,0 +1,91 @@ +package shellwords + +import ( + "strings" + + "mvdan.cc/sh/v3/syntax" +) + +// Split splits input into shell words using the parser directly, so quotes +// delimit words and are stripped from the returned value, but nothing is +// expanded. Parameter expansions, command substitutions, and other +// metacharacters are preserved verbatim. Malformed input, such as an unbalanced +// quote, returns the parser error. +func Split(input string) ([]string, error) { + fields, _, err := SplitWithEnd(input) + return fields, err +} + +// SplitWithEnd is Split plus the byte offset just past the last word the +// parser consumed. Callers that must not lose data compare the offset against +// the length of the input to detect a tail the parser skipped, such as a shell +// comment. +func SplitWithEnd(input string) ([]string, int, error) { + parser := syntax.NewParser() + var fields []string + end := 0 + for word, err := range parser.WordsSeq(strings.NewReader(input)) { + if err != nil { + return nil, 0, err + } + fields = append(fields, literalWordValue(input, word.Parts)) + end = int(word.End().Offset()) + } + return fields, end, nil +} + +// literalWordValue reconstructs the unquoted, unexpanded value of a shell word. +// Literal and single-quoted parts contribute their literal text; double-quoted +// parts have their surrounding quotes dropped while their contents stay +// literal; every other part - parameter expansions, command substitutions, +// arithmetic expansions - contributes its original source text unchanged. +// Backslash escapes are removed the same way the shell removes them during +// quote removal so a value such as `Host(\`app\`)` round-trips to `Host(`app`)`. +func literalWordValue(input string, parts []syntax.WordPart) string { + var sb strings.Builder + for _, part := range parts { + switch p := part.(type) { + case *syntax.Lit: + sb.WriteString(unquoteBackslashes(p.Value, false)) + case *syntax.SglQuoted: + sb.WriteString(p.Value) + case *syntax.DblQuoted: + for _, inner := range p.Parts { + if lit, ok := inner.(*syntax.Lit); ok { + sb.WriteString(unquoteBackslashes(lit.Value, true)) + continue + } + sb.WriteString(input[inner.Pos().Offset():inner.End().Offset()]) + } + default: + sb.WriteString(input[part.Pos().Offset():part.End().Offset()]) + } + } + return sb.String() +} + +// unquoteBackslashes removes backslashes the way the shell does during quote +// removal. Inside double quotes only \$, \`, \", \\, and an escaped newline +// lose their backslash; unquoted, a backslash escapes any following character. +func unquoteBackslashes(s string, inDblQuotes bool) string { + if !strings.Contains(s, "\\") { + return s + } + var sb strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + next := s[i+1] + if next == '\n' { + i++ + continue + } + if !inDblQuotes || next == '$' || next == '`' || next == '"' || next == '\\' { + sb.WriteByte(next) + i++ + continue + } + } + sb.WriteByte(s[i]) + } + return sb.String() +} diff --git a/plugins/common/shellwords/shellwords_test.go b/plugins/common/shellwords/shellwords_test.go new file mode 100644 index 000000000..6d4def3d5 --- /dev/null +++ b/plugins/common/shellwords/shellwords_test.go @@ -0,0 +1,107 @@ +package shellwords + +import ( + "slices" + "testing" +) + +func TestSplit(t *testing.T) { + cases := []struct { + name string + input string + want []string + wantErr bool + }{ + { + name: "empty input", + input: "", + }, + { + name: "plain words", + input: "python -m http.server 5000", + want: []string{"python", "-m", "http.server", "5000"}, + }, + { + name: "double quoted word", + input: `sh -c "echo hi"`, + want: []string{"sh", "-c", "echo hi"}, + }, + { + name: "single quoted word", + input: `sh -c 'echo hi'`, + want: []string{"sh", "-c", "echo hi"}, + }, + { + name: "escaped quotes inside double quotes", + input: `sh -c "echo \"hi\""`, + want: []string{"sh", "-c", `echo "hi"`}, + }, + { + name: "escaped single quote outside quotes", + input: `echo it\'s`, + want: []string{"echo", "it's"}, + }, + { + name: "parameter expansion kept verbatim", + input: `sh -c "echo $PORT"`, + want: []string{"sh", "-c", "echo $PORT"}, + }, + { + name: "command substitution kept verbatim", + input: `echo $(date)`, + want: []string{"echo", "$(date)"}, + }, + { + name: "backticks kept verbatim", + input: "echo `date`", + want: []string{"echo", "`date`"}, + }, + { + name: "escaped backticks inside double quotes", + input: "echo \"\\`date\\`\"", + want: []string{"echo", "`date`"}, + }, + { + name: "unterminated quote", + input: `sh -c "echo hi`, + wantErr: true, + }, + { + name: "bare operator", + input: `a && b`, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Split(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("Split(%q) expected error, got %q", tc.input, got) + } + return + } + if err != nil { + t.Fatalf("Split(%q) unexpected error: %v", tc.input, err) + } + if !slices.Equal(got, tc.want) { + t.Fatalf("Split(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestSplitWithEndReportsSkippedComment(t *testing.T) { + input := "a # comment" + fields, end, err := SplitWithEnd(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !slices.Equal(fields, []string{"a"}) { + t.Fatalf("fields = %q, want [a]", fields) + } + if end != 1 { + t.Fatalf("end = %d, want 1", end) + } +} diff --git a/plugins/common/src/common/common.go b/plugins/common/src/common/common.go index 58412f591..0dd33298c 100644 --- a/plugins/common/src/common/common.go +++ b/plugins/common/src/common/common.go @@ -5,6 +5,7 @@ import ( "os" "github.com/dokku/dokku/plugins/common" + "github.com/dokku/dokku/plugins/common/shellwords" flag "github.com/spf13/pflag" ) @@ -92,6 +93,16 @@ func main() { case "is-valid-app-name-old": appName := flag.Arg(1) err = common.IsValidAppNameOld(appName) + case "split-command": + var fields []string + fields, err = shellwords.Split(flag.Arg(1)) + if err != nil { + err = fmt.Errorf("Unable to parse command %q: %w", flag.Arg(1), err) + break + } + for _, field := range fields { + fmt.Printf("%s\x00", field) + } case "verify-app-name": appName := flag.Arg(1) err = common.VerifyAppName(appName) diff --git a/plugins/docker-options/dockeroptions.go b/plugins/docker-options/dockeroptions.go index a2ee067e8..c6bdfe55a 100644 --- a/plugins/docker-options/dockeroptions.go +++ b/plugins/docker-options/dockeroptions.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/dokku/dokku/plugins/common" - "mvdan.cc/sh/v3/syntax" + "github.com/dokku/dokku/plugins/common/shellwords" ) // DefaultProcessType is the sentinel process-type key used for options that @@ -32,7 +32,7 @@ func SplitOptionString(input string) (options []string, processes []string, err return nil, nil, nil } - fields, err := literalFields(input) + fields, err := shellwords.Split(input) if err != nil { return nil, nil, fmt.Errorf("Unable to parse docker option: %s", err.Error()) } @@ -93,7 +93,7 @@ func groupOptionTokens(fields []string) [][]string { // stops before consuming all of it - a line whose tail begins with an unquoted // `#` reads as a shell comment, and dropping it would lose stored data. func canonicalOptionsFromLine(line string) []string { - fields, end, err := literalFieldsWithEnd(line) + fields, end, err := shellwords.SplitWithEnd(line) if err != nil || end < len(strings.TrimRight(line, " \t")) { return []string{line} } @@ -118,12 +118,12 @@ func optionsEqual(stored string, option string) bool { return true } - storedFields, err := literalFields(stored) + storedFields, err := shellwords.Split(stored) if err != nil { return false } - optionFields, err := literalFields(option) + optionFields, err := shellwords.Split(option) if err != nil { return false } @@ -131,90 +131,6 @@ func optionsEqual(stored string, option string) bool { return slices.Equal(storedFields, optionFields) } -// literalFields splits input into shell words using the parser directly, so -// quotes delimit words and are stripped from the stored value, but nothing is -// expanded. Parameter expansions, command substitutions, and other -// metacharacters are preserved verbatim. Malformed input, such as an unbalanced -// quote, returns the parser error. -func literalFields(input string) ([]string, error) { - fields, _, err := literalFieldsWithEnd(input) - return fields, err -} - -// literalFieldsWithEnd is literalFields plus the byte offset just past the last -// word the parser consumed. Callers that must not lose data compare the offset -// against the length of the input to detect a tail the parser skipped, such as -// a shell comment. -func literalFieldsWithEnd(input string) ([]string, int, error) { - parser := syntax.NewParser() - var fields []string - end := 0 - for word, err := range parser.WordsSeq(strings.NewReader(input)) { - if err != nil { - return nil, 0, err - } - fields = append(fields, literalWordValue(input, word.Parts)) - end = int(word.End().Offset()) - } - return fields, end, nil -} - -// literalWordValue reconstructs the unquoted, unexpanded value of a shell word. -// Literal and single-quoted parts contribute their literal text; double-quoted -// parts have their surrounding quotes dropped while their contents stay -// literal; every other part - parameter expansions, command substitutions, -// arithmetic expansions - contributes its original source text unchanged. -// Backslash escapes are removed the same way the shell removes them during -// quote removal so a value such as `Host(\`app\`)` round-trips to `Host(`app`)`. -func literalWordValue(input string, parts []syntax.WordPart) string { - var sb strings.Builder - for _, part := range parts { - switch p := part.(type) { - case *syntax.Lit: - sb.WriteString(unquoteBackslashes(p.Value, false)) - case *syntax.SglQuoted: - sb.WriteString(p.Value) - case *syntax.DblQuoted: - for _, inner := range p.Parts { - if lit, ok := inner.(*syntax.Lit); ok { - sb.WriteString(unquoteBackslashes(lit.Value, true)) - continue - } - sb.WriteString(input[inner.Pos().Offset():inner.End().Offset()]) - } - default: - sb.WriteString(input[part.Pos().Offset():part.End().Offset()]) - } - } - return sb.String() -} - -// unquoteBackslashes removes backslashes the way the shell does during quote -// removal. Inside double quotes only \$, \`, \", \\, and an escaped newline -// lose their backslash; unquoted, a backslash escapes any following character. -func unquoteBackslashes(s string, inDblQuotes bool) string { - if !strings.Contains(s, "\\") { - return s - } - var sb strings.Builder - for i := 0; i < len(s); i++ { - if s[i] == '\\' && i+1 < len(s) { - next := s[i+1] - if next == '\n' { - i++ - continue - } - if !inDblQuotes || next == '$' || next == '`' || next == '"' || next == '\\' { - sb.WriteByte(next) - i++ - continue - } - } - sb.WriteByte(s[i]) - } - return sb.String() -} - // isFlagToken reports whether tok looks like a CLI flag (long or short) rather // than a value. Treating any token that begins with `-` and has more than one // character as a flag matches docker's flag conventions and avoids the need diff --git a/plugins/docker-options/go.mod b/plugins/docker-options/go.mod index a8f266614..e74a69922 100644 --- a/plugins/docker-options/go.mod +++ b/plugins/docker-options/go.mod @@ -5,7 +5,6 @@ go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 github.com/spf13/pflag v1.0.10 - mvdan.cc/sh/v3 v3.14.1 ) require ( @@ -25,6 +24,7 @@ require ( golang.org/x/crypto v0.57.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect + mvdan.cc/sh/v3 v3.14.1 // indirect ) replace github.com/dokku/dokku/plugins/common => ../common diff --git a/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container b/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container index 9c6a33039..ded30d045 100755 --- a/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container +++ b/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container @@ -65,12 +65,15 @@ main() { START_CMD=$(fn-scheduler-docker-local-extract-start-cmd "$APP" "$PROC_TYPE" "$START_CMD" "$DOKKU_HEROKUISH" "$DOKKU_PORT") DOCKER_ARGS+=" $IMAGE" - DOCKER_ARGS+=" $START_CMD" + fn-split-command "$START_CMD" >/dev/null declare -a ARG_ARRAY=() while IFS= read -r -d '' arg; do ARG_ARRAY+=("$arg") done < <(fn-docker-args-split "$DOCKER_ARGS") + while IFS= read -r -d '' arg; do + ARG_ARRAY+=("$arg") + done < <(fn-split-command "$START_CMD") cid=$(fn-scheduler-docker-local-start-app-container "$APP" "${ARG_ARRAY[@]}") plugn trigger post-container-create "app" "$cid" "$APP" "deploy" "$PROC_TYPE" diff --git a/plugins/scheduler-docker-local/scheduler-run b/plugins/scheduler-docker-local/scheduler-run index d184b0954..224dfc05a 100755 --- a/plugins/scheduler-docker-local/scheduler-run +++ b/plugins/scheduler-docker-local/scheduler-run @@ -111,7 +111,11 @@ trigger-scheduler-docker-local-scheduler-run() { if [[ -n "$PROC_CMD" ]]; then dokku_log_info1_quiet "Found '$POTENTIAL_PROCFILE_KEY' in Procfile, running that command" DOCKER_ARGS+=" --label=com.dokku.process-type=$POTENTIAL_PROCFILE_KEY" - read -ra proc_array <<<"$PROC_CMD" + fn-split-command "$PROC_CMD" >/dev/null + local -a proc_array=() + while IFS= read -r -d '' arg; do + proc_array+=("$arg") + done < <(fn-split-command "$PROC_CMD") set -- "${proc_array[@]}" "${@:2}" fi fi diff --git a/tests/unit/scheduler-docker-local.bats b/tests/unit/scheduler-docker-local.bats index 29c76196c..1d3ead3a3 100644 --- a/tests/unit/scheduler-docker-local.bats +++ b/tests/unit/scheduler-docker-local.bats @@ -483,3 +483,71 @@ teardown() { echo "status: $status" assert_output "running" } + +@test "(scheduler-docker-local) procfile quoted arguments on deploy" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP quoted_procfile_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "docker inspect --format '{{json .Config.Cmd}}' $TEST_APP.web.1" + echo "output: $output" + echo "status: $status" + assert_success + assert_output '["python3","-u","web.py","double quoted","single quoted","escaped \"quote\"","literal`tick`","5000"]' +} + +@test "(scheduler-docker-local) procfile quoted arguments on run" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP quoted_procfile_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku run $TEST_APP args" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "['double quoted', 'single quoted', 'escaped \"quote\"', 'literal\`tick\`', '5000']" +} + +@test "(scheduler-docker-local) procfile with shell operators fails deploy" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP operator_procfile_callback + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "Unable to parse command" +} + +quoted_procfile_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + mv "$APP_REPO_DIR/dockerfile.Dockerfile" "$APP_REPO_DIR/Dockerfile" + cat >"$APP_REPO_DIR/Procfile" <<'PROCFILE' +web: python3 -u web.py "double quoted" 'single quoted' "escaped \"quote\"" literal\`tick\` "$PORT" +args: python3 -c "import sys; print(sys.argv[1:])" "double quoted" 'single quoted' "escaped \"quote\"" literal\`tick\` "$PORT" +PROCFILE +} + +operator_procfile_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + mv "$APP_REPO_DIR/dockerfile.Dockerfile" "$APP_REPO_DIR/Dockerfile" + cat >"$APP_REPO_DIR/Procfile" <<'PROCFILE' +web: python3 -u web.py && echo done +PROCFILE +}