Skip to content
File

Blob: src/node/internal/internal_fs_glob.ts

typescript557 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5import { default as cffs } from 'cloudflare-internal:filesystem';
6import type { DirEntryHandle } from 'cloudflare-internal:filesystem';
7import { normalizePath } from 'node-internal:internal_fs_utils';
8 
9// UV_DIRENT_DIR constant from internal_fs_constants
10const UV_DIRENT_DIR = 2;
11 
12// Maximum recursion depth to prevent stack overflow on deeply nested VFS
13const MAX_WALK_DEPTH = 256;
14 
15// ============================================================================
16// Brace Expansion
17// ============================================================================
18 
19// Splits a string by top-level occurrences of a separator character,
20// respecting nested braces/parens and backslash escapes.
21function splitTopLevel(str: string, sep: string): string[] {
22 const parts: string[] = [];
23 let depth = 0;
24 let current = '';
25 
26 for (let i = 0; i < str.length; i++) {
27 const c = str.charAt(i);
28 if (c === '\\' && i + 1 < str.length) {
29 current += c + str.charAt(i + 1);
30 i++;
31 } else if (c === '{' || c === '(') {
32 depth++;
33 current += c;
34 } else if (c === '}' || c === ')') {
35 depth--;
36 current += c;
37 } else if (c === sep && depth === 0) {
38 parts.push(current);
39 current = '';
40 } else {
41 current += c;
42 }
43 }
44 
45 parts.push(current);
46 return parts;
47}
48 
49// Expands brace expressions in a glob pattern into multiple patterns.
50export function expandBraces(pattern: string): string[] {
51 let depth = 0;
52 let braceStart = -1;
53 
54 for (let i = 0; i < pattern.length; i++) {
55 const c = pattern[i];
56 if (c === '\\' && i + 1 < pattern.length) {
57 i++;
58 continue;
59 }
60 if (c === '{') {
61 if (depth === 0) braceStart = i;
62 depth++;
63 } else if (c === '}') {
64 depth--;
65 if (depth === 0 && braceStart !== -1) {
66 const prefix = pattern.slice(0, braceStart);
67 const body = pattern.slice(braceStart + 1, i);
68 const suffix = pattern.slice(i + 1);
69 
70 const alternatives = splitTopLevel(body, ',');
71 
72 if (alternatives.length === 1) {
73 return [pattern];
74 }
75 
76 const results: string[] = [];
77 for (const alt of alternatives) {
78 for (const expanded of expandBraces(prefix + alt + suffix)) {
79 results.push(expanded);
80 }
81 }
82 return results;
83 }
84 }
85 }
86 
87 return [pattern];
88}
89 
90// ============================================================================
91// Pattern Normalization
92// ============================================================================
93 
94export function normalizePattern(pattern: string): string {
95 // Strip leading ./
96 if (pattern.startsWith('./')) {
97 pattern = pattern.slice(2);
98 }
99 // Collapse multiple slashes to single
100 pattern = pattern.replace(/\/\/+/g, '/');
101 // Strip trailing slash
102 if (pattern.endsWith('/') && pattern.length > 1) {
103 pattern = pattern.slice(0, -1);
104 }
105 return pattern;
106}
107 
108// ============================================================================
109// Segment-Level Regex (with extglob support)
110// ============================================================================
111 
112// Converts a single path segment pattern to a RegExp.
113// Supports: *, ?, [...], [!...], @(...), *(...), +(...), ?(...), !(...)
114export function segmentToRegex(segment: string): RegExp {
115 const regex = segmentToRegexStr(segment);
116 return new RegExp('^' + regex + '$');
117}
118 
119function segmentToRegexStr(segment: string): string {
120 let regex = '';
121 let i = 0;
122 let inCharClass = false;
123 
124 while (i < segment.length) {
125 const c = segment[i] ?? '';
126 
127 // Handle escape sequences
128 if (c === '\\' && i + 1 < segment.length) {
129 regex += '\\' + escapeRegexChar(segment[i + 1] ?? '');
130 i += 2;
131 continue;
132 }
133 
134 // Inside character classes, most chars are literal
135 if (inCharClass) {
136 if (c === ']') {
137 regex += ']';
138 inCharClass = false;
139 } else {
140 regex += c;
141 }
142 i++;
143 continue;
144 }
145 
146 // Check for extglob: @(...), *(...), +(...), ?(...), !(...)
147 if (
148 (c === '@' || c === '*' || c === '+' || c === '?' || c === '!') &&
149 i + 1 < segment.length &&
150 segment[i + 1] === '('
151 ) {
152 const closeIdx = findMatchingParen(segment, i + 1);
153 if (closeIdx !== -1) {
154 const inner = segment.slice(i + 2, closeIdx);
155 // Convert pipe-separated alternatives, each may contain glob chars
156 const alts = splitTopLevel(inner, '|');
157 const altRegexes = alts.map((a) => segmentToRegexStr(a));
158 const group = altRegexes.join('|');
159 
160 switch (c) {
161 case '@': // exactly one
162 regex += '(?:' + group + ')';
163 break;
164 case '*': // zero or more
165 regex += '(?:' + group + ')*';
166 break;
167 case '+': // one or more
168 regex += '(?:' + group + ')+';
169 break;
170 case '?': // zero or one
171 regex += '(?:' + group + ')?';
172 break;
173 case '!': // none of (negative lookahead)
174 regex += '(?!(?:' + group + ')$)[^/]*';
175 break;
176 }
177 
178 i = closeIdx + 1;
179 continue;
180 }
181 }
182 
183 switch (c) {
184 case '[':
185 inCharClass = true;
186 regex += '[';
187 if (i + 1 < segment.length && segment[i + 1] === '!') {
188 regex += '^';
189 i++;
190 }
191 break;
192 
193 case '*':
194 regex += '[^/]*';
195 break;
196 
197 case '?':
198 regex += '[^/]';
199 break;
200 
201 case '.':
202 case '+':
203 case '^':
204 case '$':
205 case '|':
206 case '(':
207 case ')':
208 case '{':
209 case '}':
210 regex += '\\' + c;
211 break;
212 
213 default:
214 regex += c;
215 break;
216 }
217 i++;
218 }
219 
220 return regex;
221}
222 
223function findMatchingParen(str: string, openIdx: number): number {
224 let depth = 0;
225 for (let i = openIdx; i < str.length; i++) {
226 if (str[i] === '\\' && i + 1 < str.length) {
227 i++;
228 continue;
229 }
230 if (str[i] === '(') depth++;
231 else if (str[i] === ')') {
232 depth--;
233 if (depth === 0) return i;
234 }
235 }
236 return -1;
237}
238 
239function escapeRegexChar(c: string): string {
240 if ('.+*?^$|()[]{}\\'.includes(c)) {
241 return '\\' + c;
242 }
243 return c;
244}
245 
246// ============================================================================
247// Full-Path Regex (used for exclude pattern matching)
248// ============================================================================
249 
250// Converts a full glob pattern (with /) to a single RegExp for exclude matching.
251export function globToRegex(pattern: string): RegExp {
252 const normalized = normalizePattern(pattern);
253 const segments = normalized.split('/').filter((s) => s !== '');
254 const parts: string[] = [];
255 
256 for (const seg of segments) {
257 if (seg === '**') {
258 // ** matches zero or more path segments
259 // We handle this by inserting a special marker
260 parts.push('**');
261 } else if (seg === '.') {
262 // skip
263 } else if (seg === '..') {
264 parts.pop();
265 } else {
266 parts.push(segmentToRegexStr(seg));
267 }
268 }
269 
270 // Now build regex from parts, handling ** markers
271 let regex = '';
272 for (let i = 0; i < parts.length; i++) {
273 const part = parts[i] ?? '';
274 const prevPart = i > 0 ? (parts[i - 1] ?? '') : '';
275 if (part === '**') {
276 if (parts.length === 1) {
277 // ** alone: match everything
278 regex = '.*';
279 } else if (i === 0) {
280 // ** at start: match zero or more leading segments
281 regex += '(?:.*\\/)?';
282 } else if (i === parts.length - 1) {
283 // ** at end: match zero or more trailing segments
284 regex += '(?:\\/.*)?';
285 } else {
286 // ** in middle: match zero or more middle segments
287 regex += '(?:\\/[^/]+)*(?:\\/)?';
288 }
289 } else {
290 if (i > 0 && prevPart !== '**') {
291 regex += '\\/';
292 }
293 regex += part;
294 }
295 }
296 
297 return new RegExp('^' + regex + '$');
298}
299 
300// ============================================================================
301// Exclude Compiler
302// ============================================================================
303 
304// Compiles an array of glob patterns into an exclude function.
305// Only accepts string arrays — user-provided functions are handled
306// separately in globSync to preserve their (string | Dirent) signature.
307export function compileExcludePatterns(
308 exclude: readonly string[]
309): (path: string) => boolean {
310 const regexes: RegExp[] = [];
311 for (const pat of exclude) {
312 for (const expanded of expandBraces(pat)) {
313 regexes.push(globToRegex(expanded));
314 }
315 }
316 
317 return (path: string): boolean => {
318 for (const re of regexes) {
319 if (re.test(path)) return true;
320 }
321 return false;
322 };
323}
324 
325// ============================================================================
326// Directory Entry Cache
327// ============================================================================
328 
329type EntryCache = Map<string, DirEntryHandle[]>;
330 
331function getDirectoryEntries(
332 absPath: string,
333 cache: EntryCache
334): DirEntryHandle[] {
335 const cached = cache.get(absPath);
336 if (cached !== undefined) return cached;
337 
338 try {
339 const entries = cffs.readdir(normalizePath(absPath), { recursive: false });
340 cache.set(absPath, entries);
341 return entries;
342 } catch {
343 const empty: DirEntryHandle[] = [];
344 cache.set(absPath, empty);
345 return empty;
346 }
347}
348 
349function isDirectory(entry: DirEntryHandle): boolean {
350 return entry.type === UV_DIRENT_DIR;
351}
352 
353// ============================================================================
354// Pattern-Driven Directory Walk
355// ============================================================================
356 
357export interface GlobResult {
358 relativePath: string;
359 handle: DirEntryHandle | null;
360}
361 
362// Collapses consecutive '**' segments to prevent exponential blowup.
363export function collapseGlobstars(segments: string[]): string[] {
364 const result: string[] = [];
365 for (const seg of segments) {
366 if (seg === '**' && result[result.length - 1] === '**') continue;
367 result.push(seg);
368 }
369 return result;
370}
371 
372// Precompiles segment regexes for all non-special segments.
373export function precompileSegmentRegexes(
374 segments: string[]
375): (RegExp | null)[] {
376 return segments.map((seg) => {
377 if (seg === '**' || seg === '.' || seg === '..') return null;
378 return segmentToRegex(seg);
379 });
380}
381 
382export function walkGlob(
383 cwd: string,
384 segments: string[],
385 segIdx: number,
386 currentAbsPath: string,
387 relativePath: string,
388 results: Map<string, GlobResult>,
389 cache: EntryCache,
390 segmentRegexes: (RegExp | null)[],
391 visitedGlobstar?: Set<string>,
392 depth: number = 0
393): void {
394 // Guard against excessive recursion depth
395 if (depth >= MAX_WALK_DEPTH) return;
396 
397 // All segments consumed: this path is a match
398 if (segIdx >= segments.length) {
399 if (!results.has(relativePath)) {
400 // Resolve handle for the matched path by reading the parent directory
401 const lastSlash = currentAbsPath.lastIndexOf('/');
402 const parentDir =
403 lastSlash > 0 ? currentAbsPath.slice(0, lastSlash) : currentAbsPath;
404 const basename = relativePath.split('/').pop() ?? '';
405 const entries = getDirectoryEntries(parentDir, cache);
406 const handle = entries.find((e) => e.name === basename) ?? null;
407 results.set(relativePath, { relativePath, handle });
408 }
409 return;
410 }
411 
412 const seg = segments[segIdx] ?? '';
413 
414 // Handle '.' — stay in current directory
415 if (seg === '.') {
416 walkGlob(
417 cwd,
418 segments,
419 segIdx + 1,
420 currentAbsPath,
421 relativePath,
422 results,
423 cache,
424 segmentRegexes,
425 visitedGlobstar,
426 depth
427 );
428 return;
429 }
430 
431 // Handle '..' — go up one directory (but don't escape cwd)
432 if (seg === '..') {
433 if (currentAbsPath === cwd || !currentAbsPath.startsWith(cwd + '/')) {
434 return;
435 }
436 
437 const lastSlash = currentAbsPath.lastIndexOf('/');
438 const newAbs = lastSlash > 0 ? currentAbsPath.slice(0, lastSlash) : '/';
439 
440 const relParts = relativePath.split('/').filter(Boolean);
441 relParts.pop();
442 const newRel = relParts.join('/');
443 
444 walkGlob(
445 cwd,
446 segments,
447 segIdx + 1,
448 newAbs,
449 newRel,
450 results,
451 cache,
452 segmentRegexes,
453 visitedGlobstar,
454 depth
455 );
456 return;
457 }
458 
459 // Handle '**' — match zero or more directory levels
460 if (seg === '**') {
461 const gsKey = `${currentAbsPath}:${String(segIdx)}`;
462 if (visitedGlobstar === undefined) {
463 visitedGlobstar = new Set();
464 }
465 if (visitedGlobstar.has(gsKey)) return;
466 visitedGlobstar.add(gsKey);
467 
468 // Zero levels: advance to next segment at current path
469 walkGlob(
470 cwd,
471 segments,
472 segIdx + 1,
473 currentAbsPath,
474 relativePath,
475 results,
476 cache,
477 segmentRegexes,
478 visitedGlobstar,
479 depth
480 );
481 
482 // One or more levels: enumerate children
483 const entries = getDirectoryEntries(currentAbsPath, cache);
484 for (const entry of entries) {
485 const childAbs = currentAbsPath + '/' + entry.name;
486 const childRel = relativePath
487 ? relativePath + '/' + entry.name
488 : entry.name;
489 
490 // Try matching next segment against this child
491 walkGlob(
492 cwd,
493 segments,
494 segIdx + 1,
495 childAbs,
496 childRel,
497 results,
498 cache,
499 segmentRegexes,
500 visitedGlobstar,
501 depth + 1
502 );
503 
504 // If directory, recurse ** deeper
505 if (isDirectory(entry)) {
506 walkGlob(
507 cwd,
508 segments,
509 segIdx,
510 childAbs,
511 childRel,
512 results,
513 cache,
514 segmentRegexes,
515 visitedGlobstar,
516 depth + 1
517 );
518 }
519 }
520 return;
521 }
522 
523 // Regular segment: match against precompiled regex
524 const segRegex = segmentRegexes[segIdx] ?? segmentToRegex(seg);
525 const entries = getDirectoryEntries(currentAbsPath, cache);
526 
527 for (const entry of entries) {
528 if (segRegex.test(entry.name)) {
529 const childAbs = currentAbsPath + '/' + entry.name;
530 const childRel = relativePath
531 ? relativePath + '/' + entry.name
532 : entry.name;
533 
534 if (segIdx + 1 >= segments.length) {
535 // This is the last segment — record match
536 if (!results.has(childRel)) {
537 results.set(childRel, { relativePath: childRel, handle: entry });
538 }
539 } else {
540 // More segments to match — recurse
541 walkGlob(
542 cwd,
543 segments,
544 segIdx + 1,
545 childAbs,
546 childRel,
547 results,
548 cache,
549 segmentRegexes,
550 visitedGlobstar,
551 depth + 1
552 );
553 }
554 }
555 }
556}