bash-glob.ts
3586 bytes
1import { lstatSync, opendirSync, statSync } from "node:fs";
2import { isAbsolute } from "node:path";
3import type { BashWord } from "./bash";
4
5const MAX_ENTRIES = 2048;
6const MAX_MATCHES = 256;
7const MAX_COMPONENTS = 32;
8
9function componentPattern(pattern: string): { matcher: RegExp; active: boolean } | undefined {
10 let source = "";
11 let active = false;
12 for (let index = 0; index < pattern.length; index += 1) {
13 const character = pattern[index];
14 if (character === "\\") {
15 const literal = pattern[++index];
16 if (literal === undefined) return undefined;
17 source += literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18 } else if (character === "*") {
19 if (pattern[index + 1] === "*") return undefined;
20 source += ".*";
21 active = true;
22 } else if (character === "?") {
23 source += ".";
24 active = true;
25 } else if (character === "[") {
26 active = true;
27 const end = pattern.indexOf("]", index + 1);
28 if (end === -1) return undefined;
29 const content = pattern.slice(index + 1, end);
30 if (!/^!?[A-Za-z0-9_.-]+$/.test(content)) return undefined;
31 source += `[${content.replace(/^!/, "^")}]`;
32 index = end;
33 } else source += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34 }
35 try {
36 return { matcher: new RegExp(`^${source}$`, "su"), active };
37 } catch {
38 return undefined;
39 }
40}
41
42export function resolveBashGlob(word: BashWord, cwd: string): string[] | undefined {
43 if (word.unresolved) return undefined;
44 if (word.glob === undefined) return [word.text];
45 const components = word.glob.split("/");
46 if (components.length > MAX_COMPONENTS) return undefined;
47 let paths = [isAbsolute(word.glob) ? "/" : ""];
48 const absolutePath = (path: string) => (isAbsolute(path) ? path : `${cwd}/${path}`);
49 let entries = 0;
50 try {
51 for (const component of components) {
52 if (!component) continue;
53 const compiled = componentPattern(component);
54 if (!compiled) return undefined;
55 const { matcher, active } = compiled;
56 const next: string[] = [];
57 for (const path of paths) {
58 if (!active) {
59 const literal = component.replace(/\\(.)/g, "$1");
60 next.push(`${path}${path && !path.endsWith("/") ? "/" : ""}${literal}`);
61 continue;
62 }
63 const directory = opendirSync(absolutePath(path));
64 try {
65 for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
66 if (++entries > MAX_ENTRIES) return undefined;
67 if (component.includes("[") && [...entry.name].some((character) => character.charCodeAt(0) > 127))
68 return undefined;
69 if (entry.name.startsWith(".") && !component.startsWith(".")) continue;
70 if (!matcher.test(entry.name)) continue;
71 next.push(`${path}${path && !path.endsWith("/") ? "/" : ""}${entry.name}`);
72 if (next.length > MAX_MATCHES) return undefined;
73 }
74 } finally {
75 directory.closeSync();
76 }
77 }
78 paths = next;
79 }
80 const matches = paths.filter((path) => {
81 try {
82 const metadata = word.text.endsWith("/") ? statSync(absolutePath(path)) : lstatSync(absolutePath(path));
83 return !word.text.endsWith("/") || metadata.isDirectory();
84 } catch (error) {
85 if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
86 throw error;
87 }
88 });
89 return matches.length ? matches.sort().map((path) => path + (word.text.endsWith("/") ? "/" : "")) : [word.text];
90 } catch {
91 return undefined;
92 }
93}