From e75ae6dc8e1b0325c8172c4a2ac7dc3c45618d77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 14:14:05 +0000 Subject: [PATCH 1/4] fix(plugins): quote templated hook paths so spaces in project dir work The python, poetry, mariadb and mysql builtin plugins referenced a templated absolute path (e.g. "{{ .Virtenv }}/bin/venvShellHook.sh") in their init_hook without wrapping it in double quotes. Because .Virtenv expands to the project's absolute path, an unquoted reference is word-split by the shell whenever the project directory contains a space, so the hook fails to run: bash: /home/me/Documents/rm: No such file or directory Wrap each templated path in double quotes (matching the existing nodejs plugin), and bump the affected plugins' versions. Adds a regression test that asserts no builtin plugin's init_hook contains an unquoted templated path. Fixes #2673 --- plugins/init_hook_quoting_test.go | 104 ++++++++++++++++++++++++++++++ plugins/mariadb.json | 4 +- plugins/mysql.json | 4 +- plugins/poetry.json | 4 +- plugins/python.json | 4 +- 5 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 plugins/init_hook_quoting_test.go diff --git a/plugins/init_hook_quoting_test.go b/plugins/init_hook_quoting_test.go new file mode 100644 index 00000000000..63f0fd572d2 --- /dev/null +++ b/plugins/init_hook_quoting_test.go @@ -0,0 +1,104 @@ +package plugins + +import ( + "encoding/json" + "io/fs" + "strings" + "testing" +) + +// TestInitHookPathsAreQuoted guards against a regression where a builtin +// plugin's init_hook references a templated filesystem path (for example +// "{{ .Virtenv }}/bin/venvShellHook.sh") without wrapping it in double +// quotes. Because .Virtenv (and similar templates) expand to the absolute +// project path, an unquoted reference breaks the shell hook whenever the +// project directory contains a space. See jetify-com/devbox#2673. +func TestInitHookPathsAreQuoted(t *testing.T) { + entries, err := Builtins() + if err != nil { + t.Fatalf("listing builtin plugins: %v", err) + } + + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + + t.Run(name, func(t *testing.T) { + contents, err := fs.ReadFile(builtIn, name) + if err != nil { + t.Fatalf("reading %s: %v", name, err) + } + + var plugin struct { + Shell struct { + InitHook json.RawMessage `json:"init_hook"` + } `json:"shell"` + } + if err := json.Unmarshal(contents, &plugin); err != nil { + t.Fatalf("parsing %s: %v", name, err) + } + + for _, line := range initHookLines(t, plugin.Shell.InitHook) { + for _, idx := range templateIndices(line) { + if !insideDoubleQuotes(line, idx) { + t.Errorf( + "%s: init_hook line %q has an unquoted templated path; "+ + "wrap it in double quotes so it survives project paths with spaces", + name, line, + ) + } + } + } + }) + } +} + +// initHookLines normalizes the init_hook field, which may be either a single +// string or an array of strings, into a slice of strings. +func initHookLines(t *testing.T, raw json.RawMessage) []string { + t.Helper() + if len(raw) == 0 { + return nil + } + + var list []string + if err := json.Unmarshal(raw, &list); err == nil { + return list + } + + var single string + if err := json.Unmarshal(raw, &single); err == nil { + return []string{single} + } + + t.Fatalf("init_hook is neither a string nor an array of strings: %s", raw) + return nil +} + +// templateIndices returns the starting index of every "{{" template opener in +// the line. +func templateIndices(line string) []int { + var indices []int + for offset := 0; ; { + i := strings.Index(line[offset:], "{{") + if i < 0 { + return indices + } + indices = append(indices, offset+i) + offset += i + 2 + } +} + +// insideDoubleQuotes reports whether the byte at pos is enclosed in an +// unescaped pair of double quotes. +func insideDoubleQuotes(line string, pos int) bool { + inQuotes := false + for i := 0; i < pos && i < len(line); i++ { + if line[i] == '"' && (i == 0 || line[i-1] != '\\') { + inQuotes = !inQuotes + } + } + return inQuotes +} diff --git a/plugins/mariadb.json b/plugins/mariadb.json index 4db04d3b574..5a52137458c 100644 --- a/plugins/mariadb.json +++ b/plugins/mariadb.json @@ -1,6 +1,6 @@ { "name": "mariadb", - "version": "0.0.4", + "version": "0.0.5", "description": "* This plugin wraps mysqld and mysql_install_db to work in your local project\n* This plugin will create a new database for your project in MYSQL_DATADIR if one doesn't exist on shell init\n* Use mysqld to manually start the server, and `mysqladmin -u root shutdown` to manually stop it", "env": { "MYSQL_BASEDIR": "{{ .DevboxProfileDefault }}", @@ -27,7 +27,7 @@ "__remove_trigger_package": true, "shell": { "init_hook": [ - "bash {{ .Virtenv }}/setup_db.sh" + "bash \"{{ .Virtenv }}/setup_db.sh\"" ] } } diff --git a/plugins/mysql.json b/plugins/mysql.json index d982fc5bb8f..7087ee44753 100644 --- a/plugins/mysql.json +++ b/plugins/mysql.json @@ -1,6 +1,6 @@ { "name": "mysql", - "version": "0.0.4", + "version": "0.0.5", "description": "* This plugin wraps mysqld and mysql_install_db to work in your local project\n* This plugin will create a new database for your project in MYSQL_DATADIR if one doesn't exist on shell init. This DB will be started in `insecure` mode, so be sure to add a root password after creation if needed.\n* Use mysqld to manually start the server, and `mysqladmin -u root shutdown` to manually stop it", "env": { "MYSQL_BASEDIR": "{{ .DevboxProfileDefault }}", @@ -26,6 +26,6 @@ }, "__remove_trigger_package": true, "shell": { - "init_hook": ["bash {{ .Virtenv }}/setup_db.sh"] + "init_hook": ["bash \"{{ .Virtenv }}/setup_db.sh\""] } } diff --git a/plugins/poetry.json b/plugins/poetry.json index 2deee744d50..a4da8e39675 100644 --- a/plugins/poetry.json +++ b/plugins/poetry.json @@ -1,6 +1,6 @@ { "name": "poetry", - "version": "0.0.4", + "version": "0.0.5", "description": "This plugin automatically configures poetry to use the version of python installed in your Devbox shell, instead of the Python version that it is bundled with. The pyproject.toml location can be configured by setting DEVBOX_PYPROJECT_DIR (defaults to the devbox.json's directory).", "env": { "DEVBOX_DEFAULT_PYPROJECT_DIR": "{{ .DevboxProjectDir }}", @@ -13,7 +13,7 @@ }, "shell": { "init_hook": [ - "{{ .Virtenv }}/bin/initHook.sh" + "\"{{ .Virtenv }}/bin/initHook.sh\"" ] } } diff --git a/plugins/python.json b/plugins/python.json index 4e48943a48c..91e4fa6e3e6 100644 --- a/plugins/python.json +++ b/plugins/python.json @@ -1,6 +1,6 @@ { "name": "python", - "version": "0.0.4", + "version": "0.0.5", "description": "Python in Devbox works best when used with a virtual environment (venv, virtualenv, etc.). Devbox will automatically create a virtual environment using `venv` for python3 projects, so you can install packages with pip as normal.\nTo activate the environment, run `. $VENV_DIR/bin/activate` or add it to the init_hook of your devbox.json\nTo change where your virtual environment is created, modify the $VENV_DIR environment variable in your init_hook", "env": { "VENV_DIR": "{{ .DevboxProjectDir }}/.venv" @@ -11,7 +11,7 @@ "shell": { "init_hook": [ "export UV_PROJECT_ENVIRONMENT=\"$VENV_DIR\"", - "{{ .Virtenv }}/bin/venvShellHook.sh" + "\"{{ .Virtenv }}/bin/venvShellHook.sh\"" ] } } From c93ed84fcf4e8ef68ac0350e8f184de7e174259c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 14:19:31 +0000 Subject: [PATCH 2/4] test(plugins): model single quotes and unterminated quotes in hook check Address review feedback on the init_hook quoting regression test. The previous insideDoubleQuotes helper only toggled on " and could produce false negatives: a literal double quote inside a single-quoted segment would flip the state, and an unterminated quote was treated as quoted. Replace it with shellQuotedPositions, which models the parts of POSIX shell quoting relevant to word-splitting: single and double quotes are mutually exclusive, a backslash escapes a double quote inside double quotes, and a quote that is never closed leaves its bytes unquoted (so an unterminated segment is reported as unsafe). --- plugins/init_hook_quoting_test.go | 53 +++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/plugins/init_hook_quoting_test.go b/plugins/init_hook_quoting_test.go index 63f0fd572d2..91ac5333d21 100644 --- a/plugins/init_hook_quoting_test.go +++ b/plugins/init_hook_quoting_test.go @@ -41,8 +41,9 @@ func TestInitHookPathsAreQuoted(t *testing.T) { } for _, line := range initHookLines(t, plugin.Shell.InitHook) { + quoted := shellQuotedPositions(line) for _, idx := range templateIndices(line) { - if !insideDoubleQuotes(line, idx) { + if !quoted[idx] { t.Errorf( "%s: init_hook line %q has an unquoted templated path; "+ "wrap it in double quotes so it survives project paths with spaces", @@ -91,14 +92,48 @@ func templateIndices(line string) []int { } } -// insideDoubleQuotes reports whether the byte at pos is enclosed in an -// unescaped pair of double quotes. -func insideDoubleQuotes(line string, pos int) bool { - inQuotes := false - for i := 0; i < pos && i < len(line); i++ { - if line[i] == '"' && (i == 0 || line[i-1] != '\\') { - inQuotes = !inQuotes +// shellQuotedPositions returns a slice the same length as line where element i +// reports whether the byte at index i lies inside a properly closed shell quote +// (single or double). It models the parts of POSIX shell quoting that matter for +// word-splitting protection: +// +// - Single and double quotes are mutually exclusive: a quote character is +// literal (does not open a region) while inside the other quote type. +// - A backslash escapes a double quote inside a double-quoted region. +// - A quote that is never closed leaves its bytes unquoted, so an +// unterminated segment is reported as unsafe rather than safe. +func shellQuotedPositions(line string) []bool { + quoted := make([]bool, len(line)) + const none = byte(0) + open := none // the quote char of the region currently open, or none + start := -1 // index of the opening quote of the current region + for i := 0; i < len(line); i++ { + c := line[i] + switch open { + case none: + if c == '\'' || c == '"' { + open = c + start = i + } + case '\'': + // Inside single quotes nothing is special except the closing '. + if c == '\'' { + markQuoted(quoted, start+1, i) + open, start = none, -1 + } + case '"': + if c == '"' && line[i-1] != '\\' { + markQuoted(quoted, start+1, i) + open, start = none, -1 + } } } - return inQuotes + // A region left open at end-of-line was never closed: its bytes stay unquoted. + return quoted +} + +func markQuoted(quoted []bool, lo, hi int) { + for i := lo; i < hi; i++ { + quoted[i] = true + } } From 19b40762d58e8914fb26dfb63ca83304031808f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 14:23:05 +0000 Subject: [PATCH 3/4] test(plugins): rename loop byte var to satisfy varnamelen linter golangci-lint's varnamelen flagged the single-letter byte variable used across the quote-scanning loop (distance exceeds max-distance: 10). Use a descriptive name instead. --- plugins/init_hook_quoting_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/init_hook_quoting_test.go b/plugins/init_hook_quoting_test.go index 91ac5333d21..6b21fa0a606 100644 --- a/plugins/init_hook_quoting_test.go +++ b/plugins/init_hook_quoting_test.go @@ -108,21 +108,21 @@ func shellQuotedPositions(line string) []bool { open := none // the quote char of the region currently open, or none start := -1 // index of the opening quote of the current region for i := 0; i < len(line); i++ { - c := line[i] + char := line[i] switch open { case none: - if c == '\'' || c == '"' { - open = c + if char == '\'' || char == '"' { + open = char start = i } case '\'': // Inside single quotes nothing is special except the closing '. - if c == '\'' { + if char == '\'' { markQuoted(quoted, start+1, i) open, start = none, -1 } case '"': - if c == '"' && line[i-1] != '\\' { + if char == '"' && line[i-1] != '\\' { markQuoted(quoted, start+1, i) open, start = none, -1 } From d866a3d7435bc021165ca0570589321b83b839ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 14:39:56 +0000 Subject: [PATCH 4/4] ci: re-trigger checks The project-tests-only job failed only on the elixir example (mix deps.get / mix run), which is unrelated to this PR's plugin init_hook quoting change. Re-running to clear the flaky failure.