src / ps1Files.ts
import { lstat, mkdir, realpath, stat } from "fs/promises";
import { basename, isAbsolute, join, relative, resolve } from "path";
import type { PowerShell7Config } from "./config";
import { resolveConfiguredPath } from "./audit";
export type Ps1PathResult =
| { ok: true; fileName: string; scriptsDirectory: string; scriptPath: string }
| { ok: false; fileName: string; error: string };
const RESERVED_WINDOWS_NAMES = new Set([
"con",
"prn",
"aux",
"nul",
"com1",
"com2",
"com3",
"com4",
"com5",
"com6",
"com7",
"com8",
"com9",
"lpt1",
"lpt2",
"lpt3",
"lpt4",
"lpt5",
"lpt6",
"lpt7",
"lpt8",
"lpt9",
]);
export function validatePs1FileName(fileName: string): { ok: true; fileName: string } | { ok: false; error: string } {
const normalized = fileName.trim();
if (!normalized) {
return { ok: false, error: "Script file name is required." };
}
if (normalized.length > 160) {
return { ok: false, error: "Script file name must be 160 characters or fewer." };
}
if (normalized.includes("\0")) {
return { ok: false, error: "Script file name must not contain null bytes." };
}
if (normalized.includes("/") || normalized.includes("\\")) {
return { ok: false, error: "Script file name must not contain path separators." };
}
if (normalized.includes("..")) {
return { ok: false, error: "Script file name must not contain '..'." };
}
if (/^[a-zA-Z]:/.test(normalized) || isAbsolute(normalized)) {
return { ok: false, error: "Script file name must not be an absolute path or drive path." };
}
if (!normalized.toLowerCase().endsWith(".ps1")) {
return { ok: false, error: "Script file name must end with .ps1." };
}
if (basename(normalized) !== normalized) {
return { ok: false, error: "Script file name must be a plain file name, not a path." };
}
const stem = normalized.slice(0, -4).toLowerCase();
if (RESERVED_WINDOWS_NAMES.has(stem)) {
return { ok: false, error: `${normalized} uses a reserved Windows device name.` };
}
return { ok: true, fileName: normalized };
}
export function getScriptsDirectory(workingDirectory: string, config: PowerShell7Config): string {
return resolveConfiguredPath(workingDirectory, config.scriptsDirectory);
}
export function resolveScriptPath(
workingDirectory: string,
config: PowerShell7Config,
fileName: string,
): Ps1PathResult {
const validation = validatePs1FileName(fileName);
if (!validation.ok) {
return { ok: false, fileName, error: validation.error };
}
const scriptsDirectory = getScriptsDirectory(workingDirectory, config);
const scriptPath = resolve(join(scriptsDirectory, validation.fileName));
if (!isPathInsideDirectory(scriptPath, scriptsDirectory)) {
return {
ok: false,
fileName: validation.fileName,
error: "Resolved script path escapes the configured scripts directory.",
};
}
return { ok: true, fileName: validation.fileName, scriptsDirectory, scriptPath };
}
export async function ensureScriptsDirectory(directory: string): Promise<void> {
await mkdir(directory, { recursive: true });
}
export async function fileExists(filePath: string): Promise<boolean> {
try {
const fileStat = await lstat(filePath);
return fileStat.isFile();
} catch {
return false;
}
}
export async function validateExistingScriptFile(
filePath: string,
scriptsDirectory: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const fileStat = await lstat(filePath);
if (fileStat.isSymbolicLink()) {
return { ok: false, error: "Script file must not be a symbolic link." };
}
if (!fileStat.isFile()) {
return { ok: false, error: "Script path is not a regular file." };
}
const [realFilePath, realScriptsDirectory] = await Promise.all([
realpath(filePath),
realpath(scriptsDirectory),
]);
if (!isPathInsideDirectory(realFilePath, realScriptsDirectory)) {
return { ok: false, error: "Script file resolves outside the configured scripts directory." };
}
return { ok: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { ok: false, error: message };
}
}
function isPathInsideDirectory(filePath: string, directory: string): boolean {
const relativePath = relative(resolve(directory), resolve(filePath));
return relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath);
}