express-storage 1.0.0 → 1.1.2

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.
Files changed (48) hide show
  1. package/README.md +519 -348
  2. package/dist/drivers/azure.driver.d.ts +88 -0
  3. package/dist/drivers/azure.driver.d.ts.map +1 -0
  4. package/dist/drivers/azure.driver.js +367 -0
  5. package/dist/drivers/azure.driver.js.map +1 -0
  6. package/dist/drivers/base.driver.d.ts +125 -24
  7. package/dist/drivers/base.driver.d.ts.map +1 -1
  8. package/dist/drivers/base.driver.js +248 -62
  9. package/dist/drivers/base.driver.js.map +1 -1
  10. package/dist/drivers/gcs.driver.d.ts +60 -13
  11. package/dist/drivers/gcs.driver.d.ts.map +1 -1
  12. package/dist/drivers/gcs.driver.js +242 -41
  13. package/dist/drivers/gcs.driver.js.map +1 -1
  14. package/dist/drivers/local.driver.d.ts +89 -12
  15. package/dist/drivers/local.driver.d.ts.map +1 -1
  16. package/dist/drivers/local.driver.js +533 -45
  17. package/dist/drivers/local.driver.js.map +1 -1
  18. package/dist/drivers/s3.driver.d.ts +64 -13
  19. package/dist/drivers/s3.driver.d.ts.map +1 -1
  20. package/dist/drivers/s3.driver.js +269 -41
  21. package/dist/drivers/s3.driver.js.map +1 -1
  22. package/dist/factory/driver.factory.d.ts +35 -29
  23. package/dist/factory/driver.factory.d.ts.map +1 -1
  24. package/dist/factory/driver.factory.js +119 -59
  25. package/dist/factory/driver.factory.js.map +1 -1
  26. package/dist/index.d.ts +23 -22
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +26 -46
  29. package/dist/index.js.map +1 -1
  30. package/dist/storage-manager.d.ts +205 -52
  31. package/dist/storage-manager.d.ts.map +1 -1
  32. package/dist/storage-manager.js +644 -73
  33. package/dist/storage-manager.js.map +1 -1
  34. package/dist/types/storage.types.d.ts +243 -18
  35. package/dist/types/storage.types.d.ts.map +1 -1
  36. package/dist/utils/config.utils.d.ts +28 -4
  37. package/dist/utils/config.utils.d.ts.map +1 -1
  38. package/dist/utils/config.utils.js +121 -47
  39. package/dist/utils/config.utils.js.map +1 -1
  40. package/dist/utils/file.utils.d.ts +111 -14
  41. package/dist/utils/file.utils.d.ts.map +1 -1
  42. package/dist/utils/file.utils.js +215 -32
  43. package/dist/utils/file.utils.js.map +1 -1
  44. package/package.json +51 -27
  45. package/dist/drivers/oci.driver.d.ts +0 -37
  46. package/dist/drivers/oci.driver.d.ts.map +0 -1
  47. package/dist/drivers/oci.driver.js +0 -84
  48. package/dist/drivers/oci.driver.js.map +0 -1
@@ -1,9 +1,153 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { BaseStorageDriver } from './base.driver.js';
4
- import { createMonthBasedPath, ensureDirectoryExists, createLocalFileUrl } from '../utils/file.utils.js';
4
+ import { createMonthBasedPath, ensureDirectoryExists } from '../utils/file.utils.js';
5
5
  /**
6
- * Local storage driver for file system storage
6
+ * Magic byte signatures for common file types.
7
+ * Used to detect actual file content type regardless of extension.
8
+ * This provides security against extension spoofing attacks.
9
+ */
10
+ const MAGIC_BYTES = [
11
+ // Images
12
+ { bytes: [0xFF, 0xD8, 0xFF], mimeType: 'image/jpeg' },
13
+ { bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], mimeType: 'image/png' },
14
+ { bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], mimeType: 'image/gif' }, // GIF87a
15
+ { bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], mimeType: 'image/gif' }, // GIF89a
16
+ { bytes: [0x52, 0x49, 0x46, 0x46], mimeType: 'image/webp' }, // RIFF (WebP container)
17
+ { bytes: [0x42, 0x4D], mimeType: 'image/bmp' },
18
+ // Documents
19
+ { bytes: [0x25, 0x50, 0x44, 0x46], mimeType: 'application/pdf' }, // %PDF
20
+ { bytes: [0x50, 0x4B, 0x03, 0x04], mimeType: 'application/zip' }, // ZIP (also docx, xlsx, etc.)
21
+ { bytes: [0x50, 0x4B, 0x05, 0x06], mimeType: 'application/zip' }, // Empty ZIP
22
+ { bytes: [0x50, 0x4B, 0x07, 0x08], mimeType: 'application/zip' }, // Spanned ZIP
23
+ // Archives
24
+ { bytes: [0x1F, 0x8B], mimeType: 'application/gzip' },
25
+ { bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], mimeType: 'application/vnd.rar' }, // RAR
26
+ { bytes: [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], mimeType: 'application/x-7z-compressed' }, // 7z
27
+ // Audio/Video
28
+ { bytes: [0x49, 0x44, 0x33], mimeType: 'audio/mpeg' }, // ID3 (MP3)
29
+ { bytes: [0xFF, 0xFB], mimeType: 'audio/mpeg' }, // MP3 without ID3
30
+ { bytes: [0xFF, 0xFA], mimeType: 'audio/mpeg' }, // MP3 without ID3
31
+ { bytes: [0x4F, 0x67, 0x67, 0x53], mimeType: 'audio/ogg' }, // OGG
32
+ { bytes: [0x66, 0x74, 0x79, 0x70], mimeType: 'video/mp4', offset: 4 }, // ftyp (MP4/M4A)
33
+ // Executables (for security detection)
34
+ { bytes: [0x4D, 0x5A], mimeType: 'application/x-msdownload' }, // EXE/DLL
35
+ { bytes: [0x7F, 0x45, 0x4C, 0x46], mimeType: 'application/x-executable' }, // ELF
36
+ ];
37
+ /**
38
+ * Detects MIME type from file content using magic bytes.
39
+ * Returns undefined if no match is found (falls back to extension-based detection).
40
+ *
41
+ * @param filePath - Path to the file to analyze
42
+ * @returns Detected MIME type or undefined
43
+ */
44
+ function detectMimeTypeFromMagicBytes(filePath) {
45
+ try {
46
+ // Read first 16 bytes (enough for most magic numbers)
47
+ const fd = fs.openSync(filePath, 'r');
48
+ const buffer = Buffer.alloc(16);
49
+ const bytesRead = fs.readSync(fd, buffer, 0, 16, 0);
50
+ fs.closeSync(fd);
51
+ if (bytesRead === 0) {
52
+ return undefined;
53
+ }
54
+ for (const signature of MAGIC_BYTES) {
55
+ const offset = signature.offset || 0;
56
+ if (offset + signature.bytes.length > bytesRead) {
57
+ continue;
58
+ }
59
+ let matches = true;
60
+ for (let i = 0; i < signature.bytes.length; i++) {
61
+ if (buffer[offset + i] !== signature.bytes[i]) {
62
+ matches = false;
63
+ break;
64
+ }
65
+ }
66
+ if (matches) {
67
+ return signature.mimeType;
68
+ }
69
+ }
70
+ return undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ /**
77
+ * Maps file extensions to MIME types.
78
+ * Used as fallback when magic byte detection doesn't match.
79
+ */
80
+ const EXTENSION_MIME_MAP = {
81
+ // Images
82
+ '.jpg': 'image/jpeg',
83
+ '.jpeg': 'image/jpeg',
84
+ '.png': 'image/png',
85
+ '.gif': 'image/gif',
86
+ '.webp': 'image/webp',
87
+ '.svg': 'image/svg+xml',
88
+ '.ico': 'image/x-icon',
89
+ '.bmp': 'image/bmp',
90
+ '.tiff': 'image/tiff',
91
+ '.tif': 'image/tiff',
92
+ // Documents
93
+ '.pdf': 'application/pdf',
94
+ '.doc': 'application/msword',
95
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
96
+ '.xls': 'application/vnd.ms-excel',
97
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
98
+ '.ppt': 'application/vnd.ms-powerpoint',
99
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
100
+ '.txt': 'text/plain',
101
+ '.csv': 'text/csv',
102
+ '.json': 'application/json',
103
+ '.xml': 'application/xml',
104
+ // Audio
105
+ '.mp3': 'audio/mpeg',
106
+ '.wav': 'audio/wav',
107
+ '.ogg': 'audio/ogg',
108
+ '.m4a': 'audio/mp4',
109
+ // Video
110
+ '.mp4': 'video/mp4',
111
+ '.webm': 'video/webm',
112
+ '.avi': 'video/x-msvideo',
113
+ '.mov': 'video/quicktime',
114
+ '.mkv': 'video/x-matroska',
115
+ // Archives
116
+ '.zip': 'application/zip',
117
+ '.tar': 'application/x-tar',
118
+ '.gz': 'application/gzip',
119
+ '.rar': 'application/vnd.rar',
120
+ '.7z': 'application/x-7z-compressed',
121
+ // Web
122
+ '.html': 'text/html',
123
+ '.htm': 'text/html',
124
+ '.css': 'text/css',
125
+ '.js': 'application/javascript',
126
+ '.ts': 'application/typescript',
127
+ // Fonts
128
+ '.woff': 'font/woff',
129
+ '.woff2': 'font/woff2',
130
+ '.ttf': 'font/ttf',
131
+ '.otf': 'font/otf',
132
+ };
133
+ /**
134
+ * LocalStorageDriver - Saves files to your local filesystem.
135
+ *
136
+ * Great for development and small-scale applications.
137
+ * Files are organized by year/month folders automatically.
138
+ *
139
+ * **Security features:**
140
+ * - Path traversal prevention (blocks ../ and null bytes)
141
+ * - Symlinks are NOT followed or deleted (prevents directory escape attacks)
142
+ * - Magic byte detection for content-type validation (prevents extension spoofing)
143
+ * - Files stay within the configured base directory
144
+ *
145
+ * **Symlink behavior:** This driver explicitly rejects symlinks for security.
146
+ * If a file is a symlink, it will not be read, deleted, or listed. This prevents
147
+ * attackers from using symlinks to access files outside the storage directory.
148
+ *
149
+ * Note: Local storage doesn't support presigned URLs since
150
+ * there's no external service to sign requests against.
7
151
  */
8
152
  export class LocalStorageDriver extends BaseStorageDriver {
9
153
  constructor(config) {
@@ -11,92 +155,436 @@ export class LocalStorageDriver extends BaseStorageDriver {
11
155
  this.basePath = config.localPath || 'public/express-storage';
12
156
  }
13
157
  /**
14
- * Upload file to local storage
158
+ * Saves a file to the local filesystem.
159
+ *
160
+ * Files are automatically organized into YYYY/MM folders.
161
+ * For example, a file uploaded in January 2026 goes into:
162
+ * {basePath}/2026/01/{unique_filename}.jpg
163
+ *
164
+ * For large files (>100MB), uses streaming to reduce memory usage
165
+ * and prevent application crashes.
15
166
  */
16
- async upload(file) {
167
+ async upload(file, _options) {
17
168
  try {
18
- // Validate file
19
169
  const validationErrors = this.validateFile(file);
20
170
  if (validationErrors.length > 0) {
21
171
  return this.createErrorResult(validationErrors.join(', '));
22
172
  }
23
- // Generate unique filename
24
173
  const fileName = this.generateFileName(file.originalname);
25
- // Create month-based directory path
26
174
  const monthPath = createMonthBasedPath(this.basePath);
27
175
  const fullDirPath = path.resolve(monthPath);
28
- // Ensure directory exists
29
176
  ensureDirectoryExists(fullDirPath);
30
- // Create full file path
31
177
  const filePath = path.join(fullDirPath, fileName);
32
- // Write file to disk
33
- fs.writeFileSync(filePath, file.buffer);
34
- // Generate relative URL
35
- const relativePath = path.relative('public', filePath);
36
- const fileUrl = createLocalFileUrl(relativePath);
37
- return this.createSuccessResult(fileName, fileUrl);
178
+ // Use streaming for large files to reduce memory usage
179
+ if (this.shouldUseStreaming(file)) {
180
+ await this.uploadWithStream(file, filePath);
181
+ }
182
+ else {
183
+ const fileContent = this.getFileContent(file);
184
+ fs.writeFileSync(filePath, fileContent);
185
+ }
186
+ const fileUrl = this.generateFileUrl(filePath);
187
+ // Return relative path from basePath (e.g., '2026/01/filename.jpg')
188
+ const absoluteFilePath = path.resolve(filePath);
189
+ const absoluteBasePath = path.resolve(this.basePath);
190
+ const relativePath = this.normalizePathSeparators(path.relative(absoluteBasePath, absoluteFilePath));
191
+ return this.createSuccessResult(relativePath, fileUrl);
38
192
  }
39
193
  catch (error) {
40
194
  return this.createErrorResult(error instanceof Error ? error.message : 'Failed to upload file');
41
195
  }
42
196
  }
43
197
  /**
44
- * Generate upload URL (not supported for local storage)
198
+ * Uploads a large file using streaming.
199
+ *
200
+ * This method pipes the file stream directly to disk, which is more
201
+ * memory-efficient for large files (>100MB).
202
+ */
203
+ async uploadWithStream(file, filePath) {
204
+ return new Promise((resolve, reject) => {
205
+ const readStream = this.getFileStream(file);
206
+ const writeStream = fs.createWriteStream(filePath);
207
+ readStream
208
+ .pipe(writeStream)
209
+ .on('finish', resolve)
210
+ .on('error', (err) => {
211
+ // Clean up partial file on error
212
+ try {
213
+ if (fs.existsSync(filePath)) {
214
+ fs.unlinkSync(filePath);
215
+ }
216
+ }
217
+ catch {
218
+ // Ignore cleanup errors
219
+ }
220
+ reject(err);
221
+ });
222
+ // Handle read stream errors
223
+ readStream.on('error', (err) => {
224
+ writeStream.destroy();
225
+ try {
226
+ if (fs.existsSync(filePath)) {
227
+ fs.unlinkSync(filePath);
228
+ }
229
+ }
230
+ catch {
231
+ // Ignore cleanup errors
232
+ }
233
+ reject(err);
234
+ });
235
+ });
236
+ }
237
+ /**
238
+ * Builds a URL for accessing the file.
239
+ *
240
+ * If your basePath starts with 'public/', we strip that prefix
241
+ * since Express.static('public') serves files from /
242
+ *
243
+ * Example: public/uploads/2026/01/photo.jpg -> /uploads/2026/01/photo.jpg
244
+ */
245
+ generateFileUrl(filePath) {
246
+ const absoluteFilePath = path.resolve(filePath);
247
+ const absoluteBasePath = path.resolve(this.basePath);
248
+ const relativeFromBase = this.normalizePathSeparators(path.relative(absoluteBasePath, absoluteFilePath));
249
+ const normalizedBasePath = this.normalizePathSeparators(this.basePath);
250
+ if (normalizedBasePath.startsWith('public/')) {
251
+ const webBasePath = normalizedBasePath.replace(/^public\//, '');
252
+ return this.normalizeUrl(`/${webBasePath}/${relativeFromBase}`);
253
+ }
254
+ return this.normalizeUrl(`/${normalizedBasePath}/${relativeFromBase}`);
255
+ }
256
+ /**
257
+ * Converts Windows backslashes to forward slashes.
258
+ */
259
+ normalizePathSeparators(pathStr) {
260
+ return pathStr.replace(/\\/g, '/');
261
+ }
262
+ /**
263
+ * Removes duplicate slashes from URLs.
45
264
  */
46
- async generateUploadUrl(_fileName) {
265
+ normalizeUrl(url) {
266
+ return url.replace(/\/+/g, '/');
267
+ }
268
+ /**
269
+ * Local storage doesn't support presigned upload URLs.
270
+ */
271
+ async generateUploadUrl(_fileName, _contentType, _maxSize) {
47
272
  return this.createPresignedErrorResult('Presigned URLs are not supported for local storage');
48
273
  }
49
274
  /**
50
- * Generate view URL (not supported for local storage)
275
+ * Local storage doesn't support presigned view URLs.
51
276
  */
52
277
  async generateViewUrl(_fileName) {
53
278
  return this.createPresignedErrorResult('Presigned URLs are not supported for local storage');
54
279
  }
55
280
  /**
56
- * Delete file from local storage
281
+ * Validates a local file exists and matches expected values.
282
+ *
283
+ * Content type detection uses a two-tier approach:
284
+ * 1. Magic byte detection (examines actual file content for security)
285
+ * 2. Extension-based fallback (when magic bytes don't match)
286
+ *
287
+ * This helps detect extension spoofing attacks where a malicious file
288
+ * is renamed with an innocent extension (e.g., malware.exe -> photo.jpg).
289
+ */
290
+ async validateAndConfirmUpload(reference, options) {
291
+ const deleteOnFailure = options?.deleteOnFailure !== false;
292
+ try {
293
+ const filePath = this.resolveFilePath(reference);
294
+ if (!filePath || !fs.existsSync(filePath)) {
295
+ return {
296
+ success: false,
297
+ error: 'File not found',
298
+ };
299
+ }
300
+ const stats = fs.statSync(filePath);
301
+ const fileUrl = this.generateFileUrl(filePath);
302
+ // Try magic byte detection first (more secure), fall back to extension
303
+ const magicMimeType = detectMimeTypeFromMagicBytes(filePath);
304
+ const ext = path.extname(reference).toLowerCase();
305
+ const extensionMimeType = ext && EXTENSION_MIME_MAP[ext] ? EXTENSION_MIME_MAP[ext] : undefined;
306
+ // Use magic bytes if detected, otherwise fall back to extension
307
+ const actualContentType = magicMimeType || extensionMimeType;
308
+ const actualFileSize = stats.size;
309
+ // Security check: warn if magic bytes differ from extension (potential spoofing)
310
+ const contentTypeMismatchWarning = magicMimeType && extensionMimeType &&
311
+ magicMimeType !== extensionMimeType;
312
+ // Validate content type if expected
313
+ if (options?.expectedContentType && actualContentType !== options.expectedContentType) {
314
+ if (deleteOnFailure) {
315
+ await this.delete(reference);
316
+ }
317
+ const mismatchDetail = contentTypeMismatchWarning
318
+ ? ` (Warning: file extension suggests '${extensionMimeType}' but actual content is '${magicMimeType}')`
319
+ : '';
320
+ const errorResult = {
321
+ success: false,
322
+ error: `Content type mismatch: expected '${options.expectedContentType}', got '${actualContentType || 'unknown'}'${mismatchDetail}${deleteOnFailure ? ' (file deleted)' : ' (file kept for inspection)'}`,
323
+ };
324
+ if (actualContentType)
325
+ errorResult.actualContentType = actualContentType;
326
+ errorResult.actualFileSize = actualFileSize;
327
+ return errorResult;
328
+ }
329
+ // Validate file size if expected
330
+ if (options?.expectedFileSize !== undefined && actualFileSize !== options.expectedFileSize) {
331
+ if (deleteOnFailure) {
332
+ await this.delete(reference);
333
+ }
334
+ const errorResult = {
335
+ success: false,
336
+ error: `File size mismatch: expected ${options.expectedFileSize} bytes, got ${actualFileSize} bytes${deleteOnFailure ? ' (file deleted)' : ' (file kept for inspection)'}`,
337
+ };
338
+ if (actualContentType)
339
+ errorResult.actualContentType = actualContentType;
340
+ errorResult.actualFileSize = actualFileSize;
341
+ return errorResult;
342
+ }
343
+ const result = {
344
+ success: true,
345
+ reference,
346
+ viewUrl: fileUrl,
347
+ actualFileSize,
348
+ };
349
+ if (actualContentType) {
350
+ result.actualContentType = actualContentType;
351
+ }
352
+ return result;
353
+ }
354
+ catch (error) {
355
+ return {
356
+ success: false,
357
+ error: error instanceof Error ? error.message : 'Failed to validate upload',
358
+ };
359
+ }
360
+ }
361
+ /**
362
+ * Deletes a file from local storage.
363
+ *
364
+ * Security checks:
365
+ * - Rejects path traversal attempts (../ sequences)
366
+ * - Rejects null bytes in paths
367
+ * - Verifies target stays within base directory
368
+ * - Won't follow or delete symlinks (security: prevents directory escape attacks)
369
+ * - Only deletes regular files (not directories)
370
+ *
371
+ * @param reference - The file path relative to the storage base directory
372
+ * @returns true if file was deleted, false if not found or security check failed
57
373
  */
58
- async delete(fileName) {
374
+ async delete(reference) {
59
375
  try {
60
- // Find file in month directories
61
- const filePath = this.findFilePath(fileName);
62
- if (!filePath) {
376
+ if (reference.includes('..') || reference.includes('\0')) {
377
+ return false;
378
+ }
379
+ const baseDir = path.resolve(this.basePath);
380
+ const targetPath = path.join(baseDir, reference);
381
+ const resolvedPath = path.resolve(targetPath);
382
+ // Make sure we're not escaping the base directory
383
+ if (!resolvedPath.startsWith(baseDir + path.sep) && resolvedPath !== baseDir) {
384
+ return false;
385
+ }
386
+ let stat;
387
+ try {
388
+ stat = fs.lstatSync(resolvedPath);
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ // Don't follow symlinks — security protection against:
394
+ // 1. Directory escape attacks (symlink pointing outside storage)
395
+ // 2. Unauthorized file deletion via symlink redirection
396
+ // 3. Race conditions where symlink is swapped after check
397
+ if (stat.isSymbolicLink()) {
398
+ return false;
399
+ }
400
+ if (!stat.isFile()) {
63
401
  return false;
64
402
  }
65
- // Delete file
66
- fs.unlinkSync(filePath);
403
+ fs.unlinkSync(resolvedPath);
67
404
  return true;
68
405
  }
69
- catch (error) {
406
+ catch {
70
407
  return false;
71
408
  }
72
409
  }
73
410
  /**
74
- * Find file path by searching through month directories
411
+ * Safely resolves a reference to a file path within our base directory.
412
+ * Returns null for any suspicious input.
75
413
  */
76
- findFilePath(fileName) {
414
+ resolveFilePath(reference) {
77
415
  const baseDir = path.resolve(this.basePath);
78
- if (!fs.existsSync(baseDir)) {
416
+ if (reference.includes('..') || reference.includes('\0')) {
417
+ return null;
418
+ }
419
+ const directPath = path.join(baseDir, reference);
420
+ const resolvedPath = path.resolve(directPath);
421
+ if (!resolvedPath.startsWith(baseDir + path.sep) && resolvedPath !== baseDir) {
422
+ return null;
423
+ }
424
+ try {
425
+ const stat = fs.lstatSync(directPath);
426
+ if (stat.isSymbolicLink()) {
427
+ return null;
428
+ }
429
+ if (stat.isFile()) {
430
+ return directPath;
431
+ }
432
+ }
433
+ catch {
79
434
  return null;
80
435
  }
81
- // Search through all subdirectories
82
- const searchDirectories = (dir) => {
83
- const items = fs.readdirSync(dir);
84
- for (const item of items) {
85
- const itemPath = path.join(dir, item);
86
- const stat = fs.statSync(itemPath);
87
- if (stat.isDirectory()) {
88
- // Recursively search subdirectories
89
- const found = searchDirectories(itemPath);
90
- if (found)
91
- return found;
436
+ return null;
437
+ }
438
+ /**
439
+ * Lists files in local storage with optional prefix filtering and pagination.
440
+ *
441
+ * Uses early termination to avoid loading all files into memory when possible.
442
+ * Files are collected in sorted order and iteration stops once we have enough
443
+ * results for the requested page.
444
+ */
445
+ async listFiles(prefix, maxResults = 1000, continuationToken) {
446
+ try {
447
+ if (prefix && (prefix.includes('..') || prefix.includes('\0'))) {
448
+ return {
449
+ success: false,
450
+ error: 'Invalid prefix: path traversal sequences are not allowed',
451
+ };
452
+ }
453
+ const validatedMaxResults = Math.floor(Math.max(1, Math.min(Number.isNaN(maxResults) ? 1000 : maxResults, 1000)));
454
+ const baseDir = path.resolve(this.basePath);
455
+ if (!fs.existsSync(baseDir)) {
456
+ return { success: true, files: [] };
457
+ }
458
+ const matchingFiles = [];
459
+ let hasMore = false;
460
+ // Maximum recursion depth to prevent stack overflow on deeply nested directories
461
+ const MAX_RECURSION_DEPTH = 100;
462
+ // Maximum files to collect before stopping (for memory protection)
463
+ // We collect a bit more than needed for accurate hasMore detection
464
+ const MAX_COLLECT = validatedMaxResults + 1;
465
+ // Skip directories that can't possibly contain matching files
466
+ const couldContainPrefix = (dirRelativePath, targetPrefix) => {
467
+ if (!targetPrefix)
468
+ return true;
469
+ return targetPrefix.startsWith(dirRelativePath) ||
470
+ dirRelativePath.startsWith(targetPrefix) ||
471
+ dirRelativePath === '';
472
+ };
473
+ // Check if we should skip this file based on continuation token
474
+ const isAfterToken = (filePath, token) => {
475
+ if (!token)
476
+ return true;
477
+ return filePath.localeCompare(token) > 0;
478
+ };
479
+ const collectFiles = (dir, dirRelativePath, depth = 0) => {
480
+ // Return true to continue, false to stop early
481
+ // Prevent stack overflow from extremely deep directory structures
482
+ if (depth > MAX_RECURSION_DEPTH) {
483
+ return true;
484
+ }
485
+ // Early termination: we have enough files
486
+ if (matchingFiles.length >= MAX_COLLECT) {
487
+ hasMore = true;
488
+ return false;
489
+ }
490
+ if (prefix && !couldContainPrefix(dirRelativePath, prefix)) {
491
+ return true;
492
+ }
493
+ let items;
494
+ try {
495
+ items = fs.readdirSync(dir);
496
+ }
497
+ catch {
498
+ return true;
92
499
  }
93
- else if (item === fileName) {
94
- return itemPath;
500
+ // Sort items for consistent ordering
501
+ items.sort();
502
+ for (const item of items) {
503
+ // Check if we have enough files
504
+ if (matchingFiles.length >= MAX_COLLECT) {
505
+ hasMore = true;
506
+ return false;
507
+ }
508
+ const itemPath = path.join(dir, item);
509
+ const relativePath = dirRelativePath ? `${dirRelativePath}/${item}` : item;
510
+ let stat;
511
+ try {
512
+ stat = fs.lstatSync(itemPath);
513
+ }
514
+ catch {
515
+ continue;
516
+ }
517
+ // Skip symlinks for security reasons:
518
+ // 1. Symlinks could point outside the storage directory (directory escape)
519
+ // 2. Symlinks could create infinite loops in directory traversal
520
+ // 3. Symlinks could expose sensitive files from other locations
521
+ // If you need symlink support, use a different storage strategy
522
+ if (stat.isSymbolicLink()) {
523
+ continue;
524
+ }
525
+ if (stat.isDirectory()) {
526
+ // Skip directories that are lexicographically before our continuation token
527
+ // (they can't contain files we need)
528
+ if (continuationToken && !couldContainPrefix(relativePath, continuationToken.split('/')[0] || '')) {
529
+ // Only skip if this directory is completely before the token
530
+ if (relativePath.localeCompare(continuationToken) < 0 && !continuationToken.startsWith(relativePath + '/')) {
531
+ continue;
532
+ }
533
+ }
534
+ const shouldContinue = collectFiles(itemPath, relativePath, depth + 1);
535
+ if (!shouldContinue) {
536
+ return false;
537
+ }
538
+ }
539
+ else if (stat.isFile()) {
540
+ if (prefix && !relativePath.startsWith(prefix)) {
541
+ continue;
542
+ }
543
+ // Skip files at or before the continuation token
544
+ if (!isAfterToken(relativePath, continuationToken)) {
545
+ continue;
546
+ }
547
+ const fileInfo = {
548
+ name: relativePath,
549
+ size: stat.size,
550
+ lastModified: stat.mtime,
551
+ };
552
+ const ext = path.extname(relativePath).toLowerCase();
553
+ if (ext && EXTENSION_MIME_MAP[ext]) {
554
+ fileInfo.contentType = EXTENSION_MIME_MAP[ext];
555
+ }
556
+ else {
557
+ fileInfo.contentType = 'application/octet-stream';
558
+ }
559
+ matchingFiles.push(fileInfo);
560
+ }
561
+ }
562
+ return true;
563
+ };
564
+ collectFiles(baseDir, '');
565
+ // Sort results (should already be mostly sorted due to directory traversal order)
566
+ matchingFiles.sort((a, b) => a.name.localeCompare(b.name));
567
+ // Take only the requested number of results
568
+ const pageFiles = matchingFiles.slice(0, validatedMaxResults);
569
+ const result = {
570
+ success: true,
571
+ files: pageFiles,
572
+ };
573
+ // Set next token if there are more results
574
+ if (matchingFiles.length > validatedMaxResults || hasMore) {
575
+ const lastFile = pageFiles[pageFiles.length - 1];
576
+ if (lastFile) {
577
+ result.nextToken = lastFile.name;
95
578
  }
96
579
  }
97
- return null;
98
- };
99
- return searchDirectories(baseDir);
580
+ return result;
581
+ }
582
+ catch (error) {
583
+ return {
584
+ success: false,
585
+ error: error instanceof Error ? error.message : 'Failed to list files',
586
+ };
587
+ }
100
588
  }
101
589
  }
102
590
  //# sourceMappingURL=local.driver.js.map