@juspay/neurolink 9.94.5 → 9.94.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,18 @@ function computeTotalContentLength(messages) {
22
22
  return total;
23
23
  }
24
24
  /**
25
- * Check whether input contains multimodal content (images, files, PDFs, CSVs).
25
+ * Check whether input contains multimodal content (images, files, PDFs, CSVs,
26
+ * audio, video). #284: audioFiles/videoFiles were previously ignored here, so a
27
+ * request carrying only audio or video was misrouted to the text-only path.
28
+ *
29
+ * Intentional (round-2 review): routing audio/video through `isMultimodal`
30
+ * does NOT mean vision is required. `buildMultimodalMessagesArray` folds
31
+ * `audioFiles`/`videoFiles` into `inp.files` for auto-detection, and
32
+ * `appendDetectedFileResult` injects their content as plain text markers
33
+ * (`## Audio File:` / `## Video File:`), not image/vision blocks — see
34
+ * `test/continuous-test-suite-bugfixes.ts` ("MessageBuilder #284: ...").
35
+ * "Multimodal" here means "needs the multimodal builder so the payload isn't
36
+ * dropped", not "needs a vision model".
26
37
  */
27
38
  function detectMultimodal(opts) {
28
39
  const input = opts.input;
@@ -31,10 +42,18 @@ function detectMultimodal(opts) {
31
42
  const hasCSVFiles = !!input?.csvFiles?.length;
32
43
  const hasPdfFiles = !!input?.pdfFiles?.length;
33
44
  const hasFiles = !!input?.files?.length;
45
+ const hasAudioFiles = !!input?.audioFiles?.length;
46
+ const hasVideoFiles = !!input?.videoFiles?.length;
34
47
  return {
35
- isMultimodal: hasImages || hasContent || hasCSVFiles || hasPdfFiles || hasFiles,
48
+ isMultimodal: hasImages ||
49
+ hasContent ||
50
+ hasCSVFiles ||
51
+ hasPdfFiles ||
52
+ hasFiles ||
53
+ hasAudioFiles ||
54
+ hasVideoFiles,
36
55
  hasImages,
37
- hasFiles: hasCSVFiles || hasPdfFiles || hasFiles,
56
+ hasFiles: hasCSVFiles || hasPdfFiles || hasFiles || hasAudioFiles || hasVideoFiles,
38
57
  };
39
58
  }
40
59
  /**
@@ -75,6 +94,12 @@ export class MessageBuilder {
75
94
  content: input?.content,
76
95
  csvFiles: input?.csvFiles,
77
96
  pdfFiles: input?.pdfFiles,
97
+ // #284: audioFiles/videoFiles must be forwarded too, or the
98
+ // multimodal builder receives an empty payload despite
99
+ // detectMultimodal() correctly routing audio/video-only
100
+ // requests here.
101
+ audioFiles: input?.audioFiles,
102
+ videoFiles: input?.videoFiles,
78
103
  files: input?.files,
79
104
  },
80
105
  csvOptions: options.csvOptions,
@@ -192,6 +217,10 @@ export class MessageBuilder {
192
217
  content: input?.content,
193
218
  csvFiles: input?.csvFiles,
194
219
  pdfFiles: input?.pdfFiles,
220
+ // #284: forward audioFiles/videoFiles here too — see the
221
+ // matching comment in buildMessages() above.
222
+ audioFiles: input?.audioFiles,
223
+ videoFiles: input?.videoFiles,
195
224
  files: input?.files,
196
225
  },
197
226
  csvOptions: options.csvOptions,
@@ -22,7 +22,18 @@ function computeTotalContentLength(messages) {
22
22
  return total;
23
23
  }
24
24
  /**
25
- * Check whether input contains multimodal content (images, files, PDFs, CSVs).
25
+ * Check whether input contains multimodal content (images, files, PDFs, CSVs,
26
+ * audio, video). #284: audioFiles/videoFiles were previously ignored here, so a
27
+ * request carrying only audio or video was misrouted to the text-only path.
28
+ *
29
+ * Intentional (round-2 review): routing audio/video through `isMultimodal`
30
+ * does NOT mean vision is required. `buildMultimodalMessagesArray` folds
31
+ * `audioFiles`/`videoFiles` into `inp.files` for auto-detection, and
32
+ * `appendDetectedFileResult` injects their content as plain text markers
33
+ * (`## Audio File:` / `## Video File:`), not image/vision blocks — see
34
+ * `test/continuous-test-suite-bugfixes.ts` ("MessageBuilder #284: ...").
35
+ * "Multimodal" here means "needs the multimodal builder so the payload isn't
36
+ * dropped", not "needs a vision model".
26
37
  */
27
38
  function detectMultimodal(opts) {
28
39
  const input = opts.input;
@@ -31,10 +42,18 @@ function detectMultimodal(opts) {
31
42
  const hasCSVFiles = !!input?.csvFiles?.length;
32
43
  const hasPdfFiles = !!input?.pdfFiles?.length;
33
44
  const hasFiles = !!input?.files?.length;
45
+ const hasAudioFiles = !!input?.audioFiles?.length;
46
+ const hasVideoFiles = !!input?.videoFiles?.length;
34
47
  return {
35
- isMultimodal: hasImages || hasContent || hasCSVFiles || hasPdfFiles || hasFiles,
48
+ isMultimodal: hasImages ||
49
+ hasContent ||
50
+ hasCSVFiles ||
51
+ hasPdfFiles ||
52
+ hasFiles ||
53
+ hasAudioFiles ||
54
+ hasVideoFiles,
36
55
  hasImages,
37
- hasFiles: hasCSVFiles || hasPdfFiles || hasFiles,
56
+ hasFiles: hasCSVFiles || hasPdfFiles || hasFiles || hasAudioFiles || hasVideoFiles,
38
57
  };
39
58
  }
40
59
  /**
@@ -75,6 +94,12 @@ export class MessageBuilder {
75
94
  content: input?.content,
76
95
  csvFiles: input?.csvFiles,
77
96
  pdfFiles: input?.pdfFiles,
97
+ // #284: audioFiles/videoFiles must be forwarded too, or the
98
+ // multimodal builder receives an empty payload despite
99
+ // detectMultimodal() correctly routing audio/video-only
100
+ // requests here.
101
+ audioFiles: input?.audioFiles,
102
+ videoFiles: input?.videoFiles,
78
103
  files: input?.files,
79
104
  },
80
105
  csvOptions: options.csvOptions,
@@ -192,6 +217,10 @@ export class MessageBuilder {
192
217
  content: input?.content,
193
218
  csvFiles: input?.csvFiles,
194
219
  pdfFiles: input?.pdfFiles,
220
+ // #284: forward audioFiles/videoFiles here too — see the
221
+ // matching comment in buildMessages() above.
222
+ audioFiles: input?.audioFiles,
223
+ videoFiles: input?.videoFiles,
195
224
  files: input?.files,
196
225
  },
197
226
  csvOptions: options.csvOptions,
@@ -53,6 +53,7 @@ export type GenerateOptions = {
53
53
  images?: Array<Buffer | string | ImageWithAltText>;
54
54
  csvFiles?: Array<Buffer | string>;
55
55
  pdfFiles?: Array<Buffer | string>;
56
+ audioFiles?: Array<Buffer | string>;
56
57
  videoFiles?: Array<Buffer | string>;
57
58
  files?: Array<Buffer | string | FileWithMetadata>;
58
59
  content?: Content[];
@@ -419,15 +419,46 @@ export type MultimodalInput = {
419
419
  segments?: DirectorSegment[];
420
420
  };
421
421
  /**
422
- * Content format for multimodal messages (used internally)
423
- * Compatible with Vercel AI SDK message format
422
+ * Content format for multimodal messages (used internally).
423
+ *
424
+ * #325: the loose `[key: string]: unknown` index signature has been replaced
425
+ * with the concrete fields the codebase actually reads/writes across the
426
+ * text / image / file / tool-call / tool-result shapes. This keeps the broad
427
+ * structural compatibility the internal pipeline relies on (a single object
428
+ * type, not a strict discriminated union that would force narrowing at every
429
+ * consumer) while removing the "any key is allowed" hole that let typos and
430
+ * unrelated keys through unchecked.
424
431
  */
425
432
  export type MessageContent = {
426
433
  type: string;
434
+ /** Text content (`type: "text"`). */
427
435
  text?: string;
436
+ /** Base64 / data-URI image (`type: "image"`). */
428
437
  image?: string;
438
+ /** MIME type for image/file parts. */
429
439
  mimeType?: string;
430
- [key: string]: unknown;
440
+ /** Raw file bytes or base64 (`type: "file"`/document parts). */
441
+ data?: string | Buffer;
442
+ /** File name for document/file parts. */
443
+ name?: string;
444
+ /** File name (alias used by some file parts). */
445
+ filename?: string;
446
+ /** Tool-call identifier (`type: "tool-call"`/`"tool-result"`). */
447
+ toolCallId?: string;
448
+ /** Tool name (`type: "tool-call"`/`"tool-result"`). */
449
+ toolName?: string;
450
+ /** Tool-call arguments (`type: "tool-call"`). */
451
+ args?: Record<string, unknown>;
452
+ /** Tool-result payload (`type: "tool-result"`). */
453
+ result?: unknown;
454
+ /** Whether a tool-result represents an error (`type: "tool-result"`). */
455
+ isError?: boolean;
456
+ /**
457
+ * Provider-specific per-block options (e.g. Anthropic cache_control).
458
+ * Read as `item.providerOptions` when converting `MessageContent[]` to
459
+ * `ModelMessage[]` in `MessageBuilder.ts`.
460
+ */
461
+ providerOptions?: Record<string, unknown>;
431
462
  };
432
463
  /**
433
464
  * Extended chat message for multimodal support (internal use)
@@ -193,6 +193,7 @@ export type StreamOptions = {
193
193
  images?: Array<Buffer | string | ImageWithAltText>;
194
194
  csvFiles?: Array<Buffer | string>;
195
195
  pdfFiles?: Array<Buffer | string>;
196
+ audioFiles?: Array<Buffer | string>;
196
197
  videoFiles?: Array<Buffer | string>;
197
198
  files?: Array<Buffer | string | FileWithMetadata>;
198
199
  content?: Content[];
@@ -35,6 +35,9 @@ export declare const ERROR_CODES: {
35
35
  readonly IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL";
36
36
  readonly INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT";
37
37
  readonly INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE";
38
+ readonly IMAGE_BUFFER_INVALID: "IMAGE_BUFFER_INVALID";
39
+ readonly FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED";
40
+ readonly CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED";
38
41
  readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
39
42
  readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
40
43
  readonly RATE_LIMITER_QUEUE_TIMEOUT: "RATE_LIMITER_QUEUE_TIMEOUT";
@@ -216,6 +219,13 @@ export declare class ErrorFactory {
216
219
  * Create an invalid image format error
217
220
  */
218
221
  static invalidImageFormat(): NeuroLinkError;
222
+ /**
223
+ * Create an image buffer validation error: empty, undersized, or a
224
+ * truncated buffer detected by `ImageProcessor.validateBufferNotEmpty()`.
225
+ * Takes the fully-formed message so call sites keep their specific
226
+ * byte-count detail instead of a fixed generic message.
227
+ */
228
+ static imageBufferInvalid(message: string): NeuroLinkError;
219
229
  /**
220
230
  * Create a rate limiter queue full error
221
231
  */
@@ -287,6 +297,18 @@ export declare class ErrorFactory {
287
297
  * generic `Error` instances.
288
298
  */
289
299
  static proxyWorkerLifecycle(message: string, context?: Record<string, unknown>): NeuroLinkError;
300
+ /**
301
+ * Create a generic file-processing-failed error (e.g. an unrecognized or
302
+ * corrupt file in `processUnifiedFilesArray`). Preserves the original error
303
+ * as `originalError` (stack + message copied onto the new error's context).
304
+ */
305
+ static fileProcessingFailed(filename: string, originalError: Error): NeuroLinkError;
306
+ /**
307
+ * Create a generic CSV-processing-failed error (explicit `csvFiles` path,
308
+ * distinct from `fileProcessingFailed` so callers can classify CSV-specific
309
+ * failures separately). Preserves the original error as `originalError`.
310
+ */
311
+ static csvProcessingFailed(filename: string, originalError: Error): NeuroLinkError;
290
312
  }
291
313
  /**
292
314
  * Timeout wrapper for async operations
@@ -46,6 +46,10 @@ export const ERROR_CODES = {
46
46
  IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL",
47
47
  INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT",
48
48
  INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE",
49
+ IMAGE_BUFFER_INVALID: "IMAGE_BUFFER_INVALID",
50
+ // Generic file/CSV processing errors
51
+ FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED",
52
+ CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED",
49
53
  // PDF validation errors
50
54
  PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED",
51
55
  // Rate limiter errors
@@ -627,6 +631,22 @@ export class ErrorFactory {
627
631
  },
628
632
  });
629
633
  }
634
+ /**
635
+ * Create an image buffer validation error: empty, undersized, or a
636
+ * truncated buffer detected by `ImageProcessor.validateBufferNotEmpty()`.
637
+ * Takes the fully-formed message so call sites keep their specific
638
+ * byte-count detail instead of a fixed generic message.
639
+ */
640
+ static imageBufferInvalid(message) {
641
+ return new NeuroLinkError({
642
+ code: ERROR_CODES.IMAGE_BUFFER_INVALID,
643
+ message,
644
+ category: ErrorCategory.VALIDATION,
645
+ severity: ErrorSeverity.MEDIUM,
646
+ retriable: false,
647
+ context: { field: "input.images" },
648
+ });
649
+ }
630
650
  // ============================================================================
631
651
  // RATE LIMITER ERRORS
632
652
  // ============================================================================
@@ -924,6 +944,41 @@ export class ErrorFactory {
924
944
  context: context || {},
925
945
  });
926
946
  }
947
+ // ============================================================================
948
+ // GENERIC FILE / CSV PROCESSING ERRORS
949
+ // ============================================================================
950
+ /**
951
+ * Create a generic file-processing-failed error (e.g. an unrecognized or
952
+ * corrupt file in `processUnifiedFilesArray`). Preserves the original error
953
+ * as `originalError` (stack + message copied onto the new error's context).
954
+ */
955
+ static fileProcessingFailed(filename, originalError) {
956
+ return new NeuroLinkError({
957
+ code: ERROR_CODES.FILE_PROCESSING_FAILED,
958
+ message: `Failed to process file "${filename}": ${originalError.message}`,
959
+ category: ErrorCategory.EXECUTION,
960
+ severity: ErrorSeverity.HIGH,
961
+ retriable: false,
962
+ context: { filename },
963
+ originalError,
964
+ });
965
+ }
966
+ /**
967
+ * Create a generic CSV-processing-failed error (explicit `csvFiles` path,
968
+ * distinct from `fileProcessingFailed` so callers can classify CSV-specific
969
+ * failures separately). Preserves the original error as `originalError`.
970
+ */
971
+ static csvProcessingFailed(filename, originalError) {
972
+ return new NeuroLinkError({
973
+ code: ERROR_CODES.CSV_PROCESSING_FAILED,
974
+ message: `Failed to process CSV file "${filename}": ${originalError.message}`,
975
+ category: ErrorCategory.EXECUTION,
976
+ severity: ErrorSeverity.HIGH,
977
+ retriable: false,
978
+ context: { filename },
979
+ originalError,
980
+ });
981
+ }
927
982
  }
928
983
  /**
929
984
  * Timeout wrapper for async operations
@@ -24,6 +24,7 @@ import { tracers, ATTR, withSpan } from "../telemetry/index.js";
24
24
  import { CSVProcessor } from "./csvProcessor.js";
25
25
  import { ImageProcessor } from "./imageProcessor.js";
26
26
  import { logger } from "./logger.js";
27
+ import { withTimeout } from "./errorHandling.js";
27
28
  import { redactUrlForError, sanitizeErrorCause } from "./logSanitize.js";
28
29
  import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
29
30
  import { PDFProcessor } from "./pdfProcessor.js";
@@ -32,6 +33,80 @@ import { PDFProcessor } from "./pdfProcessor.js";
32
33
  */
33
34
  const DEFAULT_MAX_RETRIES = 3;
34
35
  const DEFAULT_RETRY_DELAY = 1000; // milliseconds
36
+ /**
37
+ * Short-TTL cache of URL → Content-Type (#323). A URL is commonly detected more
38
+ * than once (repeated multimodal prompts reuse the same asset URL); caching the
39
+ * HEAD's content-type avoids re-issuing the HEAD each time. `loadFromURL` also
40
+ * populates it from its GET response, so once a URL's body has been fetched a
41
+ * subsequent detection needs no network round-trip at all.
42
+ *
43
+ * Trade-off (round-2 review): this is a module-level cache shared by every
44
+ * request in the process, and correctness relies solely on the 60s TTL — a
45
+ * signed URL whose response changes at the same path within that window would
46
+ * read stale. That is an intentional, bounded trade-off (60s of possible
47
+ * staleness for far fewer HEAD round-trips), not a freshness guarantee.
48
+ * Expired entries are removed lazily on their next `get()` (see
49
+ * `getCachedUrlContentType`); `setCachedUrlContentType` additionally sweeps
50
+ * expired entries opportunistically once the cache hits its size cap, so a
51
+ * URL that is cached once and never looked up again doesn't linger until the
52
+ * FIFO eviction below forces it out.
53
+ */
54
+ const URL_CONTENT_TYPE_TTL_MS = 60_000;
55
+ const URL_CONTENT_TYPE_CACHE_MAX_SIZE = 512;
56
+ const urlContentTypeCache = new Map();
57
+ function getCachedUrlContentType(url, now) {
58
+ const hit = urlContentTypeCache.get(url);
59
+ if (hit && hit.expiresAt > now) {
60
+ // Bump recency: Map iteration order follows insertion order, and the
61
+ // eviction below deletes the *first* key, so a plain `get` on a hot
62
+ // entry would leave it first in line for eviction despite being the
63
+ // most recently used. Re-inserting turns the size-bounded FIFO below
64
+ // into an actual LRU.
65
+ urlContentTypeCache.delete(url);
66
+ urlContentTypeCache.set(url, hit);
67
+ return hit.contentType;
68
+ }
69
+ if (hit) {
70
+ // Entry exists but its TTL has passed — treat as a miss and evict it
71
+ // immediately rather than serving (or retaining) stale data.
72
+ urlContentTypeCache.delete(url);
73
+ }
74
+ return undefined;
75
+ }
76
+ /**
77
+ * Opportunistically remove already-expired entries. Only called once the
78
+ * cache is at its size cap (see `setCachedUrlContentType`) so it doesn't add
79
+ * an O(n) scan to the common-case hot path.
80
+ */
81
+ function pruneExpiredUrlContentTypeEntries(now) {
82
+ for (const [key, entry] of urlContentTypeCache) {
83
+ if (entry.expiresAt <= now) {
84
+ urlContentTypeCache.delete(key);
85
+ }
86
+ }
87
+ }
88
+ function setCachedUrlContentType(url, contentType, now) {
89
+ if (!contentType) {
90
+ return;
91
+ }
92
+ urlContentTypeCache.set(url, {
93
+ contentType,
94
+ expiresAt: now + URL_CONTENT_TYPE_TTL_MS,
95
+ });
96
+ // Bound the cache so a long-lived process can't grow it unbounded. Prefer
97
+ // reclaiming already-expired entries first; only fall back to evicting the
98
+ // oldest still-live entry (FIFO/LRU-ish, see getCachedUrlContentType) if
99
+ // the cache is still over the cap after pruning.
100
+ if (urlContentTypeCache.size > URL_CONTENT_TYPE_CACHE_MAX_SIZE) {
101
+ pruneExpiredUrlContentTypeEntries(now);
102
+ }
103
+ if (urlContentTypeCache.size > URL_CONTENT_TYPE_CACHE_MAX_SIZE) {
104
+ const oldest = urlContentTypeCache.keys().next().value;
105
+ if (oldest !== undefined) {
106
+ urlContentTypeCache.delete(oldest);
107
+ }
108
+ }
109
+ }
35
110
  /**
36
111
  * Retryable network error codes (Node.js/undici network errors)
37
112
  */
@@ -1351,6 +1426,9 @@ export class FileDetector {
1351
1426
  // not be echoed into a thrown error.
1352
1427
  throw new Error(`HTTP ${response.statusCode} fetching ${redactUrlForError(url)}`);
1353
1428
  }
1429
+ // #323: cache the Content-Type from this GET so a subsequent detection
1430
+ // of the same URL needs no HEAD.
1431
+ setCachedUrlContentType(url, response.headers["content-type"] || "", Date.now());
1354
1432
  const chunks = [];
1355
1433
  let totalSize = 0;
1356
1434
  for await (const chunk of response.body) {
@@ -1698,13 +1776,25 @@ class MimeTypeStrategy {
1698
1776
  return this.unknown();
1699
1777
  }
1700
1778
  try {
1701
- const response = await request(input, {
1702
- dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1703
- method: "HEAD",
1704
- headersTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
1705
- bodyTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
1706
- });
1707
- const contentType = response.headers["content-type"] || "";
1779
+ // #323: reuse a recently-seen Content-Type for this URL instead of
1780
+ // re-issuing a HEAD (populated here and by loadFromURL's GET).
1781
+ const now = Date.now();
1782
+ let contentType = getCachedUrlContentType(input, now);
1783
+ if (contentType === undefined) {
1784
+ // Wrap the whole HEAD (request + body drain) in withTimeout so a stalled
1785
+ // dump() can't hang detection, per the project's async-timeout guideline.
1786
+ contentType = await withTimeout((async () => {
1787
+ const response = await request(input, {
1788
+ dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1789
+ method: "HEAD",
1790
+ headersTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
1791
+ bodyTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
1792
+ });
1793
+ await response.body.dump();
1794
+ return response.headers["content-type"] || "";
1795
+ })(), FileDetector.DEFAULT_HEAD_TIMEOUT);
1796
+ setCachedUrlContentType(input, contentType, now);
1797
+ }
1708
1798
  const type = this.mimeToFileType(contentType);
1709
1799
  return {
1710
1800
  type,
@@ -4,6 +4,17 @@
4
4
  */
5
5
  import { redactPathFromMessage } from "./logSanitize.js";
6
6
  import type { ProcessedImage, FileProcessingResult } from "../types/index.js";
7
+ /**
8
+ * Minimum bytes required just to detect an image format from magic bytes (#293).
9
+ */
10
+ export declare const MIN_IMAGE_SIZE = 12;
11
+ /**
12
+ * Minimum plausible byte size for a non-empty image of each format (#293). A
13
+ * buffer that carries a format's magic bytes but is smaller than this is
14
+ * truncated/corrupt and would fail (or render blank) downstream — reject early
15
+ * with a clear message instead.
16
+ */
17
+ export declare const MIN_VALID_IMAGE_SIZE: Record<string, number>;
7
18
  /**
8
19
  * Image processor class for handling provider-specific image formatting
9
20
  */
@@ -17,6 +28,22 @@ export declare class ImageProcessor {
17
28
  * @returns Processed image as data URI
18
29
  */
19
30
  static process(content: Buffer, _options?: unknown): Promise<FileProcessingResult>;
31
+ /**
32
+ * Strict magic-byte match: does `content` actually begin with `mediaType`'s
33
+ * signature? `detectImageType` returns `image/jpeg` as a *fallback* for
34
+ * unrecognized data, so a per-format minimum must only be enforced when the
35
+ * bytes genuinely match — otherwise valid BMP/TIFF/unknown buffers would be
36
+ * wrongly rejected as "truncated jpeg" (#293 review).
37
+ */
38
+ private static hasStrictImageMagic;
39
+ /**
40
+ * Validate that a buffer is a plausibly-complete image (#293): non-empty and,
41
+ * for a strictly-identified raster format, at least the minimum plausible byte
42
+ * size. Returns the detected media type.
43
+ *
44
+ * @throws Error when the buffer is empty or a recognized format is truncated.
45
+ */
46
+ private static validateBufferNotEmpty;
20
47
  /**
21
48
  * Validate processed output meets required format
22
49
  * Checks:
@@ -11,6 +11,23 @@ import { SYSTEM_LIMITS } from "../core/constants.js";
11
11
  import { SIZE_LIMITS_BYTES } from "../processors/config/sizeLimits.js";
12
12
  import { getImageCache } from "./imageCache.js";
13
13
  import { ErrorFactory, NeuroLinkError } from "./errorHandling.js";
14
+ /**
15
+ * Minimum bytes required just to detect an image format from magic bytes (#293).
16
+ */
17
+ export const MIN_IMAGE_SIZE = 12;
18
+ /**
19
+ * Minimum plausible byte size for a non-empty image of each format (#293). A
20
+ * buffer that carries a format's magic bytes but is smaller than this is
21
+ * truncated/corrupt and would fail (or render blank) downstream — reject early
22
+ * with a clear message instead.
23
+ */
24
+ export const MIN_VALID_IMAGE_SIZE = {
25
+ "image/png": 67,
26
+ "image/jpeg": 125,
27
+ "image/gif": 43,
28
+ "image/webp": 20,
29
+ "image/avif": 100,
30
+ };
14
31
  /**
15
32
  * Network error codes that should trigger a retry
16
33
  */
@@ -92,12 +109,9 @@ export class ImageProcessor {
92
109
  * @returns Processed image as data URI
93
110
  */
94
111
  static async process(content, _options) {
95
- // Validate content is non-empty before processing
96
- if (content.length === 0) {
97
- logger.error("Empty buffer provided");
98
- throw new Error("Invalid image processing: buffer is empty");
99
- }
100
- const mediaType = this.detectImageType(content);
112
+ // #293: reject empty AND small/truncated buffers (not just 0 bytes) before
113
+ // processing; returns the detected media type.
114
+ const mediaType = this.validateBufferNotEmpty(content);
101
115
  // #261/#286 follow-up: fail loud instead of packaging the octet-stream
102
116
  // sentinel as a "valid" image (see assertKnownImageType() above).
103
117
  assertKnownImageType(mediaType);
@@ -115,6 +129,73 @@ export class ImageProcessor {
115
129
  },
116
130
  };
117
131
  }
132
+ /**
133
+ * Strict magic-byte match: does `content` actually begin with `mediaType`'s
134
+ * signature? `detectImageType` returns `image/jpeg` as a *fallback* for
135
+ * unrecognized data, so a per-format minimum must only be enforced when the
136
+ * bytes genuinely match — otherwise valid BMP/TIFF/unknown buffers would be
137
+ * wrongly rejected as "truncated jpeg" (#293 review).
138
+ */
139
+ static hasStrictImageMagic(content, mediaType) {
140
+ const b = content;
141
+ switch (mediaType) {
142
+ case "image/png":
143
+ return (b.length >= 4 &&
144
+ b[0] === 0x89 &&
145
+ b[1] === 0x50 &&
146
+ b[2] === 0x4e &&
147
+ b[3] === 0x47);
148
+ case "image/jpeg":
149
+ return b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
150
+ case "image/gif":
151
+ return b.length >= 3 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46;
152
+ case "image/webp":
153
+ return (b.length >= 12 &&
154
+ b.subarray(0, 4).toString("latin1") === "RIFF" &&
155
+ b.subarray(8, 12).toString("latin1") === "WEBP");
156
+ case "image/avif":
157
+ return (b.length >= 12 &&
158
+ b.subarray(4, 8).toString("latin1") === "ftyp" &&
159
+ b.subarray(8, 12).toString("latin1").startsWith("avif"));
160
+ default:
161
+ return false;
162
+ }
163
+ }
164
+ /**
165
+ * Validate that a buffer is a plausibly-complete image (#293): non-empty and,
166
+ * for a strictly-identified raster format, at least the minimum plausible byte
167
+ * size. Returns the detected media type.
168
+ *
169
+ * @throws Error when the buffer is empty or a recognized format is truncated.
170
+ */
171
+ static validateBufferNotEmpty(content) {
172
+ if (content.length === 0) {
173
+ logger.error("Empty buffer provided");
174
+ throw ErrorFactory.imageBufferInvalid("Invalid image processing: buffer is empty");
175
+ }
176
+ const mediaType = this.detectImageType(content);
177
+ // SVG is text and can be legitimately tiny (e.g. `<svg/>`), so the raster
178
+ // size floors don't apply to it (#293 review).
179
+ if (mediaType === "image/svg+xml") {
180
+ return mediaType;
181
+ }
182
+ // A buffer too small to carry any raster header is not a usable image.
183
+ if (content.length < MIN_IMAGE_SIZE) {
184
+ throw ErrorFactory.imageBufferInvalid(`Invalid image processing: buffer too small (${content.length} bytes) — ` +
185
+ `a minimum of ${MIN_IMAGE_SIZE} bytes is required for a valid image.`);
186
+ }
187
+ // Only enforce a per-format minimum when the format was strictly identified
188
+ // from magic bytes (never from detectImageType's jpeg fallback), so valid
189
+ // BMP/TIFF/unknown buffers are not mis-rejected as "truncated jpeg".
190
+ const minForFormat = MIN_VALID_IMAGE_SIZE[mediaType];
191
+ if (minForFormat !== undefined &&
192
+ content.length < minForFormat &&
193
+ this.hasStrictImageMagic(content, mediaType)) {
194
+ throw ErrorFactory.imageBufferInvalid(`Invalid image processing: ${mediaType} buffer is truncated ` +
195
+ `(${content.length} bytes, minimum ${minForFormat} for a valid ${mediaType}).`);
196
+ }
197
+ return mediaType;
198
+ }
118
199
  /**
119
200
  * Validate processed output meets required format
120
201
  * Checks: