diff --git a/plugins/apache/process-compose.yaml b/plugins/apache/process-compose.yaml index 7c9ce4f5a79..94c2545dfe8 100644 --- a/plugins/apache/process-compose.yaml +++ b/plugins/apache/process-compose.yaml @@ -2,7 +2,7 @@ version: "0.5" processes: apache: - command: "echo \"Apache starting on port $HTTPD_PORT\ http://localhost:$HTTPD_PORT\" && apachectl start -f $HTTPD_CONFDIR/httpd.conf -D FOREGROUND" + command: "echo \"Apache starting on port $HTTPD_PORT\ http://localhost:$HTTPD_PORT\" && apachectl start -f \"$HTTPD_CONFDIR/httpd.conf\" -D FOREGROUND" availability: restart: on_failure max_restarts: 5 @@ -12,10 +12,10 @@ processes: apache-access: condition: process_started apache-error: - command: "tail -f $HTTPD_ERROR_LOG_FILE" + command: "tail -f \"$HTTPD_ERROR_LOG_FILE\"" availability: restart: "always" apache-access: - command: "tail -f $HTTPD_ACCESS_LOG_FILE" + command: "tail -f \"$HTTPD_ACCESS_LOG_FILE\"" availability: restart: "always" diff --git a/plugins/apacheHttpd.json b/plugins/apacheHttpd.json index efa15cfd09d..c545387b6a6 100644 --- a/plugins/apacheHttpd.json +++ b/plugins/apacheHttpd.json @@ -1,6 +1,6 @@ { "name": "apache", - "version": "0.0.2", + "version": "0.0.3", "description": "If you with to edit the config file, please copy it out of the .devbox directory.", "env": { "HTTPD_DEVBOX_CONFIG_DIR": "{{ .DevboxProjectDir }}", diff --git a/plugins/caddy.json b/plugins/caddy.json index 52ea9b9cb2a..2463a7a7adf 100644 --- a/plugins/caddy.json +++ b/plugins/caddy.json @@ -1,6 +1,6 @@ { "name": "caddy", - "version": "0.0.3", + "version": "0.0.4", "description": "You can customize the config used by the caddy service by modifying the Caddyfile in devbox.d/caddy, or by changing the CADDY_CONFIG environment variable to point to a custom config. The custom config must be either JSON or Caddyfile format.", "env": { "CADDY_CONFIG": "{{ .DevboxDir }}/Caddyfile", diff --git a/plugins/caddy/process-compose.yaml b/plugins/caddy/process-compose.yaml index adcb2edfc56..6b1d6433066 100644 --- a/plugins/caddy/process-compose.yaml +++ b/plugins/caddy/process-compose.yaml @@ -2,7 +2,7 @@ version: "0.6" processes: caddy: - command: "caddy run --config=$CADDY_CONFIG" + command: "caddy run --config=\"$CADDY_CONFIG\"" availability: restart: on_failure max_restarts: 5 \ No newline at end of file diff --git a/plugins/php.json b/plugins/php.json index 3af465ad1d4..e2733b51f8a 100644 --- a/plugins/php.json +++ b/plugins/php.json @@ -1,6 +1,6 @@ { "name": "php", - "version": "0.0.3", + "version": "0.0.4", "description": "PHP is compiled with default extensions. If you would like to use non-default extensions you can add them with devbox add php81Extensions.{extension} . For example, for the memcache extension you can do `devbox add php81Extensions.memcached`.", "packages": [ "path:{{ .Virtenv }}/flake", diff --git a/plugins/php/process-compose.yaml b/plugins/php/process-compose.yaml index af7d3527431..e37211c9b20 100644 --- a/plugins/php/process-compose.yaml +++ b/plugins/php/process-compose.yaml @@ -2,7 +2,7 @@ version: "0.5" processes: php-fpm: - command: "php-fpm -y {{ .DevboxDir }}/php-fpm.conf --nodaemonize" + command: "php-fpm -y \"{{ .DevboxDir }}/php-fpm.conf\" --nodaemonize" availability: restart: "always" diff --git a/plugins/redis.json b/plugins/redis.json index f264d5b481d..71fff8e0596 100644 --- a/plugins/redis.json +++ b/plugins/redis.json @@ -1,6 +1,6 @@ { "name": "redis", - "version": "0.0.2", + "version": "0.0.3", "description": "Running `devbox services start redis` will start redis as a daemon in the background. \n\nYou can manually start Redis in the foreground by running `redis-server $REDIS_CONF --port $REDIS_PORT`. \n\nLogs, pidfile, and data dumps are stored in `.devbox/virtenv/redis`. You can change this by modifying the `dir` directive in `devbox.d/redis/redis.conf`", "env": { "REDIS_PORT": "6379", diff --git a/plugins/redis/process-compose.yaml b/plugins/redis/process-compose.yaml index 909305930d1..4d86b9cd739 100644 --- a/plugins/redis/process-compose.yaml +++ b/plugins/redis/process-compose.yaml @@ -2,7 +2,7 @@ version: "0.5" processes: redis: - command: "redis-server $REDIS_CONF --port $REDIS_PORT" + command: "redis-server \"$REDIS_CONF\" --port $REDIS_PORT" availability: restart: on_failure max_restarts: 5 \ No newline at end of file diff --git a/plugins/service_command_quoting_test.go b/plugins/service_command_quoting_test.go new file mode 100644 index 00000000000..9f30016e349 --- /dev/null +++ b/plugins/service_command_quoting_test.go @@ -0,0 +1,176 @@ +package plugins + +import ( + "io/fs" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// TestServiceCommandTemplatePathsAreQuoted guards against a regression where a +// builtin plugin's process-compose service command references a templated +// filesystem path (for example "{{ .DevboxDir }}/php-fpm.conf") without +// wrapping it in double quotes. Because those templates expand to the +// project's absolute path, an unquoted reference is word-split by the shell +// whenever the project directory contains a space, and the service fails to +// start. See jetify-com/devbox#2631. +func TestServiceCommandTemplatePathsAreQuoted(t *testing.T) { + files := processComposeFiles(t) + + for _, file := range files { + t.Run(file, func(t *testing.T) { + for _, command := range serviceCommands(t, file) { + quoted := shellQuotedPositions(command) + for _, idx := range templateIndices(command) { + if !quoted[idx] { + t.Errorf( + "%s: service command %q has an unquoted templated path; "+ + "wrap it in double quotes so it survives project paths with spaces", + file, command, + ) + } + } + } + }) + } +} + +// TestServiceCommandEnvPathsAreQuoted guards against the same regression for +// service commands that reference a path through an environment variable (for +// example "$CADDY_CONFIG", which expands to "{{ .DevboxDir }}/Caddyfile"). +// Only variables that are known to hold an absolute project path are checked; +// variables such as ports do not need quoting. See jetify-com/devbox#2631. +func TestServiceCommandEnvPathsAreQuoted(t *testing.T) { + // pathEnvVars maps a plugin's process-compose.yaml to the environment + // variables it references that expand to an absolute project path and so + // must be quoted wherever they appear in a service command. + pathEnvVars := map[string][]string{ + "apache/process-compose.yaml": { + "HTTPD_CONFDIR", + "HTTPD_ERROR_LOG_FILE", + "HTTPD_ACCESS_LOG_FILE", + }, + "caddy/process-compose.yaml": {"CADDY_CONFIG"}, + "postgresql/process-compose.yaml": {"PGHOST"}, + "redis/process-compose.yaml": {"REDIS_CONF"}, + "valkey/process-compose.yaml": {"VALKEY_CONF"}, + } + + for file, vars := range pathEnvVars { + t.Run(file, func(t *testing.T) { + commands := serviceCommands(t, file) + for _, command := range commands { + quoted := shellQuotedPositions(command) + for _, name := range vars { + for _, idx := range envVarIndices(command, name) { + if !quoted[idx] { + t.Errorf( + "%s: service command %q references $%s unquoted; "+ + "wrap it in double quotes so it survives project paths with spaces", + file, command, name, + ) + } + } + } + } + }) + } +} + +// processComposeFiles returns the embedded path of every builtin plugin's +// process-compose.yaml. +func processComposeFiles(t *testing.T) []string { + t.Helper() + files, err := fs.Glob(builtIn, "*/process-compose.yaml") + if err != nil { + t.Fatalf("globbing process-compose files: %v", err) + } + if len(files) == 0 { + t.Fatal("no process-compose.yaml files found") + } + return files +} + +// serviceCommands parses a process-compose.yaml and returns every "command" +// string it defines (start, shutdown, and readiness-probe commands alike). +func serviceCommands(t *testing.T, file string) []string { + t.Helper() + contents, err := fs.ReadFile(builtIn, file) + if err != nil { + t.Fatalf("reading %s: %v", file, err) + } + + var doc any + if err := yaml.Unmarshal(contents, &doc); err != nil { + t.Fatalf("parsing %s: %v", file, err) + } + + var commands []string + collectCommands(doc, &commands) + return commands +} + +// collectCommands walks a decoded YAML document and appends the string value +// of every mapping entry whose key is "command". +func collectCommands(node any, out *[]string) { + switch val := node.(type) { + case map[string]any: + for key, child := range val { + if key == "command" { + if s, ok := child.(string); ok { + *out = append(*out, s) + continue + } + } + collectCommands(child, out) + } + case map[any]any: + for key, child := range val { + if key == "command" { + if s, ok := child.(string); ok { + *out = append(*out, s) + continue + } + } + collectCommands(child, out) + } + case []any: + for _, child := range val { + collectCommands(child, out) + } + } +} + +// envVarIndices returns the index of the leading '$' for every reference to the +// named environment variable in line, matching both the "$NAME" and "${NAME}" +// forms. The "$NAME" form only matches when NAME is not immediately followed by +// another identifier character, so "$FOO" does not match a search for "$F". +func envVarIndices(line, name string) []int { + var indices []int + for _, pattern := range []string{"${" + name + "}", "$" + name} { + for start := 0; ; { + i := strings.Index(line[start:], pattern) + if i < 0 { + break + } + at := start + i + end := at + len(pattern) + // For the bare "$NAME" form, reject a longer identifier match. + if !strings.HasSuffix(pattern, "}") && end < len(line) && isIdentByte(line[end]) { + start = at + 1 + continue + } + indices = append(indices, at) + start = end + } + } + return indices +} + +func isIdentByte(b byte) bool { + return b == '_' || + (b >= '0' && b <= '9') || + (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') +} diff --git a/plugins/valkey.json b/plugins/valkey.json index b49332a48cc..1f39fb76321 100644 --- a/plugins/valkey.json +++ b/plugins/valkey.json @@ -1,6 +1,6 @@ { "name": "valkey", - "version": "0.0.1", + "version": "0.0.2", "description": "Running `devbox services start valkey` will start valkey as a daemon in the background. \n\nYou can manually start Valkey in the foreground by running `valkey-server $VALKEY_CONF --port $VALKEY_PORT`. \n\nLogs, pidfile, and data dumps are stored in `.devbox/virtenv/valkey`. You can change this by modifying the `dir` directive in `devbox.d/valkey/valkey.conf`", "env": { "VALKEY_PORT": "6379", diff --git a/plugins/valkey/process-compose.yaml b/plugins/valkey/process-compose.yaml index cd0f6477daa..c5dd637854a 100644 --- a/plugins/valkey/process-compose.yaml +++ b/plugins/valkey/process-compose.yaml @@ -2,7 +2,7 @@ version: "0.5" processes: valkey: - command: "valkey-server $VALKEY_CONF --port $VALKEY_PORT" + command: "valkey-server \"$VALKEY_CONF\" --port $VALKEY_PORT" availability: restart: on_failure max_restarts: 5 \ No newline at end of file