import { normalize, resolve } from 'node:path'; import { MAX_HTTP_URL_SIZE } from './constants'; export type ValidationResult = { valid: true } | { valid: false; error: string }; export type ValueValidationResult = { valid: true; value: T } | { valid: false; error: string }; function parseStrictInteger(value: string): number | null { if (!/^-?\d+$/.test(value.trim())) { return null; } const parsed = Number(value); if (!Number.isSafeInteger(parsed)) { return null; } return parsed; } /** Parse and canonicalize a network URL accepted by rss-ai's HTTP interfaces. */ export function parseHttpUrl(url: string): ValueValidationResult { try { if (url.length > MAX_HTTP_URL_SIZE) { return { valid: false, error: `URL is too long (max ${MAX_HTTP_URL_SIZE} characters).` }; } const parsed = new URL(url); if (!['http:', 'https:'].includes(parsed.protocol)) { return { valid: false, error: `Invalid protocol: ${parsed.protocol}. Only http and https are supported.` }; } if (parsed.username || parsed.password) { return { valid: false, error: 'URL credentials are not supported.' }; } // DNS treats a fully-qualified hostname with a trailing dot as the same // host. Remove it so feed/article identity and rate-limit keys converge. if (parsed.hostname.endsWith('.')) parsed.hostname = parsed.hostname.slice(0, -1); // Fragments are client-side document positions and are never part of an // HTTP request, so they cannot distinguish Feed resources. parsed.hash = ''; if (parsed.href.length > MAX_HTTP_URL_SIZE) { return { valid: false, error: `URL is too long (max ${MAX_HTTP_URL_SIZE} characters).` }; } return { valid: true, value: parsed.href }; } catch { return { valid: false, error: `Invalid URL format: ${url}` }; } } export function validateUrl(url: string): ValidationResult { const parsed = parseHttpUrl(url); return parsed.valid ? { valid: true } : parsed; } export function validatePositiveInt(value: string, name: string): ValueValidationResult { const num = parseStrictInteger(value); if (num === null || num <= 0) { return { valid: false, error: `${name} must be a positive integer, got: ${value}` }; } return { valid: true, value: num }; } export function validateIntRange(value: string, name: string, min: number, max: number): ValueValidationResult { const num = parseStrictInteger(value); if (num === null || num < min || num > max) { return { valid: false, error: `${name} must be between ${min} and ${max}, got: ${value}` }; } return { valid: true, value: num }; } export function validateDbPath(path: string): ValidationResult { // Normalize to resolve path traversal (e.g., "/safe/../etc/passwd" -> "/etc/passwd") const normalized = normalize(resolve(path)); // Block obvious system paths const blockedPrefixes = ['/etc/', '/usr/', '/bin/', '/sbin/', '/var/log/', '/proc/', '/sys/']; for (const prefix of blockedPrefixes) { if (normalized.startsWith(prefix)) { return { valid: false, error: `Database path cannot be in system directory: ${prefix}` }; } } return { valid: true }; }