@arela/uploader 1.0.3 → 1.0.4

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 (44) hide show
  1. package/.env.local +316 -0
  2. package/coverage/IdentifyCommand.js.html +1462 -0
  3. package/coverage/PropagateCommand.js.html +1507 -0
  4. package/coverage/PushCommand.js.html +1504 -0
  5. package/coverage/ScanCommand.js.html +1654 -0
  6. package/coverage/UploadCommand.js.html +1846 -0
  7. package/coverage/WatchCommand.js.html +4111 -0
  8. package/coverage/base.css +224 -0
  9. package/coverage/block-navigation.js +87 -0
  10. package/coverage/favicon.png +0 -0
  11. package/coverage/index.html +191 -0
  12. package/coverage/lcov-report/IdentifyCommand.js.html +1462 -0
  13. package/coverage/lcov-report/PropagateCommand.js.html +1507 -0
  14. package/coverage/lcov-report/PushCommand.js.html +1504 -0
  15. package/coverage/lcov-report/ScanCommand.js.html +1654 -0
  16. package/coverage/lcov-report/UploadCommand.js.html +1846 -0
  17. package/coverage/lcov-report/WatchCommand.js.html +4111 -0
  18. package/coverage/lcov-report/base.css +224 -0
  19. package/coverage/lcov-report/block-navigation.js +87 -0
  20. package/coverage/lcov-report/favicon.png +0 -0
  21. package/coverage/lcov-report/index.html +191 -0
  22. package/coverage/lcov-report/prettify.css +1 -0
  23. package/coverage/lcov-report/prettify.js +2 -0
  24. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  25. package/coverage/lcov-report/sorter.js +210 -0
  26. package/coverage/lcov.info +1937 -0
  27. package/coverage/prettify.css +1 -0
  28. package/coverage/prettify.js +2 -0
  29. package/coverage/sort-arrow-sprite.png +0 -0
  30. package/coverage/sorter.js +210 -0
  31. package/docs/CROSS_PLATFORM_PATH_HANDLING.md +593 -0
  32. package/package.json +28 -2
  33. package/src/commands/IdentifyCommand.js +1 -28
  34. package/src/commands/PropagateCommand.js +1 -1
  35. package/src/commands/PushCommand.js +1 -1
  36. package/src/commands/ScanCommand.js +27 -20
  37. package/src/config/config.js +25 -48
  38. package/src/services/ScanApiService.js +4 -5
  39. package/src/utils/PathNormalizer.js +220 -0
  40. package/tests/commands/IdentifyCommand.test.js +570 -0
  41. package/tests/commands/PropagateCommand.test.js +568 -0
  42. package/tests/commands/PushCommand.test.js +754 -0
  43. package/tests/commands/ScanCommand.test.js +382 -0
  44. package/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
@@ -10,6 +10,7 @@ import appConfig from '../config/config.js';
10
10
  import ErrorHandler from '../errors/ErrorHandler.js';
11
11
  import { ConfigurationError } from '../errors/ErrorTypes.js';
12
12
  import FileOperations from '../utils/FileOperations.js';
13
+ import PathNormalizer from '../utils/PathNormalizer.js';
13
14
 
14
15
  /**
15
16
  * Scan Command Handler
@@ -40,13 +41,13 @@ export class ScanCommand {
40
41
  this.scanApiService = new ScanApiService();
41
42
 
42
43
  const scanConfig = appConfig.getScanConfig();
43
- const basePath = appConfig.getBasePath();
44
+ // Ensure basePath is absolute for scan operations
45
+ const basePath = PathNormalizer.toAbsolutePath(appConfig.getBasePath());
44
46
 
45
47
  logger.info('šŸ” Starting arela scan command');
46
48
  logger.info(`šŸ“¦ Company: ${scanConfig.companySlug}`);
47
49
  logger.info(`šŸ–„ļø Server: ${scanConfig.serverId}`);
48
50
  logger.info(`šŸ“‚ Base Path: ${basePath}`);
49
- logger.info(`šŸ·ļø Label: ${scanConfig.basePathLabel}`);
50
51
  logger.info(`šŸ“Š Directory Level: ${scanConfig.directoryLevel}`);
51
52
 
52
53
  // Step 1: Discover directories at specified level
@@ -63,15 +64,14 @@ export class ScanCommand {
63
64
  logger.info('\nšŸ“ Registering scan instances...');
64
65
  const registrations = [];
65
66
  for (const dir of directories) {
66
- const dirLabel = dir.label
67
- ? `${scanConfig.basePathLabel}_${dir.label.replace(/[^a-zA-Z0-9_-]/g, '_')}`
68
- : scanConfig.basePathLabel;
67
+ // dir.path is already absolute from #discoverDirectories
68
+ // Use the absolute path as basePathLabel (simplifies everything!)
69
+ const absolutePath = dir.path;
69
70
 
70
71
  const registration = await this.scanApiService.registerInstance({
71
72
  companySlug: scanConfig.companySlug,
72
73
  serverId: scanConfig.serverId,
73
- basePathLabel: dirLabel,
74
- basePathFull: dir.path,
74
+ basePathFull: absolutePath,
75
75
  });
76
76
 
77
77
  registrations.push({ ...registration, directory: dir });
@@ -181,8 +181,8 @@ export class ScanCommand {
181
181
  return sources.map((source) => {
182
182
  const sourcePath =
183
183
  source === '.' ? basePath : path.resolve(basePath, source);
184
- const label =
185
- source === '.' ? '' : source.replace(/[^a-zA-Z0-9_-]/g, '_');
184
+ // Label is relative path for display purposes only
185
+ const label = source === '.' ? '' : source;
186
186
  return { path: sourcePath, label };
187
187
  });
188
188
  }
@@ -204,20 +204,20 @@ export class ScanCommand {
204
204
  // Source is current directory, use discovered path as-is
205
205
  directories.push(levelDir);
206
206
  } else {
207
- // Append source to both path and label
207
+ // Append source to path
208
208
  const combinedPath = path.resolve(levelDir.path, source);
209
- const sourceLabel = source.replace(/[^a-zA-Z0-9_-]/g, '_');
210
- const combinedLabel = levelDir.label
211
- ? `${levelDir.label}_${sourceLabel}`
212
- : sourceLabel;
213
209
 
214
210
  // Only add if the combined path actually exists
215
211
  try {
216
212
  const stats = await fs.stat(combinedPath);
217
213
  if (stats.isDirectory()) {
214
+ // Label for display
215
+ const label = levelDir.label
216
+ ? `${levelDir.label}/${source}`
217
+ : source;
218
218
  directories.push({
219
219
  path: combinedPath,
220
- label: combinedLabel,
220
+ label,
221
221
  });
222
222
  } else {
223
223
  logger.debug(`ā­ļø Skipping ${combinedPath} (not a directory)`);
@@ -249,8 +249,9 @@ export class ScanCommand {
249
249
  const fullPath = path.join(basePath, currentPath);
250
250
 
251
251
  if (currentLevel === targetLevel) {
252
- const label = currentPath.replace(/\\/g, '/').replace(/^\//g, '');
253
- return [{ path: fullPath, label: label || 'root' }];
252
+ // Label is the relative path for display
253
+ const label = currentPath || '';
254
+ return [{ path: fullPath, label }];
254
255
  }
255
256
 
256
257
  const directories = [];
@@ -418,20 +419,26 @@ export class ScanCommand {
418
419
 
419
420
  /**
420
421
  * Normalize file record for database insertion
422
+ * Stores paths with forward slashes for consistency but keeps them absolute
421
423
  * @private
422
424
  */
423
425
  #normalizeFileRecord(filePath, fileStats, basePath, scanTimestamp) {
424
426
  const fileName = path.basename(filePath);
425
427
  const fileExtension = path.extname(filePath).toLowerCase().replace('.', '');
426
- const directoryPath = path.dirname(filePath);
427
- const relativePath = path.relative(basePath, filePath);
428
+
429
+ // Normalize separators to forward slashes for consistency
430
+ const directoryPath = PathNormalizer.normalizeSeparators(
431
+ path.dirname(filePath),
432
+ );
433
+ const relativePath = PathNormalizer.getRelativePath(filePath, basePath);
434
+ const absolutePath = PathNormalizer.normalizeSeparators(filePath);
428
435
 
429
436
  return {
430
437
  fileName,
431
438
  fileExtension,
432
439
  directoryPath,
433
440
  relativePath,
434
- absolutePath: filePath,
441
+ absolutePath,
435
442
  sizeBytes: Number(fileStats.size),
436
443
  modifiedAt: fileStats.mtime.toISOString(),
437
444
  scanTimestamp,
@@ -3,6 +3,8 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
 
6
+ import PathNormalizer from '../utils/PathNormalizer.js';
7
+
6
8
  config();
7
9
 
8
10
  /**
@@ -32,10 +34,10 @@ class Config {
32
34
  const __dirname = path.dirname(__filename);
33
35
  const packageJsonPath = path.resolve(__dirname, '../../package.json');
34
36
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
35
- return packageJson.version || '1.0.3';
37
+ return packageJson.version || '1.0.4';
36
38
  } catch (error) {
37
39
  console.warn('āš ļø Could not read package.json version, using fallback');
38
- return '1.0.3';
40
+ return '1.0.4';
39
41
  }
40
42
  }
41
43
 
@@ -251,13 +253,16 @@ class Config {
251
253
  let basePathLabel = process.env.ARELA_BASE_PATH_LABEL;
252
254
 
253
255
  // Auto-derive basePathLabel from UPLOAD_BASE_PATH if not set
256
+ // IMPORTANT: Always resolve to absolute path for uniqueness
254
257
  if (!basePathLabel && process.env.UPLOAD_BASE_PATH) {
255
258
  const basePath = process.env.UPLOAD_BASE_PATH;
256
- // Get the last segment of the path
257
- const segments = basePath.split(path.sep).filter(Boolean);
258
- basePathLabel = segments[segments.length - 1] || 'root';
259
- // Sanitize the label
260
- basePathLabel = basePathLabel.replace(/[^a-zA-Z0-9_-]/g, '_');
259
+ // Resolve to absolute path (handles ../sample vs ./sample correctly)
260
+ basePathLabel = PathNormalizer.toAbsolutePath(basePath);
261
+ }
262
+
263
+ // If basePathLabel is provided, ensure it's absolute
264
+ if (basePathLabel && !path.isAbsolute(basePathLabel)) {
265
+ basePathLabel = PathNormalizer.toAbsolutePath(basePathLabel);
261
266
  }
262
267
 
263
268
  // Parse exclude patterns
@@ -271,16 +276,21 @@ class Config {
271
276
  .filter(Boolean);
272
277
 
273
278
  // Generate table name if all components are available
279
+ // Note: This is just for reference; actual table names are generated dynamically
280
+ // in ScanCommand based on discovered directories and levels
274
281
  let tableName = null;
275
282
  if (companySlug && serverId && basePathLabel) {
276
- const rawName = `${companySlug}_${serverId}_${basePathLabel}`;
277
- tableName = this.#sanitizeTableName(rawName);
283
+ tableName = PathNormalizer.generateTableName({
284
+ companySlug,
285
+ serverId,
286
+ basePathLabel,
287
+ });
278
288
  }
279
289
 
280
290
  return {
281
291
  companySlug,
282
292
  serverId,
283
- basePathLabel,
293
+ basePathFull: basePathLabel, // Renamed for consistency
284
294
  tableName,
285
295
  excludePatterns,
286
296
  batchSize: parseInt(process.env.SCAN_BATCH_SIZE) || 2000,
@@ -311,38 +321,6 @@ class Config {
311
321
  };
312
322
  }
313
323
 
314
- /**
315
- * Sanitize and generate table name
316
- * @private
317
- */
318
- #sanitizeTableName(rawName) {
319
- // Sanitize: lowercase, replace special chars with underscore
320
- let sanitized = rawName
321
- .toLowerCase()
322
- .replace(/[^a-z0-9_]/g, '_')
323
- .replace(/_+/g, '_')
324
- .replace(/^_|_$/g, '');
325
-
326
- const prefix = 'scan_';
327
- let tableName = prefix + sanitized;
328
-
329
- // PostgreSQL table name limit is 63 characters
330
- if (tableName.length > 63) {
331
- // Simple hash without crypto module
332
- let hash = 0;
333
- for (let i = 0; i < rawName.length; i++) {
334
- const char = rawName.charCodeAt(i);
335
- hash = (hash << 5) - hash + char;
336
- hash = hash & hash; // Convert to 32bit integer
337
- }
338
- const hashStr = Math.abs(hash).toString(36).substring(0, 8);
339
- const maxBaseLength = 63 - hashStr.length - 1;
340
- tableName = tableName.substring(0, maxBaseLength) + '_' + hashStr;
341
- }
342
-
343
- return tableName;
344
- }
345
-
346
324
  /**
347
325
  * Validate scan configuration
348
326
  * @throws {Error} If required scan configuration is missing
@@ -376,9 +354,9 @@ class Config {
376
354
  );
377
355
  }
378
356
 
379
- if (!this.scan.basePathLabel) {
357
+ if (!this.scan.basePathFull) {
380
358
  errors.push(
381
- 'Could not determine base path label. Set ARELA_BASE_PATH_LABEL or UPLOAD_BASE_PATH',
359
+ 'Could not determine base path. Set ARELA_BASE_PATH_LABEL or UPLOAD_BASE_PATH',
382
360
  );
383
361
  }
384
362
 
@@ -394,10 +372,8 @@ class Config {
394
372
  * @returns {Object} Scan configuration
395
373
  */
396
374
  getScanConfig() {
397
- return {
398
- ...this.scan,
399
- basePathFull: this.upload.basePath,
400
- };
375
+ // Return scan config with basePathFull already resolved to absolute path
376
+ return this.scan;
401
377
  }
402
378
 
403
379
  /**
@@ -549,6 +525,7 @@ class Config {
549
525
 
550
526
  /**
551
527
  * Get base path with validation
528
+ * Returns the path as configured (may be relative for legacy compatibility)
552
529
  * @returns {string} Base path for uploads
553
530
  * @throws {Error} If base path is not configured
554
531
  */
@@ -249,7 +249,6 @@ export class ScanApiService {
249
249
  const result = await this.#request('/api/uploader/scan/register', 'POST', {
250
250
  companySlug: config.companySlug,
251
251
  serverId: config.serverId,
252
- basePathLabel: config.basePathLabel,
253
252
  basePathFull: config.basePathFull,
254
253
  });
255
254
 
@@ -327,15 +326,15 @@ export class ScanApiService {
327
326
  * Get all tables for a specific instance
328
327
  * @param {string} companySlug - Company slug
329
328
  * @param {string} serverId - Server ID
330
- * @param {string} basePathLabel - Base path label
329
+ * @param {string} basePathFull - Base path (absolute)
331
330
  * @returns {Promise<Array>} List of tables for the instance
332
331
  */
333
- async getInstanceTables(companySlug, serverId, basePathLabel) {
332
+ async getInstanceTables(companySlug, serverId, basePathFull) {
334
333
  logger.debug(
335
- `Fetching instance tables for ${companySlug}/${serverId}/${basePathLabel}...`,
334
+ `Fetching instance tables for ${companySlug}/${serverId}/${basePathFull}...`,
336
335
  );
337
336
  return await this.#request(
338
- `/api/uploader/scan/instance-tables?companySlug=${encodeURIComponent(companySlug)}&serverId=${encodeURIComponent(serverId)}&basePathLabel=${encodeURIComponent(basePathLabel)}`,
337
+ `/api/uploader/scan/instance-tables?companySlug=${encodeURIComponent(companySlug)}&serverId=${encodeURIComponent(serverId)}&basePathFull=${encodeURIComponent(basePathFull)}`,
339
338
  'GET',
340
339
  );
341
340
  }
@@ -0,0 +1,220 @@
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ /**
6
+ * PathNormalizer - Absolute path resolution and table name generation
7
+ *
8
+ * Strategy:
9
+ * 1. Always resolve paths to absolute paths first (resolves ../sample vs ./sample)
10
+ * 2. Store absolute paths in base_path_full
11
+ * 3. Normalize ONLY for table name generation (sanitize special chars)
12
+ * 4. Keep full path structure in table names (hash if too long)
13
+ */
14
+ export class PathNormalizer {
15
+ /**
16
+ * Resolve a path to an absolute path
17
+ * This ensures ../sample and ./sample are treated as different paths
18
+ *
19
+ * Examples:
20
+ * - ../sample (from /data/project) -> /data/sample
21
+ * - ./sample (from /data/project) -> /data/project/sample
22
+ * - C:\Users\Documents -> C:\Users\Documents (on Windows)
23
+ * - /home/user/docs -> /home/user/docs (on Unix)
24
+ *
25
+ * @param {string} inputPath - Path to resolve
26
+ * @param {string} basePath - Base path for resolving relative paths (defaults to cwd)
27
+ * @returns {string} Absolute path in native OS format
28
+ */
29
+ static toAbsolutePath(inputPath, basePath = null) {
30
+ if (!inputPath) return '';
31
+
32
+ // If already absolute, return as-is
33
+ if (path.isAbsolute(inputPath)) {
34
+ return path.normalize(inputPath);
35
+ }
36
+
37
+ // Resolve relative path
38
+ const base = basePath || process.cwd();
39
+ return path.resolve(base, inputPath);
40
+ }
41
+
42
+ /**
43
+ * Normalize path separators to forward slashes for consistency
44
+ * Does NOT remove drive letters or convert to POSIX - just normalizes separators
45
+ *
46
+ * @param {string} inputPath - Path to normalize
47
+ * @returns {string} Path with forward slashes
48
+ */
49
+ static normalizeSeparators(inputPath) {
50
+ if (!inputPath) return '';
51
+ return inputPath.replace(/\\/g, '/');
52
+ }
53
+
54
+ /**
55
+ * Get relative path from base with normalized separators
56
+ *
57
+ * @param {string} fullPath - Full absolute path
58
+ * @param {string} basePath - Base absolute path to subtract
59
+ * @returns {string} Relative path with forward slashes
60
+ */
61
+ static getRelativePath(fullPath, basePath) {
62
+ const relativePath = path.relative(basePath, fullPath);
63
+ return this.normalizeSeparators(relativePath);
64
+ }
65
+
66
+ /**
67
+ * Sanitize an absolute path for use in database table names
68
+ * Converts path separators and special characters to underscores
69
+ * Preserves the path structure for uniqueness
70
+ *
71
+ * Examples:
72
+ * - /data/2023 -> data_2023
73
+ * - C:\Users\Documents -> c_users_documents
74
+ * - /home/user/project/sample -> home_user_project_sample
75
+ *
76
+ * @param {string} absolutePath - Absolute path to sanitize
77
+ * @returns {string} Sanitized string safe for table names
78
+ */
79
+ static sanitizeForTableName(absolutePath) {
80
+ if (!absolutePath) return '';
81
+
82
+ return absolutePath
83
+ .toLowerCase()
84
+ .replace(/^[a-z]:/i, '') // Remove Windows drive letter (C:, D:, etc.)
85
+ .replace(/[^a-z0-9]/g, '_') // Replace all non-alphanumeric with underscore
86
+ .replace(/_+/g, '_') // Replace multiple underscores with single
87
+ .replace(/^_|_$/g, ''); // Remove leading/trailing underscores
88
+ }
89
+
90
+ /**
91
+ * Generate a PostgreSQL-compatible table name from scan configuration
92
+ * Format: scan_<company>_<server>_<sanitized_absolute_path>
93
+ *
94
+ * Uses the absolute path to ensure uniqueness:
95
+ * - ../sample -> /data/sample
96
+ * - ./sample -> /data/project/sample
97
+ * These create different table names!
98
+ *
99
+ * Handles the 63-character PostgreSQL limit by:
100
+ * 1. Trying the full name first
101
+ * 2. If too long, truncating and adding an 8-character hash for uniqueness
102
+ *
103
+ * @param {Object} config - Scan configuration
104
+ * @param {string} config.companySlug - Company identifier
105
+ * @param {string} config.serverId - Server identifier
106
+ * @param {string} config.basePathLabel - Absolute base path
107
+ * @returns {string} PostgreSQL-compatible table name
108
+ */
109
+ static generateTableName(config) {
110
+ const { companySlug, serverId, basePathLabel } = config;
111
+
112
+ // Sanitize each component (basePathLabel should already be absolute)
113
+ const sanitizedCompany = companySlug
114
+ .toLowerCase()
115
+ .replace(/[^a-z0-9]/g, '_');
116
+ const sanitizedServer = serverId.toLowerCase().replace(/[^a-z0-9]/g, '_');
117
+ const sanitizedPath = this.sanitizeForTableName(basePathLabel);
118
+
119
+ // Build raw name for hashing (preserve original for uniqueness)
120
+ const rawName = `${companySlug}_${serverId}_${basePathLabel}`;
121
+
122
+ // Build sanitized table name
123
+ let tableName = `scan_${sanitizedCompany}_${sanitizedServer}`;
124
+
125
+ if (sanitizedPath) {
126
+ tableName += `_${sanitizedPath}`;
127
+ }
128
+
129
+ // PostgreSQL table name limit is 63 characters
130
+ if (tableName.length > 63) {
131
+ // Generate a hash for uniqueness
132
+ const hash = crypto
133
+ .createHash('md5')
134
+ .update(rawName)
135
+ .digest('hex')
136
+ .substring(0, 8);
137
+
138
+ const basePrefix = 'scan_';
139
+ const prefix = `${basePrefix}${sanitizedCompany}_${sanitizedServer}_`;
140
+
141
+ // Check if even prefix + hash exceeds limit
142
+ if (prefix.length + hash.length > 63) {
143
+ // Company/server names are too long, need to truncate them too
144
+ const maxCompanyServerLength = 63 - basePrefix.length - hash.length - 2; // -2 for underscores
145
+ const halfLength = Math.floor(maxCompanyServerLength / 2);
146
+ const companyLength = halfLength;
147
+ const serverLength = maxCompanyServerLength - companyLength;
148
+
149
+ const truncatedCompany = sanitizedCompany.substring(0, companyLength);
150
+ const truncatedServer = sanitizedServer.substring(0, serverLength);
151
+
152
+ tableName = `${basePrefix}${truncatedCompany}_${truncatedServer}_${hash}`;
153
+ } else {
154
+ // Preserve start and end of path, put hash in middle
155
+ const availableSpace = 63 - prefix.length - hash.length - 2; // -2 for underscores around hash
156
+
157
+ if (availableSpace <= 0 || !sanitizedPath) {
158
+ // If no space for path or path is empty, just use hash
159
+ tableName = `${prefix}${hash}`;
160
+ } else if (sanitizedPath.length <= availableSpace) {
161
+ // Path fits in available space, use it all
162
+ tableName = `${prefix}${sanitizedPath}_${hash}`;
163
+ } else {
164
+ // Need to split: preserve start and end
165
+ const halfSpace = Math.floor(availableSpace / 2);
166
+ const startLength = halfSpace;
167
+ const endLength = availableSpace - startLength;
168
+
169
+ // Extract start and end portions of the sanitized path
170
+ const pathStart = sanitizedPath.substring(0, startLength);
171
+ const pathEnd = sanitizedPath.substring(sanitizedPath.length - endLength);
172
+
173
+ // Build table name with start, hash, and end
174
+ tableName = `${prefix}${pathStart}_${hash}_${pathEnd}`;
175
+ }
176
+ }
177
+ }
178
+
179
+ return tableName;
180
+ }
181
+
182
+ /**
183
+ * Build an absolute path from base and relative component
184
+ *
185
+ * @param {string} basePath - Absolute base path
186
+ * @param {string} relativePath - Relative path from base (can be empty)
187
+ * @returns {string} Absolute path
188
+ */
189
+ static buildAbsolutePath(basePath, relativePath = '') {
190
+ if (!relativePath || relativePath === '.') {
191
+ return basePath;
192
+ }
193
+ return path.resolve(basePath, relativePath);
194
+ }
195
+
196
+ /**
197
+ * Validate that a path can be used for table name generation
198
+ *
199
+ * @param {string} pathStr - Path to validate
200
+ * @returns {Object} Validation result { valid: boolean, error?: string }
201
+ */
202
+ static validatePath(pathStr) {
203
+ if (!pathStr || typeof pathStr !== 'string') {
204
+ return { valid: false, error: 'Path is required and must be a string' };
205
+ }
206
+
207
+ if (pathStr.trim() === '') {
208
+ return { valid: false, error: 'Path cannot be empty' };
209
+ }
210
+
211
+ // Check for null bytes (security)
212
+ if (pathStr.includes('\0')) {
213
+ return { valid: false, error: 'Path cannot contain null bytes' };
214
+ }
215
+
216
+ return { valid: true };
217
+ }
218
+ }
219
+
220
+ export default PathNormalizer;