@@ -18,14 +18,24 @@ use walkdir::WalkDir;
1818
1919use crate :: path;
2020
21- /// Compiles a glob pattern with `literal_separator` so `*` does not cross `/`
22- /// while `**` does (the conventional shell/Bun semantics).
23- fn glob_matcher ( pattern : & str ) -> Result < globset:: GlobMatcher , ProviderError > {
24- GlobBuilder :: new ( pattern)
21+ /// Compiles a glob pattern into a matcher plus a "negated" flag, covering the
22+ /// full conventional set: `?`, `*` (not crossing `/`), `**` (crossing), `[ab]`,
23+ /// `[a-z]`, `[!abc]` **and** `[^abc]`, `{a,b}`, `\` escaping, and a leading `!`
24+ /// that negates the whole pattern.
25+ fn parse_glob ( pattern : & str ) -> Result < ( globset:: GlobMatcher , bool ) , ProviderError > {
26+ // A leading `!` negates; `\!…` is a literal `!` (globset unescapes it).
27+ let ( negated, body) = match pattern. strip_prefix ( '!' ) {
28+ Some ( rest) => ( true , rest. to_string ( ) ) ,
29+ None => ( false , pattern. to_string ( ) ) ,
30+ } ;
31+ // Accept the `[^…]` negated-class form (globset spells it `[!…]`).
32+ let body = body. replace ( "[^" , "[!" ) ;
33+ let matcher = GlobBuilder :: new ( & body)
2534 . literal_separator ( true )
2635 . build ( )
2736 . map ( |g| g. compile_matcher ( ) )
28- . map_err ( |e| ProviderError :: Other ( format ! ( "invalid glob pattern {pattern:?}: {e}" ) ) )
37+ . map_err ( |e| ProviderError :: Other ( format ! ( "invalid glob pattern {pattern:?}: {e}" ) ) ) ?;
38+ Ok ( ( matcher, negated) )
2939}
3040
3141/// A [`FileSystem`] over the real OS, jailed to `root`. Relative paths resolve
@@ -241,7 +251,8 @@ impl FileSystem for SystemFileSystem {
241251 }
242252
243253 fn glob_match ( & self , pattern : & str , path : & str ) -> Result < bool , ProviderError > {
244- Ok ( glob_matcher ( pattern) ?. is_match ( path) )
254+ let ( matcher, negated) = parse_glob ( pattern) ?;
255+ Ok ( negated ^ matcher. is_match ( path) )
245256 }
246257
247258 fn glob_scan (
@@ -251,18 +262,27 @@ impl FileSystem for SystemFileSystem {
251262 opts : GlobScanOptions ,
252263 ) -> BoxFuture < Result < Vec < String > , ProviderError > > {
253264 let resolved = self . jailed ( & base) ;
265+ let root = self . root . clone ( ) ;
254266 Box :: pin ( async move {
255267 let base_real = resolved?;
256- let matcher = glob_matcher ( & pattern) ?;
268+ let ( matcher, negated ) = parse_glob ( & pattern) ?;
257269 let mut out = Vec :: new ( ) ;
258- // follow_links(false) keeps the walk from leaving the jail via a
259- // symlink; the base is already confined to root.
260- for entry in WalkDir :: new ( & base_real) . follow_links ( false ) {
270+ // Default: don't follow symlinks (can't leave the jail). When the
271+ // caller opts in, follow them but reject any entry whose real path
272+ // escapes the root.
273+ for entry in WalkDir :: new ( & base_real) . follow_links ( opts. follow_symlinks ) {
261274 let entry = entry. map_err ( |e| ProviderError :: Other ( format ! ( "glob scan: {e}" ) ) ) ?;
262275 let path = entry. path ( ) ;
263276 if path == base_real {
264277 continue ; // skip the base itself
265278 }
279+ if opts. follow_symlinks
280+ && path:: canonicalize ( path)
281+ . map ( |real| !path:: within_root ( & real, & root) )
282+ . unwrap_or ( false )
283+ {
284+ continue ; // a followed link left the jail
285+ }
266286 let rel = path. strip_prefix ( & base_real) . unwrap_or ( path) ;
267287 let rel_str = rel. to_string_lossy ( ) . replace ( '\\' , "/" ) ;
268288 if !opts. dot && rel_str. split ( '/' ) . any ( |c| c. starts_with ( '.' ) ) {
@@ -271,7 +291,7 @@ impl FileSystem for SystemFileSystem {
271291 if opts. only_files && !entry. file_type ( ) . is_file ( ) {
272292 continue ;
273293 }
274- if matcher. is_match ( & rel_str) {
294+ if negated ^ matcher. is_match ( & rel_str) {
275295 out. push ( if opts. absolute {
276296 path. to_string_lossy ( ) . into_owned ( )
277297 } else {
0 commit comments