import { readdirSync } from "fs"; import { homedir } from "os"; import path from "path"; import { getAdditionalAllowedRoots, normalizeSlashes } from "./allowed-roots"; import { isExistingPathWithinRoots } from "./path-security"; import { listAllSessions } from "./session-reader"; export { allowFileRoot, normalizeSlashes } from "./allowed-roots"; // Short-TTL cache for the allowed-roots set. Without this, every file list/read // request re-scans every pi session on disk just to check access. 5s is short // enough that newly-created cwds appear promptly; stored on globalThis so it // survives Next.js hot-reload. declare global { var __piAllowedRootsCache: { roots: Set; expiresAt: number } | undefined; } const ALLOWED_ROOTS_TTL_MS = 5_000; const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/; export function isWindowsAbsolutePath(filePath: string): boolean { return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//"); } export async function getAllowedFileRoots(): Promise> { const now = Date.now(); const cached = globalThis.__piAllowedRootsCache; if (cached && cached.expiresAt > now) return cached.roots; const sessions = await listAllSessions(); const roots = new Set(); for (const s of sessions) { if (s.cwd) roots.add(normalizeSlashes(s.cwd)); // The project root (main repo shared by all worktrees) is browsable too — // the project dropdown lists it even when only worktrees have sessions. if (s.projectRoot) roots.add(normalizeSlashes(s.projectRoot)); } // Also allow ~/pi-cwd-* directories created by the default-cwd endpoint. try { for (const name of readdirSync(homedir())) { if (/^pi-cwd-\d{8}$/.test(name)) { roots.add(normalizeSlashes(path.join(homedir(), name))); } } } catch { // ignore if home is unreadable } for (const root of getAdditionalAllowedRoots()) roots.add(root); globalThis.__piAllowedRootsCache = { roots, expiresAt: now + ALLOWED_ROOTS_TTL_MS }; return roots; } export function isFilePathAllowed(target: string, allowedRoots: Set): boolean { for (const root of allowedRoots) { const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root); const resolver = useWindowsRules ? path.win32 : path; const sep = useWindowsRules ? "\\" : path.sep; const normalized = resolver.resolve(target); const normalizedRoot = resolver.resolve(root); const comparable = useWindowsRules ? normalized.toLowerCase() : normalized; const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot; const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep; if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) { return true; } } return false; } /** Authorize an existing path after resolving symbolic links. */ export function isExistingFilePathAllowed(target: string, allowedRoots: Set): boolean { return isExistingPathWithinRoots(target, allowedRoots); }