cc580889d2
Update coverage reports and add new validation utilities Add comprehensive validation utilities for Ollama URL and model name Add new API response types and client configuration interfaces Add Logger utility for consistent logging Add validation function for plugin settings Add unit tests for validation functions Update TypeScript configuration Update coverage reports to reflect new code additions ```
374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
/**
|
|
* Logging utility for consistent log formatting and levels
|
|
* Provides standardized logging across the plugin
|
|
*/
|
|
|
|
export enum LogLevel {
|
|
DEBUG = 'debug',
|
|
INFO = 'info',
|
|
WARN = 'warn',
|
|
ERROR = 'error',
|
|
}
|
|
|
|
/**
|
|
* Centralized logging utility with consistent formatting
|
|
*/
|
|
export class Logger {
|
|
private static currentLevel: LogLevel = LogLevel.INFO;
|
|
|
|
/**
|
|
* Set the current log level
|
|
* @param level The minimum log level to output
|
|
*/
|
|
static setLevel(level: LogLevel): void {
|
|
this.currentLevel = level;
|
|
}
|
|
|
|
/**
|
|
* Get the current log level
|
|
* @returns The current log level
|
|
*/
|
|
static getLevel(): LogLevel {
|
|
return this.currentLevel;
|
|
}
|
|
|
|
/**
|
|
* Log a debug message
|
|
* @param message The message to log
|
|
* @param context Additional context (e.g., component name)
|
|
*/
|
|
static debug(message: string, context?: string): void {
|
|
if (this.currentLevel <= LogLevel.DEBUG) {
|
|
this.log(LogLevel.DEBUG, message, context);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log an info message
|
|
* @param message The message to log
|
|
* @param context Additional context (e.g., component name)
|
|
*/
|
|
static info(message: string, context?: string): void {
|
|
if (this.currentLevel <= LogLevel.INFO) {
|
|
this.log(LogLevel.INFO, message, context);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log a warning message
|
|
* @param message The message to log
|
|
* @param context Additional context (e.g., component name)
|
|
*/
|
|
static warn(message: string, context?: string): void {
|
|
if (this.currentLevel <= LogLevel.WARN) {
|
|
this.log(LogLevel.WARN, message, context);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log an error message
|
|
* @param message The message to log
|
|
* @param context Additional context (e.g., component name)
|
|
*/
|
|
static error(message: string, context?: string, error?: unknown): void {
|
|
if (this.currentLevel <= LogLevel.ERROR) {
|
|
this.log(LogLevel.ERROR, message, context);
|
|
if (error) {
|
|
console.error('[Error Details]', error instanceof Error ? error.stack : error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal log method with consistent formatting
|
|
* @param level The log level
|
|
* @param message The message to log
|
|
* @param context Additional context
|
|
*/
|
|
private static log(level: LogLevel, message: string, context?: string): void {
|
|
const timestamp = new Date().toISOString();
|
|
const levelStr = level.toUpperCase();
|
|
const contextStr = context ? `[${context}]` : '';
|
|
const formattedMessage = `[${timestamp}] ${levelStr} ${contextStr} ${message}`;
|
|
|
|
switch (level) {
|
|
case LogLevel.DEBUG:
|
|
console.debug(formattedMessage);
|
|
break;
|
|
case LogLevel.INFO:
|
|
console.info(formattedMessage);
|
|
break;
|
|
case LogLevel.WARN:
|
|
console.warn(formattedMessage);
|
|
break;
|
|
case LogLevel.ERROR:
|
|
console.error(formattedMessage);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log a performance measurement
|
|
* @param operation The operation being measured
|
|
* @param durationMs The duration in milliseconds
|
|
* @param context Additional context
|
|
*/
|
|
static perf(operation: string, durationMs: number, context?: string): void {
|
|
this.info(`Performance: ${operation} took ${durationMs.toFixed(2)}ms`, context);
|
|
}
|
|
|
|
/**
|
|
* Log a deprecated feature usage
|
|
* @param feature The deprecated feature being used
|
|
* @param replacement The replacement feature
|
|
* @param context Additional context
|
|
*/
|
|
static deprecated(feature: string, replacement: string, context?: string): void {
|
|
this.warn(`Deprecated: ${feature} is deprecated. Use ${replacement} instead.`, context);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalizes file paths for browser/ Obsidian environment
|
|
* Replaces multiple slashes with single slash and handles forward/backward slashes
|
|
*/
|
|
|
|
/**
|
|
* Validates the Ollama URL configuration
|
|
* @param url The URL to validate
|
|
* @returns Object with validation result and error message if invalid
|
|
*/
|
|
export function validateOllamaUrl(url: string): { valid: boolean; error?: string } {
|
|
if (!url || typeof url !== 'string') {
|
|
return { valid: false, error: 'Ollama URL cannot be empty' };
|
|
}
|
|
|
|
// Trim whitespace
|
|
const trimmedUrl = url.trim();
|
|
if (trimmedUrl.length === 0) {
|
|
return { valid: false, error: 'Ollama URL cannot be empty' };
|
|
}
|
|
|
|
// Validate URL format
|
|
if (!isValidHttpUrl(trimmedUrl)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Ollama URL must be a valid HTTP or HTTPS URL (e.g., http://localhost:11434)',
|
|
};
|
|
}
|
|
|
|
// Check for common mistakes
|
|
if (trimmedUrl.endsWith('/')) {
|
|
return { valid: false, error: 'Ollama URL should not end with a slash' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Validates the Ollama model name configuration
|
|
* @param modelName The model name to validate
|
|
* @returns Object with validation result and error message if invalid
|
|
*/
|
|
export function validateModelName(modelName: string): { valid: boolean; error?: string } {
|
|
if (!modelName || typeof modelName !== 'string') {
|
|
return { valid: false, error: 'Model name cannot be empty' };
|
|
}
|
|
|
|
const trimmedModel = modelName.trim();
|
|
if (trimmedModel.length === 0) {
|
|
return { valid: false, error: 'Model name cannot be empty' };
|
|
}
|
|
|
|
// Model names should be alphanumeric with optional dots, dashes, and underscores
|
|
const modelNameRegex = /^[a-zA-Z0-9._-]+$/;
|
|
if (!modelNameRegex.test(trimmedModel)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Model name can only contain letters, numbers, dots, dashes, and underscores',
|
|
};
|
|
}
|
|
|
|
// Check length constraints
|
|
if (trimmedModel.length < 2) {
|
|
return { valid: false, error: 'Model name must be at least 2 characters long' };
|
|
}
|
|
|
|
if (trimmedModel.length > 100) {
|
|
return { valid: false, error: 'Model name must be less than 100 characters long' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Validates plugin settings before saving
|
|
* @param settings The settings to validate
|
|
* @returns Array of validation errors, empty if all valid
|
|
*/
|
|
export function validatePluginSettings(settings: any): string[] {
|
|
const errors: string[] = [];
|
|
|
|
if (settings.ollamaUrl) {
|
|
const urlValidation = validateOllamaUrl(settings.ollamaUrl);
|
|
if (!urlValidation.valid) {
|
|
errors.push(`Ollama URL: ${urlValidation.error}`);
|
|
}
|
|
}
|
|
|
|
if (settings.model) {
|
|
const modelValidation = validateModelName(settings.model);
|
|
if (!modelValidation.valid) {
|
|
errors.push(`Model: ${modelValidation.error}`);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
export function normalizePath(path: string): string {
|
|
// Replace multiple slashes with single slash
|
|
let normalized = path.replace(/[\\\/]+/g, '/');
|
|
|
|
// Remove trailing slash unless it's the root
|
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
normalized = normalized.slice(0, -1);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Validates a path string for safety (no traversal, no absolute paths, no invalid chars)
|
|
*/
|
|
/**
|
|
* Validates a URL string to ensure it's a proper HTTP or HTTPS URL
|
|
*/
|
|
export function isValidHttpUrl(string: string): boolean {
|
|
let url;
|
|
try {
|
|
url = new URL(string);
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
}
|
|
|
|
/**
|
|
* Safely parses JSON with validation to prevent code injection
|
|
* @param text The JSON string to parse
|
|
* @param maxDepth Maximum allowed nesting depth (prevents DoS via deeply nested JSON)
|
|
* @param maxSize Maximum allowed size in characters (prevents DoS via very large JSON)
|
|
* @returns Parsed object or throws error
|
|
*/
|
|
export function safeParseJson(text: string, maxDepth: number = 20, maxSize: number = 1000000): any {
|
|
if (typeof text !== 'string') {
|
|
throw new Error('Input must be a string');
|
|
}
|
|
|
|
// Check size limit
|
|
if (text.length > maxSize) {
|
|
throw new Error(`JSON input too large (${text.length} characters, max ${maxSize})`);
|
|
}
|
|
|
|
// Check for potentially dangerous patterns
|
|
const dangerousPatterns = [
|
|
/\bconstructor\b/i,
|
|
/\bprototype\b/i,
|
|
/\b__proto__\b/i,
|
|
/\bfunction\b/i,
|
|
/\brequire\b/i,
|
|
/\brequire\b/i,
|
|
/\bprocess\b/i,
|
|
/\bchild_process\b/i,
|
|
/\bglobal\b/i,
|
|
/\bwindow\b/i,
|
|
/\bdangerouslySetInnerHTML\b/i,
|
|
];
|
|
|
|
for (const pattern of dangerousPatterns) {
|
|
if (pattern.test(text)) {
|
|
throw new Error('Potentially dangerous JSON pattern detected');
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = JSON.parse(text);
|
|
|
|
// Recursive depth check
|
|
function checkDepth(obj: any, depth: number): boolean {
|
|
if (depth > maxDepth) {
|
|
return false;
|
|
}
|
|
|
|
if (Array.isArray(obj)) {
|
|
for (const item of obj) {
|
|
if (!checkDepth(item, depth + 1)) {
|
|
return false;
|
|
}
|
|
}
|
|
} else if (obj && typeof obj === 'object') {
|
|
for (const key in obj) {
|
|
if (!checkDepth(obj[key], depth + 1)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (!checkDepth(result, 0)) {
|
|
throw new Error(`JSON nesting too deep (max ${maxDepth} levels)`);
|
|
}
|
|
|
|
// Validate that the result is a safe object (not a function, etc.)
|
|
if (typeof result === 'function' || result instanceof Function) {
|
|
throw new Error('JSON parsing resulted in a function - potential code injection');
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
// Re-throw JSON parse errors with additional context
|
|
if (error instanceof Error && error.message.includes('JSON')) {
|
|
throw new Error(`Invalid JSON: ${error.message}`);
|
|
}
|
|
throw new Error(
|
|
`Failed to parse JSON: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
export function validatePath(path: string): { valid: boolean; error?: string } {
|
|
const normalized = normalizePath(path);
|
|
|
|
// Check for path traversal by looking for .. as a path segment (not just substring in filenames)
|
|
const segments = normalized.split('/');
|
|
if (segments.includes('..')) {
|
|
return { valid: false, error: 'Path traversal not allowed' };
|
|
}
|
|
|
|
// Check if absolute path
|
|
if (normalized.startsWith('/') || normalized.startsWith('\\')) {
|
|
return { valid: false, error: 'Absolute paths not allowed' };
|
|
}
|
|
|
|
// Check for windows drive letters
|
|
if (/^[a-zA-Z]:/.test(normalized)) {
|
|
return { valid: false, error: 'Absolute paths not allowed' };
|
|
}
|
|
|
|
// Check for invalid characters
|
|
const invalidChars = /[\<\>\:\"\|\\\?\*~]/;
|
|
if (invalidChars.test(path)) {
|
|
return { valid: false, error: 'Path contains illegal characters' };
|
|
}
|
|
|
|
// Check path length
|
|
const MAX_PATH_LENGTH = 200;
|
|
if (path.length > MAX_PATH_LENGTH) {
|
|
return { valid: false, error: 'Path too long' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|