Skip to content

Index workspaces whose path contains glob metacharacters - #4183

Open
andriytyurnikov wants to merge 2 commits into
Shopify:mainfrom
andriytyurnikov:configuration-glob-metacharacters
Open

Index workspaces whose path contains glob metacharacters#4183
andriytyurnikov wants to merge 2 commits into
Shopify:mainfrom
andriytyurnikov:configuration-glob-metacharacters

Conversation

@andriytyurnikov

Copy link
Copy Markdown

Fixes #4024, which was auto-closed as stale by the bot rather than by a decision, and needs reopening.

Important

Stacked on #4022. Until that merges, the diff shown here also contains its commits. The only commit belonging to this PR is Index workspaces whose path contains glob metacharacters — two files, configuration.rb and configuration_test.rb.

It genuinely depends on #4022 rather than merely following it: without the escaping fix, a bracketed path raises URI::InvalidComponentError in URI::Generic.build before this code is reached.

The problem

#4022 makes a bracketed file work. A bracketed workspace root still indexes zero files, so every feature is dead for the entire project rather than for one file in it.

# workspace: /tmp/.../[id]/{slug}, containing app/nested.rb and top_level.rb
config.instance_variable_get(:@included_patterns) #=> ["{}/**/*.rb", "*.rb"]
config.indexable_uris                             #=> []

Two causes compound.

top_level_directories globs "#{Dir.pwd}/*", which returns nothing when the working directory holds metacharacters. @included_patterns is built from it as "{#{top_level_directories.join(",")}}/**/*.rb", so an empty result degenerates to {}/**/*.rb. Independently, indexable_uris interpolates the workspace path into its own pattern, so even the *.rb fallback matches nothing.

Changes

Pass paths as base: instead of interpolating them. Dir.glob's base: treats its argument as a literal directory, which is correct on every platform — unlike backslash escaping, which Ruby does not support on Windows. Applied to the workspace path in indexable_uris, to Dir.pwd in top_level_directories, and to gem require paths, which land under the workspace whenever BUNDLE_PATH points inside the project.

Guard the empty case rather than converting it. Dir.glob("{}/**/*.rb", base: dir) does not return nothing — it escapes the base and walks the file system from the root, raising Errno::EPERM on macOS protected directories. This is the Ruby bug that made #4022 defer the whole file. Never constructing the empty brace group sidesteps it entirely, and is what unblocks the rest.

Match exclusion patterns against the workspace-relative path. File.fnmatch? reads brackets as a character class too, so File.join(@workspace_path, pattern) silently disabled every user-configured exclusion under such a workspace:

File.fnmatch?("/ws/[id]/ignore/**/*.rb", "/ws/[id]/ignore/excluded.rb", flags) #=> false

Absolute patterns keep matching the absolute path; relative ones now match the relative path, so the workspace path never becomes part of a pattern. The prefix is normalised before being stripped, because it originates from a client-supplied URI that may carry a trailing slash — something the previous File.join absorbed for free.

Deliberately not converted

The Ruby installation paths (RbConfig::CONFIG["rubylibdir"] and the default gem directories below it) are left interpolated. Reaching that bug requires a bracketed Ruby install, not a bracketed project, and base: reads distinctly worse there — one of the two call sites went from three lines to seven. The line drawn here is: harden the paths a user's project layout controls.

Tests

Four tests, each verified to fail when its fix is reverted:

Test Covers
..._with_glob_metacharacters_in_the_workspace_path nested and top-level files under [id]/{slug} — the headline, was []
test_excluded_patterns_apply_when_..._glob_metacharacters exclusions still apply under such a workspace
test_excluded_patterns_apply_when_..._a_trailing_slash the normalised prefix
..._in_a_workspace_without_indexable_subdirectories the empty {} case, which otherwise raises Errno::EPERM

The last one is worth calling out: it passes on main and fails only against the base: conversion, so it exists specifically to pin the Ruby bug that made this file risky to touch.

One note on how these were checked. The trailing-slash test initially passed for the wrong reason — the file was never indexed, so refute_includes was vacuously true. It only showed up by reverting the fix and seeing the test still pass. It now sets included_patterns so the exclusion is genuinely exercised.

Test plan

  • bundle exec rake — 334 + 18,565 runs, 0 failures, 0 errors
  • bundle exec srb tc — no errors
  • bin/rubocop — no offenses
  • Bracketed workspace verified end to end: @included_patterns goes from {}/**/*.rb to {app}/**/*.rb, and indexable_uris from [] to both files

…acters

Fixes Shopify#3503. Contributes to Shopify#3639.

A URI identifies a resource, so the server and the editor have to agree
on how a file is spelled. If indexing builds `file:///a/%5Bid%5D.rb` for
a file the editor calls `file:///a/[id].rb`, the same file is tracked
under two identities: indexed twice, reported twice, and never resolved
to the document the editor has open. Two independent things break that
agreement today.

`URI::Generic.from_path` used the RFC 2396 safe set. VS Code builds its
URIs with `vscode-uri`, which leaves only the RFC 3986 unreserved set
(`A-Za-z0-9-._~`) plus `/` unescaped. Compared against that package
directly, 12 of 17 sample paths disagreed, and not only on brackets:
`(`, `)`, `!`, `'`, `*`, `$`, `&`, `+`, `,`, `;`, `=` and `@` were all
left unescaped here and escaped there. Narrowing the safe set to
`[^\-_.~a-zA-Z\d/]` brings that to 17 of 17 with every round trip
intact, and fixes `from_path` raising `URI::InvalidComponentError` for a
path containing `?`, which used to be treated as safe and produced a
string `URI::Generic.build` rejected.

Separately, `Dir.glob` reads `[id]` as a character class and `{a,b}` as
an alternation, so interpolating a workspace or file path into a pattern
makes it silently match nothing. `references.rb`, `rename.rb`,
`addon.rb`, `test_style.rb` and `go_to_relevant_file.rb` now pass the
path as `base:`, which treats it literally on every platform, including
Windows, where backslash escaping is unavailable. Content that is
genuinely user input rather than a path is escaped instead, through
`RubyLsp.escape_glob_metacharacters`: `require_relative` content in the
completion listener and file names in `go_to_relevant_file.rb`. The
comma is escaped too, because `complete_require_relative` interpolates
into a brace group where an unescaped comma splits the alternation.

Adding a bracketed fixture surfaced a pre-existing bug in the test
harness. Seven expectation classes built their URI as
`URI("file://#{@_path}")` from a relative path, so
`file://test/fixtures/foo.rb` parsed `test` as the authority and the
path came out as `/fixtures/foo.rb`, dropping the `test/` segment.
`CodeLens` appends `-Itest` when the path matches `**/test/**/*`, so
with that segment missing, five committed expectations asserted commands
without `-Itest`, the opposite of what a user gets for a file genuinely
under `test/`. They are regenerated here; across all 324 modified lines
the only changes are the restored segment and the flag it enables. The
runner also looks expectations up with `File.file?` rather than a glob,
and sanitises fixture names when generating method names, without which
the new fixture yields `def test_hover__[id]__does_not_raise` and every
expectation suite fails to parse.

Every test added was verified to fail when its corresponding fix is
reverted.

The Windows drive letter case is deliberately left alone. `vscode-uri`
lower cases it regardless of input while `from_path` preserves it, which
is Shopify#3285, closed but only half fixed. Two committed tests assert the
current behaviour and `server.rb` has case-sensitive
`start_with?(@global_state.workspace_path)` guards that would have to
move at the same time, so normalising only `from_path` would trade one
Windows bug for another. Bracketed workspace roots are also out of scope
here and tracked in Shopify#4024.
A workspace root containing `[`, `]`, `{` or `}` indexes zero files, so
every feature is dead for the whole project rather than for a single
file. Two causes compound.

`top_level_directories` globs `"#{Dir.pwd}/*"`, which returns nothing
when the working directory holds metacharacters. `@included_patterns` is
built from it as `"{#{top_level_directories.join(",")}}/**/*.rb"`, so it
degenerates to `{}/**/*.rb`. Independently, `indexable_uris` interpolates
the workspace path into its pattern, so even the `*.rb` fallback matches
nothing. Both now pass the path as `Dir.glob`'s `base:`, which treats it
literally on every platform, including Windows, where the backslash
escaping used elsewhere is unavailable.

The empty case is guarded rather than converted. `Dir.glob("{}/**/*.rb",
base: dir)` does not return nothing: it escapes the base and walks the
file system from the root, raising `Errno::EPERM` on macOS protected
directories. Never constructing the empty brace group avoids depending on
that behaviour at all.

Exclusion patterns had the same exposure through `File.fnmatch?`, which
reads brackets as a character class too, silently disabling every user
configured exclusion under such a workspace. Relative patterns are now
matched against the workspace relative path, so the workspace path never
becomes part of a pattern. The prefix is normalised first because it
originates from a client supplied URI that may carry a trailing slash,
which the previous `File.join` absorbed.

Gem require paths are converted as well, since a `BUNDLE_PATH` inside a
bracketed workspace places them under it. The Ruby installation paths are
deliberately left alone: reaching them requires a bracketed Ruby install
rather than a bracketed project, and `base:` reads worse there.

Each of the four tests fails when its corresponding fix is reverted.
@andriytyurnikov
andriytyurnikov force-pushed the configuration-glob-metacharacters branch from bd84822 to d0a263f Compare July 31, 2026 23:22
@andriytyurnikov
andriytyurnikov marked this pull request as ready for review July 31, 2026 23:25
@andriytyurnikov
andriytyurnikov requested a review from a team as a code owner July 31, 2026 23:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dir.glob in configuration.rb vulnerable to bracket/brace characters in workspace path

1 participant