Archived
feat: add external path access allowlist
This commit is contained in:
@@ -1,19 +1,36 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, relative, sep, win32 } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { sanitizedGitEnv } from "../git/gitEnv.js";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import type { ClientFileSuggestion } from "../types.js";
|
||||
import { createPathAccessPolicy, isAbsoluteishPath, resolvePathAccessTarget, type PathAccessPolicy } from "./pathAccessPolicy.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const commandMaxBuffer = 1024 * 1024 * 8;
|
||||
const maxFilesystemFallbackPaths = 20_000;
|
||||
const maxFileSuggestions = 80;
|
||||
|
||||
interface ExecFileOptions {
|
||||
interface CommandRunnerOptions {
|
||||
cwd: string;
|
||||
maxBuffer: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
input?: string | Buffer;
|
||||
}
|
||||
|
||||
type CommandRunner = (file: string, args: string[], options: CommandRunnerOptions) => Promise<{ stdout: string }>;
|
||||
|
||||
class CommandExitError extends Error {
|
||||
readonly exitCode?: number;
|
||||
|
||||
constructor(file: string, code: number | null, stderr: string) {
|
||||
const codeText = code === null ? "unknown" : String(code);
|
||||
super(`${file} exited with code ${codeText}${stderr === "" ? "" : `: ${stderr}`}`);
|
||||
this.name = "CommandExitError";
|
||||
if (code !== null) this.exitCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type FileSuggestionScope = "tracked" | "all";
|
||||
@@ -21,56 +38,221 @@ export type FileSuggestionScope = "tracked" | "all";
|
||||
export interface FileSuggestionOptions {
|
||||
kind?: ClientFileSuggestion["kind"] | undefined;
|
||||
scope?: FileSuggestionScope | undefined;
|
||||
pathAccess?: PiWebPathAccessConfig | undefined;
|
||||
}
|
||||
|
||||
export interface FileSuggestionDependencies {
|
||||
execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>;
|
||||
execFile?: CommandRunner;
|
||||
fzf?: CommandRunner;
|
||||
}
|
||||
|
||||
export function isAbsoluteishFileSuggestionQuery(query = ""): boolean {
|
||||
return isAbsoluteishPath(fileQueryText(query));
|
||||
}
|
||||
|
||||
export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
|
||||
const queryText = fileQueryText(query);
|
||||
if (isAbsoluteishFileSuggestionQuery(query)) {
|
||||
return (await listPathSuggestions(cwd, queryText, options.pathAccess, deps))
|
||||
.filter((file) => options.kind === undefined || file.kind === options.kind)
|
||||
.slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeFileQuery(query);
|
||||
const exec = deps.execFile ?? execFileAsync;
|
||||
const files = await listFilesForScope(cwd, options.scope, exec);
|
||||
return rankFileSuggestions(
|
||||
const command = deps.execFile ?? runCommand;
|
||||
const files = await listFilesForScope(cwd, options.scope, command);
|
||||
return (await rankFileSuggestionsWithOptionalFzf(
|
||||
cwd,
|
||||
files.filter((file) => options.kind === undefined || file.kind === options.kind),
|
||||
normalizedQuery,
|
||||
).slice(0, maxFileSuggestions);
|
||||
fzfRunnerForDependencies(deps),
|
||||
)).slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
export async function listPathSuggestions(cwd: string, prefix = ""): Promise<ClientFileSuggestion[]> {
|
||||
const normalizedPrefix = prefix.replace(/^@/, "").replace(/\\/g, "/");
|
||||
export async function listPathSuggestions(cwd: string, prefix = "", pathAccess?: PiWebPathAccessConfig, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
|
||||
const query = fileQueryText(prefix);
|
||||
const fzf = fzfRunnerForDependencies(deps);
|
||||
if (isAbsoluteishPath(query)) return listAllowedPathSuggestions(cwd, query, pathAccess, fzf);
|
||||
|
||||
const normalizedPrefix = query.replace(/\\/g, "/");
|
||||
const directoryPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix : dirname(normalizedPrefix) === "." ? "" : `${dirname(normalizedPrefix)}/`;
|
||||
const searchPrefix = normalizedPrefix.endsWith("/") ? "" : basename(normalizedPrefix);
|
||||
const entries = await readdir(join(cwd, directoryPrefix), { withFileTypes: true });
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue;
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory && entry.isSymbolicLink()) {
|
||||
try {
|
||||
isDirectory = (await stat(join(cwd, directoryPrefix, entry.name))).isDirectory();
|
||||
} catch {
|
||||
isDirectory = false;
|
||||
}
|
||||
}
|
||||
suggestions.push({ path: `${directoryPrefix}${entry.name}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||
}
|
||||
return suggestions
|
||||
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
|
||||
.slice(0, 80);
|
||||
const candidates = await listDirectoryEntrySuggestions(cwd, directoryPrefix);
|
||||
return (await rankPathSuggestionsWithOptionalFzf(
|
||||
cwd,
|
||||
candidates,
|
||||
searchPrefix,
|
||||
() => prefixPathSuggestions(candidates, searchPrefix),
|
||||
fzf,
|
||||
)).slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listDirectoryEntrySuggestions(cwd: string, directoryPrefix: string): Promise<ClientFileSuggestion[]> {
|
||||
const policy = await createPathAccessPolicy(cwd, undefined);
|
||||
const resolved = await resolveWorkspaceSuggestionDirectory(policy, directoryPrefix);
|
||||
if (resolved === undefined) return [];
|
||||
|
||||
const entries = await readdir(resolved.target, { withFileTypes: true });
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const entry of entries.sort(compareDirectoryEntries)) {
|
||||
const childPath = appendRequestPath(resolved.displayPath, entry.name);
|
||||
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
|
||||
if (isDirectory === undefined) continue;
|
||||
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function resolveWorkspaceSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
|
||||
try {
|
||||
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
|
||||
return resolved.kind === "workspace" ? resolved : undefined;
|
||||
} catch (error) {
|
||||
if (isPathSuggestionMiss(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listAllowedPathSuggestions(cwd: string, query: string, pathAccess: PiWebPathAccessConfig | undefined, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
const policy = await createPathAccessPolicy(cwd, pathAccess);
|
||||
if (policy.allowedRoots.length === 0) throw new Error("Absolute paths are not allowed");
|
||||
const rootCandidates = allowedRootSuggestionCandidates(policy, query);
|
||||
const directoryCandidates = await listAllowedDirectoryEntryCandidates(policy, query);
|
||||
return (await rankPathSuggestionsWithOptionalFzf(
|
||||
cwd,
|
||||
mergeSuggestions(rootCandidates, directoryCandidates),
|
||||
query,
|
||||
() => mergeSuggestions(allowedRootPrefixSuggestions(policy, query), prefixPathSuggestions(directoryCandidates, pathSuggestionPrefix(query).searchPrefix)).sort(compareFileSuggestions),
|
||||
fzf,
|
||||
)).slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
function allowedRootPrefixSuggestions(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
|
||||
return allowedRootSuggestionCandidates(policy, query).filter((suggestion) => pathStartsWith(suggestion.path, query));
|
||||
}
|
||||
|
||||
function allowedRootSuggestionCandidates(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const root of policy.allowedRoots) {
|
||||
for (const displayPath of allowedRootDisplayPaths(root.path, query)) {
|
||||
const path = ensureTrailingPathSeparator(displayPath);
|
||||
if (hasTrailingPathSeparator(query) && stripTrailingPathSeparators(path) === stripTrailingPathSeparators(query)) continue;
|
||||
if (seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
suggestions.push({ path, kind: "other" });
|
||||
}
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
function allowedRootDisplayPaths(rootPath: string, query: string): string[] {
|
||||
if (query !== "~" && !query.startsWith("~/") && !query.startsWith("~\\")) return [rootPath];
|
||||
|
||||
const home = homedir();
|
||||
const homeRelativePath = relative(home, rootPath);
|
||||
if (!isInsideRelativePath(homeRelativePath)) return [rootPath];
|
||||
const separator = query.startsWith("~\\") ? "\\" : "/";
|
||||
const tildePath = homeRelativePath === "" ? "~" : `~${separator}${homeRelativePath.split(/[\\/]+/u).join(separator)}`;
|
||||
return [tildePath, rootPath];
|
||||
}
|
||||
|
||||
async function listAllowedDirectoryEntryCandidates(policy: PathAccessPolicy, query: string): Promise<ClientFileSuggestion[]> {
|
||||
const { directoryPrefix } = pathSuggestionPrefix(query);
|
||||
const resolved = await resolveSuggestionDirectory(policy, directoryPrefix);
|
||||
if (resolved === undefined) return [];
|
||||
|
||||
const entries = await readdir(resolved.target, { withFileTypes: true });
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const entry of entries.sort(compareDirectoryEntries)) {
|
||||
const childPath = appendRequestPath(directoryPrefix, entry.name);
|
||||
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
|
||||
if (isDirectory === undefined) continue;
|
||||
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function resolveSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
|
||||
try {
|
||||
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
|
||||
return resolved.kind === "allowed" ? resolved : undefined;
|
||||
} catch (error) {
|
||||
if (isPathSuggestionMiss(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function suggestionEntryIsDirectory(policy: PathAccessPolicy, childPath: string, entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): Promise<boolean | undefined> {
|
||||
if (!entry.isSymbolicLink()) return entry.isDirectory();
|
||||
|
||||
try {
|
||||
const resolved = await resolvePathAccessTarget(policy, childPath);
|
||||
const result = await stat(resolved.target);
|
||||
if (result.isDirectory()) return true;
|
||||
if (result.isFile()) return false;
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (isPathSuggestionMiss(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function pathSuggestionPrefix(query: string): { directoryPrefix: string; searchPrefix: string } {
|
||||
if (query === "~" || hasTrailingPathSeparator(query)) return { directoryPrefix: query, searchPrefix: "" };
|
||||
const directory = dirname(query);
|
||||
return { directoryPrefix: directory === "." ? "" : directory, searchPrefix: basename(query) };
|
||||
}
|
||||
|
||||
function appendRequestPath(base: string, name: string): string {
|
||||
if (base === "") return name;
|
||||
if (hasTrailingPathSeparator(base)) return `${base}${name}`;
|
||||
if (isAbsolute(base) || win32.isAbsolute(base)) return join(base, name);
|
||||
return `${base}/${name}`;
|
||||
}
|
||||
|
||||
function pathStartsWith(path: string, query: string): boolean {
|
||||
return path.toLowerCase().startsWith(query.toLowerCase());
|
||||
}
|
||||
|
||||
function ensureTrailingPathSeparator(path: string): string {
|
||||
return hasTrailingPathSeparator(path) ? path : `${path}/`;
|
||||
}
|
||||
|
||||
function hasTrailingPathSeparator(path: string): boolean {
|
||||
return path.endsWith("/") || path.endsWith("\\");
|
||||
}
|
||||
|
||||
function stripTrailingPathSeparators(path: string): string {
|
||||
let end = path.length;
|
||||
while (end > 1 && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
|
||||
return path.slice(0, end);
|
||||
}
|
||||
|
||||
function isInsideRelativePath(path: string): boolean {
|
||||
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
||||
}
|
||||
|
||||
function isPathSuggestionMiss(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.message === "Path is outside allowed paths"
|
||||
|| error.message === "Path does not exist"
|
||||
|| error.message === "Path traversal is not allowed"
|
||||
|| error.message === "Path escapes workspace"
|
||||
|| error.message.startsWith("Path is not absolute:");
|
||||
}
|
||||
|
||||
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
if (scope === "all") return listAllFiles(cwd, exec);
|
||||
if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true));
|
||||
return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false));
|
||||
}
|
||||
|
||||
async function listTrackedFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listTrackedFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
return withDirectories(nulRecords(await git(cwd, ["ls-files", "-z"], exec)), "tracked");
|
||||
}
|
||||
|
||||
async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listGitFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const [tracked, untracked] = await Promise.all([
|
||||
git(cwd, ["ls-files", "-z"], exec),
|
||||
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec),
|
||||
@@ -81,7 +263,7 @@ async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
|
||||
];
|
||||
}
|
||||
|
||||
async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listAllFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const [gitFiles, plainFiles] = await Promise.all([
|
||||
listGitFiles(cwd, exec).catch((): ClientFileSuggestion[] => []),
|
||||
listPlainFiles(cwd, exec, true),
|
||||
@@ -89,7 +271,7 @@ async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
|
||||
return mergeSuggestions(gitFiles, plainFiles);
|
||||
}
|
||||
|
||||
async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
|
||||
async function listPlainFiles(cwd: string, exec: CommandRunner, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
|
||||
try {
|
||||
const args = includeIgnored ? ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] : ["--files"];
|
||||
const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
|
||||
@@ -138,13 +320,75 @@ async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink:
|
||||
}
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> {
|
||||
async function git(cwd: string, args: string[], exec: CommandRunner): Promise<string> {
|
||||
const { stdout } = await exec("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: commandMaxBuffer });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function normalizeFileQuery(query: string): string {
|
||||
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "").toLowerCase();
|
||||
return fileQueryText(query).toLowerCase();
|
||||
}
|
||||
|
||||
function fileQueryText(query: string): string {
|
||||
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "");
|
||||
}
|
||||
|
||||
function fzfRunnerForDependencies(deps: FileSuggestionDependencies): CommandRunner | undefined {
|
||||
return deps.fzf ?? (deps.execFile === undefined ? runCommand : undefined);
|
||||
}
|
||||
|
||||
async function rankFileSuggestionsWithOptionalFzf(cwd: string, files: ClientFileSuggestion[], normalizedQuery: string, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
return rankSuggestionsWithOptionalFzf(cwd, files, normalizedQuery, () => rankFileSuggestions(files, normalizedQuery), fzf);
|
||||
}
|
||||
|
||||
async function rankPathSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
return rankSuggestionsWithOptionalFzf(cwd, candidates, query, fallback, fzf);
|
||||
}
|
||||
|
||||
async function rankSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
if (fzf === undefined || query === "" || candidates.length === 0) return fallback();
|
||||
|
||||
try {
|
||||
return await fzfFilterSuggestions(cwd, candidates, query, fzf);
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
async function fzfFilterSuggestions(cwd: string, candidates: ClientFileSuggestion[], query: string, fzf: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const byPath = new Map(candidates.map((suggestion) => [suggestion.path, suggestion]));
|
||||
const { stdout } = await runFzf(cwd, [...byPath.keys()], query, fzf);
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const path of nulRecords(stdout)) {
|
||||
const suggestion = byPath.get(path);
|
||||
if (suggestion === undefined || seen.has(suggestion.path)) continue;
|
||||
seen.add(suggestion.path);
|
||||
suggestions.push(suggestion);
|
||||
}
|
||||
if (suggestions.length === 0 && stdout !== "") throw new Error("fzf returned paths outside the gathered suggestions");
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function runFzf(cwd: string, candidates: string[], query: string, fzf: CommandRunner): Promise<{ stdout: string }> {
|
||||
try {
|
||||
return await fzf("fzf", ["--filter", query, "--read0", "--print0"], { cwd, maxBuffer: commandMaxBuffer, input: `${candidates.join("\0")}\0` });
|
||||
} catch (error) {
|
||||
if (errorExitCode(error) === 1) return { stdout: "" };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function prefixPathSuggestions(candidates: ClientFileSuggestion[], searchPrefix: string): ClientFileSuggestion[] {
|
||||
const normalizedSearchPrefix = searchPrefix.toLowerCase();
|
||||
return candidates
|
||||
.filter((suggestion) => pathSuggestionName(suggestion.path).toLowerCase().startsWith(normalizedSearchPrefix))
|
||||
.sort(compareFileSuggestions);
|
||||
}
|
||||
|
||||
function pathSuggestionName(path: string): string {
|
||||
const stripped = stripTrailingPathSeparators(path);
|
||||
return stripped.split(/[\\/]+/u).filter(Boolean).at(-1) ?? stripped;
|
||||
}
|
||||
|
||||
function rankFileSuggestions(files: ClientFileSuggestion[], normalizedQuery: string): ClientFileSuggestion[] {
|
||||
@@ -197,6 +441,10 @@ function compareFileSuggestions(a: ClientFileSuggestion, b: ClientFileSuggestion
|
||||
return Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
function compareDirectoryEntries(a: { isDirectory(): boolean; name: string }, b: { isDirectory(): boolean; name: string }): number {
|
||||
return Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function kindRank(kind: ClientFileSuggestion["kind"]): number {
|
||||
switch (kind) {
|
||||
case "tracked": return 0;
|
||||
@@ -209,6 +457,72 @@ function pathDepth(path: string): number {
|
||||
return path.split("/").filter(Boolean).length;
|
||||
}
|
||||
|
||||
async function runCommand(file: string, args: string[], options: CommandRunnerOptions): Promise<{ stdout: string }> {
|
||||
const { input, ...execOptions } = options;
|
||||
if (input === undefined) return execFileAsync(file, args, execOptions);
|
||||
return runCommandWithInput(file, args, { ...execOptions, input });
|
||||
}
|
||||
|
||||
async function runCommandWithInput(file: string, args: string[], options: CommandRunnerOptions & { input: string | Buffer }): Promise<{ stdout: string }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(file, args, {
|
||||
cwd: options.cwd,
|
||||
...(options.env === undefined ? {} : { env: options.env }),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let settled = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length;
|
||||
if (stdoutBytes > options.maxBuffer) {
|
||||
child.kill();
|
||||
rejectOnce(new Error(`${file} stdout exceeded maxBuffer`));
|
||||
return;
|
||||
}
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrBytes += chunk.length;
|
||||
if (stderrBytes > options.maxBuffer) {
|
||||
child.kill();
|
||||
rejectOnce(new Error(`${file} stderr exceeded maxBuffer`));
|
||||
return;
|
||||
}
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", rejectOnce);
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (code === 0) {
|
||||
resolve({ stdout });
|
||||
return;
|
||||
}
|
||||
reject(new CommandExitError(file, code, stderr));
|
||||
});
|
||||
child.stdin.on("error", () => undefined);
|
||||
child.stdin.end(options.input);
|
||||
});
|
||||
}
|
||||
|
||||
function errorExitCode(error: unknown): number | undefined {
|
||||
if (error instanceof CommandExitError) return error.exitCode;
|
||||
if (!(error instanceof Error)) return undefined;
|
||||
if ("exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
|
||||
if ("code" in error && typeof error.code === "number") return error.code;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function textLines(text: string): string[] {
|
||||
return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line).filter((line) => line !== "");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user