diff --git a/packages/glob/__tests__/glob-search.test.ts b/packages/glob/__tests__/glob-search.test.ts index b1f7bb3f..d701a86b 100644 --- a/packages/glob/__tests__/glob-search.test.ts +++ b/packages/glob/__tests__/glob-search.test.ts @@ -359,54 +359,122 @@ describe('glob (search)', () => { }) it('detects cycle starting from symlink', async () => { - // Create the following layout: - // - // /file - // /symDir -> - const root: string = path.join(getTestTemp(), 'search_detects_cycle_starting_from_symlink'); - await fs.mkdir(root, {recursive: true}); - await fs.writeFile(path.join(root, 'file'), 'test file content'); - await createSymlinkDir(root, path.join(root, 'symDir')); + // Create the following layout: + // + // /file + // /symDir -> + const root: string = path.join( + getTestTemp(), + 'search_detects_cycle_starting_from_symlink' + ) + await fs.mkdir(root, {recursive: true}) + await fs.writeFile(path.join(root, 'file'), 'test file content') + await createSymlinkDir(root, path.join(root, 'symDir')) - const itemPaths = await glob.glob(path.join(root, 'symDir')); - expect(itemPaths).toHaveLength(2); - expect(itemPaths[0]).toBe(path.join(root, 'symDir')); - expect(itemPaths[1]).toBe(path.join(root, 'symDir', 'file')); - // todo: ? expect(itemPaths[2]).toBe(path.join(root, 'symDir', 'symDir')); - }); + const itemPaths = await glob.glob(path.join(root, 'symDir')) + expect(itemPaths).toHaveLength(2) + expect(itemPaths[0]).toBe(path.join(root, 'symDir')) + expect(itemPaths[1]).toBe(path.join(root, 'symDir', 'file')) + // todo: ? expect(itemPaths[2]).toBe(path.join(root, 'symDir', 'symDir')); + }) it('detects deep cycle starting from middle', async () => { - // Create the following layout: - // - // /file_under_root - // /folder_a - // /folder_a/file_under_a - // /folder_a/folder_b - // /folder_a/folder_b/file_under_b - // /folder_a/folder_b/folder_c - // /folder_a/folder_b/folder_c/file_under_c - // /folder_a/folder_b/folder_c/sym_folder -> - const root = path.join(getTestTemp(), 'search_detects_deep_cycle_starting_from_middle'); - await fs.mkdir(path.join(root, 'folder_a', 'folder_b', 'folder_c'), {recursive: true}); - await fs.writeFile(path.join(root, 'file_under_root'), 'test file under root contents'); - await fs.writeFile(path.join(root, 'folder_a', 'file_under_a'), 'test file under a contents'); - await fs.writeFile(path.join(root, 'folder_a', 'folder_b', 'file_under_b'), 'test file under b contents'); - await fs.writeFile(path.join(root, 'folder_a', 'folder_b', 'folder_c', 'file_under_c'), 'test file under c contents'); - await createSymlinkDir(root, path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder')); - await fs.stat(path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder', 'file_under_root')) + // Create the following layout: + // + // /file_under_root + // /folder_a + // /folder_a/file_under_a + // /folder_a/folder_b + // /folder_a/folder_b/file_under_b + // /folder_a/folder_b/folder_c + // /folder_a/folder_b/folder_c/file_under_c + // /folder_a/folder_b/folder_c/sym_folder -> + const root = path.join( + getTestTemp(), + 'search_detects_deep_cycle_starting_from_middle' + ) + await fs.mkdir(path.join(root, 'folder_a', 'folder_b', 'folder_c'), { + recursive: true + }) + await fs.writeFile( + path.join(root, 'file_under_root'), + 'test file under root contents' + ) + await fs.writeFile( + path.join(root, 'folder_a', 'file_under_a'), + 'test file under a contents' + ) + await fs.writeFile( + path.join(root, 'folder_a', 'folder_b', 'file_under_b'), + 'test file under b contents' + ) + await fs.writeFile( + path.join(root, 'folder_a', 'folder_b', 'folder_c', 'file_under_c'), + 'test file under c contents' + ) + await createSymlinkDir( + root, + path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder') + ) + await fs.stat( + path.join( + root, + 'folder_a', + 'folder_b', + 'folder_c', + 'sym_folder', + 'file_under_root' + ) + ) - const itemPaths = await glob.glob(path.join(root, 'folder_a', 'folder_b')); - expect(itemPaths).toHaveLength(8); - expect(itemPaths[0]).toBe(path.join(root, 'folder_a', 'folder_b')); - expect(itemPaths[1]).toBe(path.join(root, 'folder_a', 'folder_b', 'file_under_b')); - expect(itemPaths[2]).toBe(path.join(root, 'folder_a', 'folder_b', 'folder_c')); - expect(itemPaths[3]).toBe(path.join(root, 'folder_a', 'folder_b', 'folder_c', 'file_under_c')); - expect(itemPaths[4]).toBe(path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder')); - expect(itemPaths[5]).toBe(path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder', 'file_under_root')); - expect(itemPaths[6]).toBe(path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder', 'folder_a')); - expect(itemPaths[7]).toBe(path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder', 'folder_a', 'file_under_a')); - // todo: ? expect(itemPaths[8]).toBe(path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder', 'folder_a', 'folder_b')); - }); + const itemPaths = await glob.glob(path.join(root, 'folder_a', 'folder_b')) + expect(itemPaths).toHaveLength(8) + expect(itemPaths[0]).toBe(path.join(root, 'folder_a', 'folder_b')) + expect(itemPaths[1]).toBe( + path.join(root, 'folder_a', 'folder_b', 'file_under_b') + ) + expect(itemPaths[2]).toBe( + path.join(root, 'folder_a', 'folder_b', 'folder_c') + ) + expect(itemPaths[3]).toBe( + path.join(root, 'folder_a', 'folder_b', 'folder_c', 'file_under_c') + ) + expect(itemPaths[4]).toBe( + path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder') + ) + expect(itemPaths[5]).toBe( + path.join( + root, + 'folder_a', + 'folder_b', + 'folder_c', + 'sym_folder', + 'file_under_root' + ) + ) + expect(itemPaths[6]).toBe( + path.join( + root, + 'folder_a', + 'folder_b', + 'folder_c', + 'sym_folder', + 'folder_a' + ) + ) + expect(itemPaths[7]).toBe( + path.join( + root, + 'folder_a', + 'folder_b', + 'folder_c', + 'sym_folder', + 'folder_a', + 'file_under_a' + ) + ) + // todo: ? expect(itemPaths[8]).toBe(path.join(root, 'folder_a', 'folder_b', 'folder_c', 'sym_folder', 'folder_a', 'folder_b')); + }) // it('normalizes find path', (done: MochaDone) => { // this.timeout(1000); diff --git a/packages/glob/src/glob.ts b/packages/glob/src/glob.ts index c3f09538..64aa97e4 100644 --- a/packages/glob/src/glob.ts +++ b/packages/glob/src/glob.ts @@ -30,7 +30,8 @@ export async function glob( // Search const result: string[] = [] for (const searchPath of searchPaths) { - // Skip if not exists + // Exists? Note, intentionally using lstat. Detection for broken symlink + // will be performed later (if following symlinks). try { await fs.promises.lstat(searchPath) } catch (err) { diff --git a/packages/glob/src/internal-pattern.ts b/packages/glob/src/internal-pattern.ts index a968b354..9ba321d6 100644 --- a/packages/glob/src/internal-pattern.ts +++ b/packages/glob/src/internal-pattern.ts @@ -33,28 +33,8 @@ export class Pattern { pattern = pattern.substr(1) } - // Empty - assert(pattern, 'pattern cannot be empty') - - // On Windows, do not allow paths like C: and C:foo (for simplicity) - assert( - !IS_WINDOWS || !/^([A-Z]:|[A-Z]:[^\\/].*)$/i.test(pattern), - `The pattern '${pattern}' uses an unsupported root-directory prefix. When a drive letter is specified, use absolute path syntax.` - ) - - // Root the pattern - if (!pathHelper.isRooted(pattern)) { - // Escape glob characters - let root = process.cwd() - root = (IS_WINDOWS ? root : root.replace(/\\/g, '\\\\')) // escape '\' on Linux/macOS - .replace(/(\[)(?=[^/]+\])/g, '[[]') // escape '[' when ']' follows within the path segment - .replace(/\?/g, '[?]') // escape '?' - .replace(/\*/g, '[*]') // escape '*' - pattern = pathHelper.ensureRooted(root, pattern) - } - - // Normalize slashes - pattern = pathHelper.normalizeSeparators(pattern) + // Normalize slashes and ensure rooted + pattern = this.fixupPattern(pattern) // Trailing slash indicates the pattern should only match directories, not regular files this.trailingSlash = pathHelper @@ -116,6 +96,41 @@ export class Pattern { ) } + /** + * Normalizes slashes and roots the pattern + */ + private fixupPattern(pattern: string): string { + // Empty + assert(pattern, 'pattern cannot be empty') + + // Replace leading `.` segment + pattern = pathHelper.normalizeSeparators(pattern) + if (pattern === '.' || pattern.startsWith(`.${path.sep}`)) { + pattern = this.globEscape(process.cwd()) + pattern.substr(1) + } + + // Otherwise `.` and `..` segments not allowed + if ( + pattern === '..' || + pattern.startsWith(`..${path.sep}`) || + pattern.includes(`${path.sep}.${path.sep}`) || + pattern.includes(`${path.sep}..${path.sep}`) || + pattern.endsWith(`${path.sep}.`) || + pattern.endsWith(`${path.sep}..`) + ) { + throw new Error( + `Invalid pattern '${pattern}'. Relative pathing '.' and '..' is not allowed.` + ) + } + + // Root the pattern + if (!pathHelper.isRooted(pattern)) { + pattern = pathHelper.ensureRooted(this.globEscape(process.cwd()), pattern) + } + + return pattern + } + /** * Initializes the search path and root regexp */ @@ -123,6 +138,21 @@ export class Pattern { // Parse the pattern as a path const patternPath = new Path(pattern) + // On Windows, do not allow paths like C: and C:foo (for simplicity) + assert( + !IS_WINDOWS || !/^[A-Z]:$/i.test(patternPath.segments[0]), + `The pattern '${pattern}' uses an unsupported root-directory prefix. When a drive letter is specified, use absolute path syntax.` + ) + + // No relative pathing + for (const patternSegment of patternPath.segments) { + const literal = this.convertToLiteral(patternSegment) + assert( + literal !== '.' && literal !== '..', + `Invalid pattern. Relative pathing '.' and '..' is not allowed. Pattern '${pattern}'` + ) + } + // Build the search path this.searchPath = '' for (const patternSegment of patternPath.segments) { @@ -217,6 +247,16 @@ export class Pattern { return literal } + /** + * Escapes glob patterns within a path + */ + private globEscape(s: string): string { + return (IS_WINDOWS ? s : s.replace(/\\/g, '\\\\')) // escape '\' on Linux/macOS + .replace(/(\[)(?=[^/]+\])/g, '[[]') // escape '[' when ']' follows within the path segment + .replace(/\?/g, '[?]') // escape '?' + .replace(/\*/g, '[*]') // escape '*' + } + /** * Escapes regexp special characters * https://javascript.info/regexp-escaping