@juspay/neurolink 9.94.4 → 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.
@@ -4,7 +4,7 @@
4
4
  * Uses multi-strategy approach for reliable type identification
5
5
  */
6
6
  import { open, readFile, realpath } from "fs/promises";
7
- import { isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
7
+ import { basename, isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
8
8
  import { getGlobalDispatcher, interceptors, request } from "undici";
9
9
  // Lazy-loaded processor singletons — avoids loading heavy media deps
10
10
  // (mediabunny, fluent-ffmpeg, music-metadata, adm-zip) on every generate() call.
@@ -24,6 +24,8 @@ 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";
28
+ import { redactUrlForError, sanitizeErrorCause } from "./logSanitize.js";
27
29
  import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
28
30
  import { PDFProcessor } from "./pdfProcessor.js";
29
31
  /**
@@ -31,6 +33,80 @@ import { PDFProcessor } from "./pdfProcessor.js";
31
33
  */
32
34
  const DEFAULT_MAX_RETRIES = 3;
33
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
+ }
34
110
  /**
35
111
  * Retryable network error codes (Node.js/undici network errors)
36
112
  */
@@ -1338,25 +1414,58 @@ export class FileDetector {
1338
1414
  const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
1339
1415
  const retryDelay = options?.retryDelay ?? DEFAULT_RETRY_DELAY;
1340
1416
  return withRetry(async () => {
1341
- const response = await request(url, {
1342
- dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1343
- method: "GET",
1344
- headersTimeout: timeout,
1345
- bodyTimeout: timeout,
1346
- });
1347
- if (response.statusCode !== 200) {
1348
- throw new Error(`HTTP ${response.statusCode} fetching ${url}`);
1349
- }
1350
- const chunks = [];
1351
- let totalSize = 0;
1352
- for await (const chunk of response.body) {
1353
- totalSize += chunk.length;
1354
- if (totalSize > maxSize) {
1355
- throw new Error(`File too large: ${formatFileSize(totalSize)} (max: ${formatFileSize(maxSize)})`);
1417
+ try {
1418
+ const response = await request(url, {
1419
+ dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1420
+ method: "GET",
1421
+ headersTimeout: timeout,
1422
+ bodyTimeout: timeout,
1423
+ });
1424
+ if (response.statusCode !== 200) {
1425
+ // Query string / fragment stripped — a presigned URL's token must
1426
+ // not be echoed into a thrown error.
1427
+ throw new Error(`HTTP ${response.statusCode} fetching ${redactUrlForError(url)}`);
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());
1432
+ const chunks = [];
1433
+ let totalSize = 0;
1434
+ for await (const chunk of response.body) {
1435
+ totalSize += chunk.length;
1436
+ if (totalSize > maxSize) {
1437
+ throw new Error(`File too large: ${formatFileSize(totalSize)} (max: ${formatFileSize(maxSize)})`);
1438
+ }
1439
+ chunks.push(chunk);
1440
+ }
1441
+ return Buffer.concat(chunks);
1442
+ }
1443
+ catch (error) {
1444
+ // Node/undici DNS, TLS, and connect-timeout errors embed the full
1445
+ // request URL (including a presigned query token) in
1446
+ // `error.message`. Redact into a NEW error instead of mutating the
1447
+ // original in place, so anything that still holds a reference to
1448
+ // the original — debug logs, telemetry spans, upstream callers —
1449
+ // keeps seeing the real message. `.code` is copied onto the new
1450
+ // error so `isRetryableNetworkError`'s retry check in the outer
1451
+ // `withRetry` catch still classifies it correctly. The raw
1452
+ // original error is NEVER attached as `cause` — that would leave
1453
+ // the unredacted URL reachable via `error.cause.message` for
1454
+ // anything that walks the cause chain (cause-aware logging,
1455
+ // telemetry). `cause` instead gets its own sanitized copy.
1456
+ // `sanitizeErrorCause` handles non-`Error` thrown values too (a raw
1457
+ // string/object can just as easily carry the full URL), so there is
1458
+ // no unconditional `throw error` fallback that would bypass
1459
+ // redaction for that case.
1460
+ const cause = sanitizeErrorCause(error);
1461
+ const redacted = new Error(cause.message, { cause });
1462
+ redacted.name = cause.name;
1463
+ const code = cause.code;
1464
+ if (code !== undefined) {
1465
+ redacted.code = code;
1356
1466
  }
1357
- chunks.push(chunk);
1467
+ throw redacted;
1358
1468
  }
1359
- return Buffer.concat(chunks);
1360
1469
  }, { maxRetries, retryDelay });
1361
1470
  }
1362
1471
  /**
@@ -1374,6 +1483,13 @@ export class FileDetector {
1374
1483
  // the base dir pointing outside cannot bypass containment. The
1375
1484
  // path.relative check (not a string prefix) correctly handles the root dir
1376
1485
  // ("/") and sibling-prefix ("/app" vs "/app-evil") edge cases.
1486
+ //
1487
+ // The actual open() below MUST target this validated `real` path, not the
1488
+ // original `filePath` — otherwise a symlink swapped between the realpath()
1489
+ // check and the open() call routes the read outside the sandbox even
1490
+ // though validation passed (TOCTOU). With no sandbox configured there's no
1491
+ // boundary to defend, so the original path is used as given.
1492
+ let pathToOpen = filePath;
1377
1493
  if (options?.allowedBaseDir) {
1378
1494
  let base;
1379
1495
  let real;
@@ -1381,24 +1497,66 @@ export class FileDetector {
1381
1497
  base = await realpath(resolvePath(options.allowedBaseDir));
1382
1498
  real = await realpath(filePath);
1383
1499
  }
1384
- catch {
1385
- throw new Error(`Access denied: "${filePath}" could not be resolved within the allowed base directory`);
1500
+ catch (error) {
1501
+ // Full path stays in the debug log; the thrown (potentially
1502
+ // client-facing) error only gets the basename to avoid leaking the
1503
+ // host's directory layout to an untrusted caller. The cause is
1504
+ // sanitized too — Node's realpath ENOENT/EACCES messages embed the
1505
+ // full path verbatim, which would otherwise survive on the cause
1506
+ // chain (cause-aware logging, telemetry) even though the outer
1507
+ // message is already redacted.
1508
+ logger.debug("loadFromPath: realpath resolution failed", {
1509
+ filePath,
1510
+ error,
1511
+ });
1512
+ // Assigned to a variable before the throw (rather than an inline
1513
+ // `{ cause: sanitizeErrorCause(...) }`) so the sanitized, path-redacted
1514
+ // copy is unambiguously the attached cause — the raw `error`, whose
1515
+ // message still embeds the full path, is never reachable from the
1516
+ // thrown result.
1517
+ const cause = sanitizeErrorCause(error, { filePath });
1518
+ const denied = new Error(`Access denied: "${basename(filePath)}" could not be resolved within the allowed base directory`, { cause });
1519
+ throw denied;
1386
1520
  }
1387
1521
  const rel = relativePath(base, real);
1388
1522
  if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolutePath(rel)) {
1389
- throw new Error(`Access denied: "${filePath}" resolves outside the allowed base directory`);
1523
+ logger.debug("loadFromPath: path resolves outside allowed base dir", {
1524
+ filePath,
1525
+ real,
1526
+ });
1527
+ throw new Error(`Access denied: "${basename(filePath)}" resolves outside the allowed base directory`);
1390
1528
  }
1529
+ pathToOpen = real;
1391
1530
  }
1392
1531
  // Open a handle and stat/read through the SAME descriptor so a symlink
1393
1532
  // swap between the size check and the read cannot occur (TOCTOU).
1394
- const handle = await open(filePath, "r");
1533
+ let handle;
1534
+ try {
1535
+ handle = await open(pathToOpen, "r");
1536
+ }
1537
+ catch (error) {
1538
+ // A failed open (ENOENT/EACCES/…) embeds the opened path verbatim in
1539
+ // its message. When a sandbox is configured `pathToOpen` is the
1540
+ // realpath-resolved target (`real`), which differs from both `filePath`
1541
+ // and its resolved form — so redact `pathToOpen` specifically, or the
1542
+ // full host path would survive on both the thrown message and the cause
1543
+ // chain despite this PR's path-redaction hardening.
1544
+ const cause = sanitizeErrorCause(error, { filePath: pathToOpen });
1545
+ const failed = new Error(cause.message, { cause });
1546
+ failed.name = cause.name;
1547
+ const code = cause.code;
1548
+ if (code !== undefined) {
1549
+ failed.code = code;
1550
+ }
1551
+ throw failed;
1552
+ }
1395
1553
  try {
1396
1554
  const statInfo = await handle.stat();
1397
1555
  if (!statInfo.isFile()) {
1398
- throw new Error(`Not a file: ${filePath}`);
1556
+ throw new Error(`Not a file: ${basename(filePath)}`);
1399
1557
  }
1400
1558
  if (statInfo.size > maxSize) {
1401
- throw new Error(`File too large: ${filePath} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1559
+ throw new Error(`File too large: ${basename(filePath)} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1402
1560
  }
1403
1561
  return await handle.readFile();
1404
1562
  }
@@ -1450,6 +1608,15 @@ class MagicBytesStrategy {
1450
1608
  input[6] === 0x79 &&
1451
1609
  input[7] === 0x70) {
1452
1610
  const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
1611
+ // AVIF images share the ISO-BMFF ftyp box with MP4/MOV; the major brand
1612
+ // ('avif' still, 'avis' sequence, 'avio' intra-only AV1 image/sequence
1613
+ // — spec-listed under compatible_brands but also emitted as
1614
+ // major_brand by real encoders) distinguishes them. Detect before the
1615
+ // audio/video branches so an AVIF buffer isn't misrouted to the video
1616
+ // pipeline (#286).
1617
+ if (/^(avif|avis|avio)/.test(brand)) {
1618
+ return this.result("image", "image/avif", 95);
1619
+ }
1453
1620
  if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
1454
1621
  return this.result("audio", "audio/mp4", 95);
1455
1622
  }
@@ -1609,13 +1776,25 @@ class MimeTypeStrategy {
1609
1776
  return this.unknown();
1610
1777
  }
1611
1778
  try {
1612
- const response = await request(input, {
1613
- dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1614
- method: "HEAD",
1615
- headersTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
1616
- bodyTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
1617
- });
1618
- 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
+ }
1619
1798
  const type = this.mimeToFileType(contentType);
1620
1799
  return {
1621
1800
  type,
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { createHash } from "crypto";
16
16
  import { logger } from "./logger.js";
17
+ import { redactUrlForError } from "./logSanitize.js";
17
18
  /**
18
19
  * LRU Cache for downloaded images
19
20
  *
@@ -142,7 +143,9 @@ export class ImageCache {
142
143
  const entry = this.cache.get(normalizedUrl);
143
144
  if (!entry) {
144
145
  this.stats.misses++;
145
- logger.debug("Image cache miss", { url: normalizedUrl.substring(0, 50) });
146
+ logger.debug("Image cache miss", {
147
+ url: redactUrlForError(normalizedUrl),
148
+ });
146
149
  return null;
147
150
  }
148
151
  // Check TTL expiration
@@ -150,7 +153,7 @@ export class ImageCache {
150
153
  this.stats.expirations++;
151
154
  this.delete(normalizedUrl);
152
155
  logger.debug("Image cache entry expired", {
153
- url: normalizedUrl.substring(0, 50),
156
+ url: redactUrlForError(normalizedUrl),
154
157
  });
155
158
  return null;
156
159
  }
@@ -162,7 +165,7 @@ export class ImageCache {
162
165
  this.cache.set(normalizedUrl, entry);
163
166
  this.stats.hits++;
164
167
  logger.debug("Image cache hit", {
165
- url: normalizedUrl.substring(0, 50),
168
+ url: redactUrlForError(normalizedUrl),
166
169
  accessCount: entry.accessCount,
167
170
  });
168
171
  return entry;
@@ -192,7 +195,7 @@ export class ImageCache {
192
195
  // Skip caching if image exceeds max size
193
196
  if (size > this.maxImageSize) {
194
197
  logger.debug("Image too large to cache", {
195
- url: normalizedUrl.substring(0, 50),
198
+ url: redactUrlForError(normalizedUrl),
196
199
  size,
197
200
  maxSize: this.maxImageSize,
198
201
  });
@@ -211,8 +214,8 @@ export class ImageCache {
211
214
  // Update content hash index to point to the new URL as well
212
215
  this.contentHashIndex.set(contentHash, normalizedUrl);
213
216
  logger.debug("Image cache dedup hit", {
214
- newUrl: normalizedUrl.substring(0, 50),
215
- existingUrl: existingUrl.substring(0, 50),
217
+ newUrl: redactUrlForError(normalizedUrl),
218
+ existingUrl: redactUrlForError(existingUrl),
216
219
  });
217
220
  return;
218
221
  }
@@ -234,7 +237,7 @@ export class ImageCache {
234
237
  this.cache.set(normalizedUrl, entry);
235
238
  this.contentHashIndex.set(contentHash, normalizedUrl);
236
239
  logger.debug("Image cached", {
237
- url: normalizedUrl.substring(0, 50),
240
+ url: redactUrlForError(normalizedUrl),
238
241
  size,
239
242
  contentHash: contentHash.substring(0, 8),
240
243
  cacheSize: this.cache.size,
@@ -272,7 +275,7 @@ export class ImageCache {
272
275
  this.cache.delete(oldestKey);
273
276
  this.stats.evictions++;
274
277
  logger.debug("Image cache eviction", {
275
- url: String(oldestKey).substring(0, 50),
278
+ url: redactUrlForError(String(oldestKey)),
276
279
  });
277
280
  }
278
281
  }
@@ -2,7 +2,19 @@
2
2
  * Image processing utilities for multimodal support
3
3
  * Handles format conversion for different AI providers
4
4
  */
5
+ import { redactPathFromMessage } from "./logSanitize.js";
5
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>;
6
18
  /**
7
19
  * Image processor class for handling provider-specific image formatting
8
20
  */
@@ -16,6 +28,22 @@ export declare class ImageProcessor {
16
28
  * @returns Processed image as data URI
17
29
  */
18
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;
19
47
  /**
20
48
  * Validate processed output meets required format
21
49
  * Checks:
@@ -171,6 +199,15 @@ export declare const imageUtils: {
171
199
  * Convert base64 string to Buffer
172
200
  */
173
201
  base64ToBuffer: (base64: string) => Buffer;
202
+ /**
203
+ * Redact `filePath` (both as given and its `path.resolve()`'d form) from
204
+ * an error message. Exposed directly (not just used internally by
205
+ * {@link fileToBase64DataUri}) so the resolved-path branch can be verified
206
+ * by a deterministic unit test — real `fs` errors only ever embed the
207
+ * literal path as passed, never a resolved variant, so that branch isn't
208
+ * otherwise observable through the async file-reading API.
209
+ */
210
+ redactPathFromMessage: typeof redactPathFromMessage;
174
211
  /**
175
212
  * Convert file path to base64 data URI
176
213
  */