All files utils.ts

72.22% Statements 91/126
68.18% Branches 45/66
50% Functions 9/18
72.22% Lines 91/126

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374          2x 2x 2x 2x 2x           2x 2x                                                                                                                                                                                                                                                       2x 12x 2x       10x 10x         10x 3x             7x 1x     6x               2x 15x 2x     13x 13x         13x 13x 3x             10x 1x     9x 1x     8x               2x 4x   4x 4x 4x 2x       4x 4x 4x 2x       4x   2x   21x     21x       21x                 2x   16x 16x   5x   11x                   2x 36x 1x       35x 1x       34x                           34x 340x 4x       30x 30x       101x 1x     100x           100x 48x 73x 21x         79x     28x 1x       27x       27x     3x 3x               2x 21x     21x 21x 2x       19x 2x       17x 1x       16x 16x 8x       8x 8x 1x     7x    
/**
 * 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 {
    Iif (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 {
    Iif (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 {
    Iif (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 {
    Iif (this.currentLevel <= LogLevel.ERROR) {
      this.log(LogLevel.ERROR, message, context);
      Iif (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();
  Iif (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();
  Iif (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
  Iif (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;
      }
 
      Iif (Array.isArray(obj)) {
        for (const item of obj) {
          Iif (!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.)
    Iif (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 };
}