Skip to content

Glob walk: a drive-root base no longer drops the pattern, and three ways a failure could lose a match - #575

Open
ronleizrowice-ant wants to merge 8 commits into
mainfrom
fix/glob-walk-root-base-and-failures
Open

ronleizrowice-ant wants to merge 8 commits into
mainfrom
fix/glob-walk-root-base-and-failures

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The walk behind a read-deny glob reads its pattern as an automaton, one path component at a time (#564). Reading it again turned up one base it cannot start from and three ways a failure loses a match instead of reporting it. The first drops a whole configuration entry on Windows, in silence.

What was wrong

  • A drive root or a share root as the base. path.dirname keeps the separator of a root, so the base of C:\Users*\**\.env is C:/ and of \\server\share\x*\y is //server/share/. Split into path components to seed the automaton, either ends in an empty name no position can consume: the walk starts nowhere, nothing matches, and the entry is dropped with no error and no warning. That is every denyRead, denyWrite, allowRead and allowWrite glob of that shape on Windows.
  • A listing failure cached against the directory. Its positions were marked before the listing and never rolled back, so a transient EMFILE, or a real path too long to name, answered for every later name that led there and dropped every match beneath it.
  • A second name answering for the first. A failed call was retried under a shorter name whatever the errno, and the caller judged the second call's: EACCES on the real path then ENOENT on the short one reads as "nothing is there" -- no match, nothing unlistable, no deny.
  • A link met by a pattern that cannot be split. Such a pattern matches whole paths and lists no directory through a symlink, so a link out of the tree dropped whatever the pattern matches in there.
  • The weaker reading, taken in silence. A pattern the automaton could not be built for fell back to whole-path matching with nothing said, and one long enough to overflow the stack reached that fallback through a RangeError.

Change

A base never carries a trailing separator, so a Windows pattern whose wildcard sits in the component right below a drive root or a share root (C:\Users*\**\.env, \\server\share\x*\y) is now walked instead of matching nothing; a drive root is handed to the filesystem as C:/, since C: alone is the drive's current directory. Only / and a pattern with no literal directory at all are refused, as before, in the walk and in the manager's glob warnings through one predicate; a ** right below a drive or share root lists that volume. An earlier head of this branch refused a drive root and a share root as it refuses /, which also dropped the shapes the last release enforces (\\server\share\*.pem, D:\**\*.key); that refusal is gone. A failure is uncached again: one record per real directory, named once in unlisted, retried under each name that leads to it. A call answers from a second name only for a path too long to be one, and logs which it used. A link out of the tree that an unsplittable pattern cannot be followed through is denied whole, except one that leads back up to the starting directory or above it: such a link is left alone, as it is for a pattern that can be followed, because what it leads to holds the starting directory itself, and denying that whole would hide it, everything beside it and every path bound back beneath it, over one link anything able to write the tree can make; the links the walk does follow are named where it follows them. Both regular expressions are compiled inside the try that guards the automaton: a pattern that compiles into none is raised under its own name, one too long to build is refused at a bound on the piece count, and either fallback warns. Cleanups: the directory-form automaton is built only for the caller that asks, positions are not sorted for an order nothing reads, the automaton's state is held rather than cast.

Who sees a difference

  • A Windows glob whose first wildcard sits in the component below a drive or share root was dropped in silence and is now walked and enforced, as one whose wildcard starts that component always was. A ** there lists the whole volume, at initialize(); a rule of that shape is better off naming the directories it means (C:\Users\*\... starts from C:\Users). For a drive-root pattern on the working directory's own drive, main listed the current directory and spelled the results as if they were under the root, so nothing was stamped; handing the root over as C:/ puts that right as well.
  • An unsplittable denyRead glob now mounts over the directory a link out of the tree leads to, and binds nothing back beneath it.
  • A directory that cannot be listed is denied whole where a transient failure used to lose it.

Not done here

  • globToRegex itself: globToRegex: negate bracket sets, and compile from tokens so no path text can become a wildcard #573 rewrites it. This branch keeps to walkGlobPattern, globPositions and globPatternBaseDir; the two meet in one README line and in the piece bound, which counts what globPieces returns either way. globToRegex: negate bracket sets, and compile from tokens so no path text can become a wildcard #573 also drops one of the three shapes the README calls unsplittable, so that clause is left to it.
  • A link whose target cannot be looked at (a directory above the target is not searchable), and that the pattern would continue through rather than match, is not followed and nothing is denied for it, whether or not the pattern can be split; for one that can, that is unchanged from the last release. Denying it whole would hide the directory holding it, which under a ** pattern is any directory with such a link in it, the project root included. A link of that kind that is itself a match is still denied under its own spelling. The README says so.
  • Nothing here was run on Windows. The drive-root walk is driven on Linux through a directory named C: with path's Windows semantics spied in; a share root is covered by the pure functions only, since a leading // does not survive path normalisation on POSIX.
  • The refusal of a pattern that compiles into no regular expression has no test: the only patterns reaching it are the unterminated bracket sets globToRegex: negate bracket sets, and compile from tokens so no path text can become a wildcard #573 makes legal.

Testing

tsc --noEmit, eslint and prettier --check clean on the changed files; full npm test on Linux matches a clean checkout of main on the same machine. Against main's sources, with only the new predicate carried over, all five new behaviour tests fail. The three added last (a link that leads up is in neither the matches nor the unlisted directories, and no mount is put over what holds the tree; a drive root and a share root are not refused and their bases carry no separator; the walk from a drive-shaped base finds its matches) fail at the head before them and fail again with each fix taken back out.

Live, under bubblewrap: with denyRead: ["<root>/proj/**/?[*].pem"] and proj/away -> outside, main reads outside/b].pem in full inside the sandbox where this branch finds an empty directory, and a match inside the tree stays masked in both. The property test compares the walk with globToRegex over four generated trees and 500 patterns in about 30ms.

…or on one

path.dirname keeps the separator of a root it returns, so the base of a
pattern whose first wildcard sits right below one is 'C:/', or
'//server/share/' on a share. Split into path components to seed the walk,
either ends in an empty name no position can consume: the automaton starts
nowhere, the pattern matches nothing, and the entry is dropped with no error
and no warning. On Windows that is every denyRead, denyWrite, allowRead and
allowWrite glob of the shape C:\Users*\**\.env.

A base now never carries a separator, and one that is a filesystem root --
'/', a drive root, the root of a UNC share -- is refused and warned about the
way the POSIX root already was, in the walk and in the manager's glob-pattern
warnings, through one predicate so that the two cannot drift.
…ough a link

A pattern that cannot be read one path component at a time is matched against
whole paths, and no directory is listed through a symlink under it. A link
leading out of the walk's tree was dropped: what the pattern matches in there
is found under no other name, so the deny lost it. Such a link is now recorded
as a place the walk could not enumerate, which the read-deny expansion denies
whole and binds nothing back beneath. A link within the tree still loses
nothing, since every directory there is listed under its own name.

The link the walk follows is now named where it is followed, which is the one
place that knows it: what is found through it is reported where it really
lives, so the warning about a mount outside the pattern's base cannot name it.
The positions a directory had been listed for were marked before the listing
and never rolled back, so a listing that failed answered for every later name
that led there: a transient EMFILE, or a real path too long to name, dropped
every match beneath the directory for the rest of the walk. A failure is
uncached again, with one record per real directory, so the directory is named
once in `unlisted` however many names fail on it, and a retry costs one
listing per name that leads to it -- each of them an entry of a directory that
is itself listed once per position.

A filesystem call also no longer answers from a second name for anything but a
path too long to be one. Every other errno belongs to the object rather than
to the name, and the shorter name crosses links a sandboxed command owns:
EACCES on the real path followed by ENOENT on the short one read as "nothing
is there", which is the fail-open the unlistable and uninspectable branches
exist to prevent.
Both of globPositions' regular expressions are compiled inside the try that
guards the automaton, and a pattern that compiles into no regular expression
at all is raised under its own name rather than thrown from wherever the
sandbox happened to be setting up. A pattern the automaton cannot be built for
-- including one so long that building it would overflow the stack, which a
bound on the piece count now refuses -- falls back to matching whole real
paths with a warning that says so, rather than quietly covering less: on a
deny path that fallback also stops the walk descending symlinked directories.

Beside that: the directory-form automaton is built only for the caller that
asks for it, the positions are no longer sorted on every step for an order
nothing reads, and the state written back into the automaton is held rather
than cast.
… the separator

Nothing in the tree held the walk's automaton and globToRegex together, though
they are the two halves of one rule and are edited by different hands. A
seeded, bounded property test compares them over generated trees and patterns:
what the walk returns must be exactly what the regular expression matches
beneath the pattern's base. It takes about 30ms.

Also: a bracket range that holds the separator ([+-9]) joins the set that does
([s/]), and the fixture that builds a path near PATH_MAX no longer spins for
ever when the temporary directory is exactly the length that leaves it nothing
to add.
A link back up the tree is not descended, so it denies what it reaches only
when the link is itself a match; the README said it always did. It now also
says what a pattern that cannot be split does with a link that leads out of
the starting directory.
@ronleizrowice-ant ronleizrowice-ant mentioned this pull request Sep 19, 2026
The spy type is not disposable under the type packages the test
typecheck runs with, so `using spy = spyOn(...)` does not compile there.
Each becomes a const restored in a finally, as the other suites do it,
with the same scope the declaration had.
…base

Two things the earlier commits on this branch got wrong.

A pattern that cannot be read one component at a time denies whole a
link that leads out of its starting directory, since nothing in there is
listed under any name. "Out of the starting directory" included every
ANCESTOR of it, and the test that leaves a link leading up alone came
after that branch, where such a pattern never reaches it. So with an
unsplittable deny glob, one `ln -s ../.. x` in the tree put a tmpfs over
the directory holding the project, with nothing bound back beneath it,
for every later command. A link that leads back up to the starting
directory or above it is now left alone, as it is for a pattern that
can be followed.

The refusal of a filesystem root as a base was written for `/`, which is
refused so that a whole filesystem is not listed. It also matched a
drive root and a share root, so every Windows pattern whose only literal
directory is one (`\\server\share\*.pem`, `D:\**\*.key`) matched nothing
and was not enforced, where the last release walks and enforces them.
Only `/` and a pattern with no literal directory are refused now. A base
never carries a trailing separator; a drive root is handed to the
filesystem as `C:/`, since `C:` alone is that drive's current directory.

The README also says what is still not covered: a link whose target
cannot be looked at, and that the pattern would continue through rather
than match, is not followed and nothing is denied for it.

This branch has not been deployed

No deployments
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.

1 participant