import { globSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; // --------------------------------------------------------------------------- // Bash path extraction — enhanced with glob, variable, tilde expansion // --------------------------------------------------------------------------- /** * Scan command string for VAR=value assignments and return a map. * Handles: VAR=val, VAR="val", VAR='val', VAR=val cmd, VAR=val && cmd */ function extractVars(command: string): Map { const vars = new Map(); // Match VAR=value patterns (not inside quotes or existing strings) const re = /(?:^|[\s;&|]+)([A-Za-z_]\w*)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s;&|"']+)/g; let m: RegExpExecArray | null; while ((m = re.exec(command)) !== null) { let val = m[2]; if ( (val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")) ) { val = val.slice(1, -1); } vars.set(m[1], val); } return vars; } /** * Expand glob patterns in a path. Returns [path] if no glob, or expanded paths. * Only expands if the path contains *, ?, or [...]. */ function expandGlob(p: string): string[] { if (!/[*?[]/.test(p)) return [p]; try { const results = globSync(p).filter( (f) => { try { return !statSync(f).isDirectory(); } catch { return false; } }, ); return results.length > 0 ? results : [p]; } catch { return [p]; } } /** * Clean and expand a raw path extracted from a bash command. * Steps: strip quotes → tilde → $VAR from command vars → ${VAR} from env → glob * Returns empty string if path has unresolved shell variables. */ export function cleanPath( raw: string, cmdVars?: Map, ): string[] { let p = raw.trim(); // Strip surrounding quotes if ( (p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'")) ) { p = p.slice(1, -1); } // Tilde expansion if (p.startsWith("~/") || p === "~") { p = p.replace(/^~/, homedir()); } // Expand $VAR and ${VAR} — first from command-local vars, then process.env p = p.replace(/\$\{(\w+)\}/g, (_, name) => { if (cmdVars?.has(name)) return cmdVars.get(name)!; return process.env[name] ?? `$\{${name}}`; }); p = p.replace(/\$(\w+)/g, (_, name) => { if (cmdVars?.has(name)) return cmdVars.get(name)!; return process.env[name] ?? `$${name}`; }); // Unresolved shell variable → can't track if (/\$[{]/.test(p) || /\$\w/.test(p)) return []; // Glob expansion return expandGlob(p); } /** * Extract file paths from a bash command string. * Covers: redirections (>, >>, 2>), rm, mv, cp, touch, mkdir, echo >, cat >, tee, sed -i, perl -i */ export function extractBashPaths(command: string): string[] { const paths: string[] = []; const cmdVars = extractVars(command); // Helper: clean and push all expanded paths, filtering shell redirects const pushPaths = (raw: string) => { // Skip shell redirect tokens like "2>", "2>/dev/null", "1>&2" if (/^[0-2&]?>/.test(raw)) return; const expanded = cleanPath(raw, cmdVars); for (const ep of expanded) { if (ep && !paths.includes(ep)) paths.push(ep); } }; // Helper: push all whitespace-split paths from a captured arg group const pushAll = (raw: string) => { for (const token of raw.trim().split(/\s+/)) { if (token) pushPaths(token); } }; // 1. Redirections: >, >>, 2>, 2>>, &>, &>> for (const m of command.matchAll(/(?:[0-2&]?>>?|>>?)\s*([^\s;&|]+)/g)) { pushPaths(m[1]); } // 2. rm [flags] for (const m of command.matchAll(/\brm\s+(?:-[^\s]*\s+)*([^;&|]+)/g)) { pushAll(m[1]); } // 3. mv for (const m of command.matchAll( /\bmv\s+(?:-[^\s]*\s+)*([^\s;&|]+)\s+([^\s;&|]+)/g, )) { pushPaths(m[1]); pushPaths(m[2]); } // 4. cp [flags] for (const m of command.matchAll( /\bcp\s+(?:-[^\s]*\s+)*([^\s;&|]+)\s+([^\s;&|]+)/g, )) { pushPaths(m[1]); pushPaths(m[2]); } // 5. touch for (const m of command.matchAll(/\btouch\s+(?:-[^\s]*\s+)*([^;&|]+)/g)) { pushAll(m[1]); } // 6. mkdir for (const m of command.matchAll(/\bmkdir\s+(?:-[^\s]*\s+)*([^;&|]+)/g)) { pushAll(m[1]); } // 7. echo ... > file for (const m of command.matchAll(/\becho\s+.*?>\s*([^\s;&|]+)/g)) { pushPaths(m[1]); } // 8. cat > file for (const m of command.matchAll(/\bcat\s+.*?>\s*([^\s;&|]+)/g)) { pushPaths(m[1]); } // 9. tee [flags] for (const m of command.matchAll(/\btee\s+(?:-[^\s]*\s+)*([^;&|]+)/g)) { pushAll(m[1]); } // 10. sed -i / perl -i const sedMatch = command.match( /(?:sed|perl)\s+-i[^\s]*\s+(?:-e\s+[^\s]+\s+)?(?:[^\s]+\s+)?([^\s;&|]+)$/, ); if (sedMatch) pushPaths(sedMatch[1]); // 11. chmod for (const m of command.matchAll(/\bchmod\s+(?:-[^\s]*\s+)*[^\s]+\s+([^;&|]+)/g)) { pushAll(m[1]); } // 12. ln [flags] for (const m of command.matchAll( /\bln\s+(?:-[^\s]*\s+)*([^\s;&|]+)\s+([^\s;&|]+)/g, )) { pushPaths(m[1]); pushPaths(m[2]); } // 13. git mv for (const m of command.matchAll( /\bgit\s+mv\s+(?:-[^\s]*\s+)*([^\s;&|]+)\s+([^\s;&|]+)/g, )) { pushPaths(m[1]); pushPaths(m[2]); } // 14. git rm [flags] for (const m of command.matchAll(/\bgit\s+rm\s+(?:-[^\s]*\s+)*([^;&|]+)/g)) { pushAll(m[1]); } return paths; }