qvdjs 0.9.0 → 0.9.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.
package/dist/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- var path2 = require('path');
4
- var fs2 = require('fs');
3
+ var fs = require('fs');
4
+ var path = require('path');
5
5
  var crypto = require('crypto');
6
6
  var xml2 = require('xml2js');
7
7
  var assert = require('assert');
@@ -10,8 +10,8 @@ var v8 = require('v8');
10
10
 
11
11
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
12
12
 
13
- var path2__default = /*#__PURE__*/_interopDefault(path2);
14
- var fs2__default = /*#__PURE__*/_interopDefault(fs2);
13
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
14
+ var path__default = /*#__PURE__*/_interopDefault(path);
15
15
  var crypto__default = /*#__PURE__*/_interopDefault(crypto);
16
16
  var xml2__default = /*#__PURE__*/_interopDefault(xml2);
17
17
  var assert__default = /*#__PURE__*/_interopDefault(assert);
@@ -262,25 +262,101 @@ var init_QvdSymbol = __esm({
262
262
  };
263
263
  }
264
264
  });
265
+ function isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) {
266
+ const isCaseInsensitiveFS = process.platform === "win32";
267
+ const base = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
268
+ const target = isCaseInsensitiveFS ? resolvedPath.toLowerCase() : resolvedPath;
269
+ const relative = path__default.default.relative(base, target);
270
+ if (relative === "") {
271
+ return true;
272
+ }
273
+ if (path__default.default.isAbsolute(relative)) {
274
+ return false;
275
+ }
276
+ return relative !== ".." && !relative.startsWith(`..${path__default.default.sep}`);
277
+ }
278
+ function resolveDeepestExisting(target) {
279
+ let current = target;
280
+ for (; ; ) {
281
+ try {
282
+ return fs__default.default.realpathSync(current);
283
+ } catch (error) {
284
+ const code = (
285
+ /** @type {{code?: string}} */
286
+ error?.code
287
+ );
288
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
289
+ return null;
290
+ }
291
+ const parent = path__default.default.dirname(current);
292
+ if (parent === current) {
293
+ return null;
294
+ }
295
+ current = parent;
296
+ }
297
+ }
298
+ }
299
+ function isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath) {
300
+ let baseStat;
301
+ try {
302
+ baseStat = fs__default.default.statSync(fs__default.default.realpathSync(resolvedBaseDir));
303
+ } catch {
304
+ return null;
305
+ }
306
+ let current = resolveDeepestExisting(resolvedPath);
307
+ if (current === null) {
308
+ return null;
309
+ }
310
+ for (; ; ) {
311
+ let stat;
312
+ try {
313
+ stat = fs__default.default.statSync(current);
314
+ } catch {
315
+ return null;
316
+ }
317
+ if (stat.dev === baseStat.dev && stat.ino === baseStat.ino) {
318
+ return true;
319
+ }
320
+ const parent = path__default.default.dirname(current);
321
+ if (parent === current) {
322
+ return false;
323
+ }
324
+ current = parent;
325
+ }
326
+ }
265
327
  function validatePath(filePath, allowedDir) {
328
+ if (typeof filePath !== "string" || filePath.length === 0) {
329
+ throw new exports.QvdValidationError("filePath must be a non-empty string", {
330
+ provided: filePath,
331
+ type: typeof filePath
332
+ });
333
+ }
334
+ if (allowedDir !== void 0 && allowedDir !== null && typeof allowedDir !== "string") {
335
+ throw new exports.QvdValidationError("allowedDir must be a string, null or undefined", {
336
+ provided: allowedDir,
337
+ type: typeof allowedDir
338
+ });
339
+ }
266
340
  if (filePath.includes("\0")) {
267
341
  throw new exports.QvdSecurityError("Path traversal detected: Null byte in path", {
268
342
  path: filePath,
269
343
  reason: "null_byte"
270
344
  });
271
345
  }
346
+ const resolvedPath = path__default.default.resolve(filePath);
272
347
  const baseDir = allowedDir || process.cwd();
273
- const resolvedBaseDir = path2__default.default.resolve(baseDir);
274
- const resolvedPath = path2__default.default.resolve(filePath);
275
- const isCaseInsensitiveFS = process.platform === "win32" || process.platform === "darwin";
276
- const baseForComparison = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
277
- const pathForComparison = isCaseInsensitiveFS ? resolvedPath.toLowerCase() : resolvedPath;
278
- if (!pathForComparison.startsWith(baseForComparison + path2__default.default.sep) && pathForComparison !== baseForComparison) {
348
+ const resolvedBaseDir = path__default.default.resolve(baseDir);
349
+ const onDisk = isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath);
350
+ const contained = onDisk === null ? isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) : onDisk;
351
+ if (!contained) {
279
352
  throw new exports.QvdSecurityError("Path traversal detected: Access denied", {
280
353
  path: filePath,
281
354
  resolvedPath,
282
355
  allowedDir: resolvedBaseDir,
283
- reason: "outside_allowed_directory"
356
+ reason: "outside_allowed_directory",
357
+ // Says which check refused, so a rejection of a path that looks contained is traceable to
358
+ // a symlink or a case difference rather than looking like a bug.
359
+ check: onDisk === null ? "lexical" : "filesystem"
284
360
  });
285
361
  }
286
362
  return resolvedPath;
@@ -309,7 +385,10 @@ var init_QvdFileWriter = __esm({
309
385
  * @param {QvdDataFrame} df The data frame to write to the QVD file.
310
386
  * @param {Object} [options={}] Options for the writer.
311
387
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
312
- * path must be within this directory. Defaults to current working directory.
388
+ * path must be within this directory, with symlinks resolved first, so a link inside it that
389
+ * points outside it is rejected. Defaults to the current working directory. To permit
390
+ * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
391
+ * empty value falls back to the working directory rather than removing the restriction.
313
392
  * @param {Function} [options.onProgress] Optional progress callback function.
314
393
  */
315
394
  constructor(filePath, df, options = {}) {
@@ -355,7 +434,7 @@ var init_QvdFileWriter = __esm({
355
434
  const headerBuffer = Buffer.concat([Buffer.from(this._header, "utf-8"), Buffer.from([0])]);
356
435
  let fd;
357
436
  try {
358
- fd = await fs2__default.default.promises.open(this._path, "w");
437
+ fd = await fs__default.default.promises.open(this._path, "w");
359
438
  await fd.write(headerBuffer, 0, headerBuffer.length, 0);
360
439
  await fd.write(this._symbolBuffer, 0, this._symbolBuffer.length, headerBuffer.length);
361
440
  await fd.write(this._indexBuffer, 0, this._indexBuffer.length, headerBuffer.length + this._symbolBuffer.length);
@@ -381,7 +460,7 @@ var init_QvdFileWriter = __esm({
381
460
  SourceFileUtcTime: existingMetadata.SourceFileUtcTime || "",
382
461
  SourceFileSize: existingMetadata.SourceFileSize || -1,
383
462
  StaleUtcTime: existingMetadata.StaleUtcTime || "",
384
- TableName: existingMetadata.TableName || path2__default.default.basename(this._path, path2__default.default.extname(this._path)),
463
+ TableName: existingMetadata.TableName || path__default.default.basename(this._path, path__default.default.extname(this._path)),
385
464
  Compression: existingMetadata.Compression || "",
386
465
  Comment: existingMetadata.Comment || "",
387
466
  EncryptionInfo: existingMetadata.EncryptionInfo || "",
@@ -401,7 +480,7 @@ var init_QvdFileWriter = __esm({
401
480
  SourceFileUtcTime: "",
402
481
  SourceFileSize: -1,
403
482
  StaleUtcTime: "",
404
- TableName: path2__default.default.basename(this._path, path2__default.default.extname(this._path)),
483
+ TableName: path__default.default.basename(this._path, path__default.default.extname(this._path)),
405
484
  Compression: "",
406
485
  Comment: "",
407
486
  EncryptionInfo: "",
@@ -571,21 +650,22 @@ var init_QvdFileWriter = __esm({
571
650
  this._emitProgress("index-table", processedRows, numRows);
572
651
  }
573
652
  });
574
- this._df.columns.forEach((column) => {
575
- const bitOffset = this._indexTableMetadata?.slice(0, this._df.columns.indexOf(column)).reduce((sum, metadata) => sum + metadata[1], 0);
653
+ this._df.columns.forEach((column, columnIndex) => {
654
+ const bitOffset = this._indexTableMetadata?.slice(0, columnIndex).reduce((sum, metadata) => sum + metadata[1], 0);
576
655
  assert__default.default(this._indexTable, "The QVD file header has not been parsed.");
577
- const bitWidth = this._indexTable.length > 0 ? Math.max(
578
- ...this._indexTable.map(
579
- (indices) => indices[this._df.columns.indexOf(column)].length
580
- )
581
- ) : 0;
582
- const fieldContainsNull = this._symbolTableMetadata?.[this._df.columns.indexOf(column)][2];
656
+ const fieldContainsNull = this._symbolTableMetadata?.[columnIndex][2];
583
657
  const bias = fieldContainsNull ? -2 : 0;
658
+ const symbolCount = this._symbolTable?.[columnIndex].length ?? 0;
659
+ const maxStoredIndex = symbolCount === 0 ? 0 : symbolCount - 1 + (fieldContainsNull ? 2 : 0);
660
+ const bitWidth = maxStoredIndex === 0 ? 0 : 32 - Math.clz32(maxStoredIndex);
584
661
  this._indexTableMetadata?.push([bitOffset, bitWidth, bias]);
585
662
  this._indexTable.forEach((indices) => {
586
- const bitString = indices[this._df.columns.indexOf(column)];
587
- const paddedBitString = bitString.padStart(bitWidth, "0");
588
- indices[this._df.columns.indexOf(column)] = paddedBitString;
663
+ const bitString = indices[columnIndex];
664
+ assert__default.default(
665
+ bitString === "0" || bitString.length <= bitWidth,
666
+ `Stored index for column '${column}' needs ${bitString.length} bits, but BitWidth was derived as ${bitWidth} from ${symbolCount} symbols. The symbol table and the index table are out of sync.`
667
+ );
668
+ indices[columnIndex] = bitWidth === 0 ? "" : bitString.padStart(bitWidth, "0");
589
669
  });
590
670
  });
591
671
  this._indexBuffer = Buffer.concat(
@@ -593,6 +673,9 @@ var init_QvdFileWriter = __esm({
593
673
  this._indexTable.map((indices) => {
594
674
  indices.reverse();
595
675
  const bits = indices.join("");
676
+ if (bits.length === 0) {
677
+ return Buffer.from(Uint8Array.from([0]));
678
+ }
596
679
  const paddingWidth = (8 - bits.length % 8) % 8;
597
680
  const paddedBits = bits.padStart(bits.length + paddingWidth, "0");
598
681
  const bytes = paddedBits.match(/.{1,8}/g)?.map((byte) => parseInt(byte, 2));
@@ -805,7 +888,7 @@ function validateFieldMetadata(field, symbolBufferLength, filePath) {
805
888
  });
806
889
  }
807
890
  }
808
- function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath) {
891
+ function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath, fileSize = null) {
809
892
  if (isNaN(recordSize) || !Number.isSafeInteger(recordSize) || recordSize < 0) {
810
893
  throw new exports.QvdCorruptedError("Invalid record byte size", {
811
894
  recordSize,
@@ -851,16 +934,40 @@ function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, ind
851
934
  stage: "parseIndexTable"
852
935
  });
853
936
  }
854
- const maxReasonableOverage = 100 * 1024 * 1024;
855
- if (indexTableLength > bufferLength + maxReasonableOverage) {
856
- throw new exports.QvdCorruptedError("Index table length unreasonably large", {
937
+ if (indexTableLength !== totalRows * recordSize) {
938
+ throw new exports.QvdCorruptedError("Index table length inconsistent with record count", {
857
939
  indexTableLength,
858
- bufferSize: bufferLength,
940
+ expectedLength: totalRows * recordSize,
941
+ totalRows,
942
+ recordSize,
859
943
  file: filePath,
860
944
  stage: "parseIndexTable"
861
945
  });
862
946
  }
947
+ if (fileSize !== null) {
948
+ if (indexTableOffset + indexTableLength > fileSize) {
949
+ throw new exports.QvdCorruptedError("Index table extends beyond the end of the file", {
950
+ indexTableOffset,
951
+ indexTableLength,
952
+ fileSize,
953
+ file: filePath,
954
+ stage: "parseIndexTable"
955
+ });
956
+ }
957
+ }
863
958
  const requiredIndexBytes = rowsToLoad * recordSize;
959
+ if (indexTableOffset + requiredIndexBytes > bufferLength) {
960
+ throw new exports.QvdCorruptedError("Index table truncated", {
961
+ indexTableOffset,
962
+ requiredBytes: requiredIndexBytes,
963
+ availableBytes: Math.max(0, bufferLength - indexTableOffset),
964
+ rowsToLoad,
965
+ recordSize,
966
+ bufferSize: bufferLength,
967
+ file: filePath,
968
+ stage: "parseIndexTable"
969
+ });
970
+ }
864
971
  if (indexTableLength < requiredIndexBytes) {
865
972
  throw new exports.QvdCorruptedError("Index table length smaller than required", {
866
973
  indexTableLength,
@@ -1171,7 +1278,7 @@ var QvdFileReader_exports = {};
1171
1278
  __export(QvdFileReader_exports, {
1172
1279
  QvdFileReader: () => exports.QvdFileReader
1173
1280
  });
1174
- exports.QvdFileReader = void 0;
1281
+ var MAX_HEADER_SIZE, READ_CHUNK_SIZE; exports.QvdFileReader = void 0;
1175
1282
  var init_QvdFileReader = __esm({
1176
1283
  "src/QvdFileReader.js"() {
1177
1284
  init_QvdDataFrame();
@@ -1181,6 +1288,8 @@ var init_QvdFileReader = __esm({
1181
1288
  init_memoryUtils();
1182
1289
  init_validationUtils();
1183
1290
  init_symbolParser();
1291
+ MAX_HEADER_SIZE = 16 * 1024 * 1024;
1292
+ READ_CHUNK_SIZE = 512 * 1024 * 1024;
1184
1293
  exports.QvdFileReader = class {
1185
1294
  /**
1186
1295
  * Constructs a new QVD file parser.
@@ -1188,7 +1297,10 @@ var init_QvdFileReader = __esm({
1188
1297
  * @param {string} filePath The path to the QVD file to load.
1189
1298
  * @param {Object} [options={}] Options for the reader.
1190
1299
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
1191
- * path must be within this directory. Defaults to current working directory.
1300
+ * path must be within this directory, with symlinks resolved first, so a link inside it that
1301
+ * points outside it is rejected. Defaults to the current working directory. To permit
1302
+ * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
1303
+ * empty value falls back to the working directory rather than removing the restriction.
1192
1304
  * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines
1193
1305
  * what percentage of available memory (or V8 heap limit, whichever is smaller) can be used
1194
1306
  * for loading QVD files. Default is 0.3 (30%). Increase for larger heap configurations.
@@ -1204,6 +1316,7 @@ var init_QvdFileReader = __esm({
1204
1316
  this._header = null;
1205
1317
  this._symbolTable = null;
1206
1318
  this._indexTable = null;
1319
+ this._fileSize = null;
1207
1320
  }
1208
1321
  /**
1209
1322
  * Reads the binary data of the QVD file.
@@ -1234,27 +1347,50 @@ var init_QvdFileReader = __esm({
1234
1347
  */
1235
1348
  async _readData(maxRows = null) {
1236
1349
  if (maxRows === null) {
1237
- this._buffer = await fs2__default.default.promises.readFile(this._path);
1350
+ this._buffer = await fs__default.default.promises.readFile(this._path);
1351
+ this._fileSize = this._buffer.length;
1238
1352
  return;
1239
1353
  }
1240
1354
  const HEADER_DELIMITER = "\r\n\0";
1241
1355
  const CHUNK_SIZE = 64 * 1024;
1242
- const stream = fs2__default.default.createReadStream(this._path, {
1356
+ const stream = fs__default.default.createReadStream(this._path, {
1243
1357
  highWaterMark: CHUNK_SIZE
1244
1358
  });
1359
+ const headerChunks = [];
1360
+ let headerBytes = 0;
1361
+ let tail = Buffer.alloc(0);
1245
1362
  let headerBuffer = Buffer.alloc(0);
1246
1363
  let headerDelimiterIndex = -1;
1247
1364
  try {
1248
1365
  for await (const chunk of stream) {
1249
- headerBuffer = Buffer.concat([headerBuffer, chunk]);
1250
- headerDelimiterIndex = headerBuffer.indexOf(HEADER_DELIMITER);
1251
- if (headerDelimiterIndex !== -1) {
1366
+ const chunkStart = headerBytes;
1367
+ const searchBuffer = tail.length > 0 ? Buffer.concat([tail, chunk]) : chunk;
1368
+ const foundInSearch = searchBuffer.indexOf(HEADER_DELIMITER);
1369
+ headerChunks.push(chunk);
1370
+ headerBytes += chunk.length;
1371
+ if (foundInSearch !== -1) {
1372
+ headerDelimiterIndex = chunkStart - tail.length + foundInSearch;
1252
1373
  stream.destroy();
1253
1374
  break;
1254
1375
  }
1376
+ tail = Buffer.from(searchBuffer.subarray(-(HEADER_DELIMITER.length - 1)));
1377
+ if (headerBytes > MAX_HEADER_SIZE) {
1378
+ stream.destroy();
1379
+ throw new exports.QvdCorruptedError(
1380
+ `The XML header delimiter was not found within the first ${MAX_HEADER_SIZE / (1024 * 1024)}MB of the file.`,
1381
+ {
1382
+ file: this._path,
1383
+ bytesSearched: headerBytes,
1384
+ maxHeaderSize: MAX_HEADER_SIZE,
1385
+ stage: "readData"
1386
+ }
1387
+ );
1388
+ }
1255
1389
  }
1256
1390
  } catch (error) {
1257
- if (error && typeof error === "object" && "code" in error && error.code !== "ERR_STREAM_PREMATURE_CLOSE") {
1391
+ const isExpectedEarlyClose = headerDelimiterIndex !== -1 && error !== null && typeof error === "object" && /** @type {{code?: unknown}} */
1392
+ error.code === "ERR_STREAM_PREMATURE_CLOSE";
1393
+ if (!isExpectedEarlyClose) {
1258
1394
  throw error;
1259
1395
  }
1260
1396
  }
@@ -1267,6 +1403,7 @@ var init_QvdFileReader = __esm({
1267
1403
  }
1268
1404
  );
1269
1405
  }
1406
+ headerBuffer = Buffer.concat(headerChunks);
1270
1407
  const headerEndIndex = headerDelimiterIndex + HEADER_DELIMITER.length;
1271
1408
  const headerXml = headerBuffer.subarray(0, headerEndIndex).toString();
1272
1409
  const headerObj = await xml2__default.default.parseStringPromise(headerXml, { explicitArray: false });
@@ -1283,12 +1420,50 @@ var init_QvdFileReader = __esm({
1283
1420
  const totalRows = parseInt(headerObj["QvdTableHeader"]["NoOfRecords"], 10);
1284
1421
  const rowsToLoad = Math.min(maxRows, totalRows);
1285
1422
  validateSymbolTableSizeEarly(symbolTableLength, this._path);
1423
+ for (const [name, value] of [
1424
+ ["Offset", symbolTableLength],
1425
+ ["RecordByteSize", recordSize],
1426
+ ["NoOfRecords", totalRows]
1427
+ ]) {
1428
+ if (!Number.isSafeInteger(value) || Number(value) < 0) {
1429
+ throw new exports.QvdCorruptedError(`Invalid header value: ${name}`, {
1430
+ name,
1431
+ value,
1432
+ file: this._path,
1433
+ stage: "readData"
1434
+ });
1435
+ }
1436
+ }
1286
1437
  const indexTableBytesToRead = rowsToLoad * recordSize;
1287
1438
  const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
1288
- const fd = await fs2__default.default.promises.open(this._path, "r");
1439
+ const fd = await fs__default.default.promises.open(this._path, "r");
1289
1440
  try {
1290
- this._buffer = Buffer.allocUnsafe(totalBytesToRead);
1291
- await fd.read(this._buffer, 0, totalBytesToRead, 0);
1441
+ const { size: fileSize } = await fd.stat();
1442
+ this._fileSize = fileSize;
1443
+ if (totalBytesToRead > fileSize) {
1444
+ throw new exports.QvdCorruptedError("The file is shorter than its header claims.", {
1445
+ file: this._path,
1446
+ fileSize,
1447
+ requiredBytes: totalBytesToRead,
1448
+ stage: "readData"
1449
+ });
1450
+ }
1451
+ this._buffer = Buffer.alloc(totalBytesToRead);
1452
+ let position = 0;
1453
+ while (position < totalBytesToRead) {
1454
+ const length = Math.min(READ_CHUNK_SIZE, totalBytesToRead - position);
1455
+ const { bytesRead } = await fd.read(this._buffer, position, length, position);
1456
+ if (bytesRead === 0) {
1457
+ throw new exports.QvdCorruptedError("Unexpected end of file while reading QVD data.", {
1458
+ file: this._path,
1459
+ fileSize,
1460
+ bytesRead: position,
1461
+ requiredBytes: totalBytesToRead,
1462
+ stage: "readData"
1463
+ });
1464
+ }
1465
+ position += bytesRead;
1466
+ }
1292
1467
  } finally {
1293
1468
  await fd.close();
1294
1469
  }
@@ -1390,7 +1565,10 @@ var init_QvdFileReader = __esm({
1390
1565
  } else {
1391
1566
  symbolIndex = this._convertBitsToInt32(mask.slice(bitOffset, bitOffset + bitWidth));
1392
1567
  }
1393
- symbolIndex -= bias;
1568
+ symbolIndex += bias;
1569
+ if (symbolIndex < 0) {
1570
+ return;
1571
+ }
1394
1572
  const fieldName = field["FieldName"];
1395
1573
  symbolUsage.get(fieldName)?.add(symbolIndex);
1396
1574
  });
@@ -1495,7 +1673,8 @@ var init_QvdFileReader = __esm({
1495
1673
  this._indexTableOffset,
1496
1674
  this._buffer.length,
1497
1675
  rowsToLoad,
1498
- this._path
1676
+ this._path,
1677
+ this._fileSize
1499
1678
  );
1500
1679
  const indexBuffer = this._buffer.subarray(this._indexTableOffset, this._indexTableOffset + indexTableLength + 1);
1501
1680
  for (const field of fields) {
@@ -1531,14 +1710,33 @@ var init_QvdFileReader = __esm({
1531
1710
  });
1532
1711
  this._indexTable.push(symbolIndices);
1533
1712
  }
1713
+ if (this._indexTable.length !== rowsToLoad) {
1714
+ throw new exports.QvdCorruptedError("Index table contains fewer records than expected", {
1715
+ expectedRows: rowsToLoad,
1716
+ actualRows: this._indexTable.length,
1717
+ totalRows,
1718
+ recordSize,
1719
+ file: this._path,
1720
+ stage: "parseIndexTable"
1721
+ });
1722
+ }
1534
1723
  }
1535
1724
  /**
1536
1725
  * Loads the QVD file into memory and parses it.
1537
1726
  *
1538
1727
  * @param {number|null} maxRows The maximum number of rows to load. If null, all rows are loaded.
1728
+ * Must be a non-negative integer when given.
1729
+ * @throws {QvdValidationError} If maxRows is neither null nor a non-negative integer.
1539
1730
  * @return {Promise<QvdDataFrame>} The loaded QVD file.
1540
1731
  */
1541
1732
  async load(maxRows = null) {
1733
+ if (maxRows !== null && (typeof maxRows !== "number" || !Number.isInteger(maxRows) || maxRows < 0)) {
1734
+ throw new exports.QvdValidationError("maxRows must be a non-negative integer, or null to load all rows", {
1735
+ provided: maxRows,
1736
+ type: typeof maxRows,
1737
+ file: this._path
1738
+ });
1739
+ }
1542
1740
  await this._readData(maxRows);
1543
1741
  await this._parseHeader();
1544
1742
  let symbolsToKeep = null;
@@ -1573,7 +1771,7 @@ var init_QvdFileReader = __esm({
1573
1771
  const symbol = this._symbolTable?.[fieldIndex]?.[symbolIndex];
1574
1772
  const value = symbol?.toPrimaryValue();
1575
1773
  if (typeof value === "string") {
1576
- if (!isNaN(Number(value))) {
1774
+ if (value.trim() !== "" && !isNaN(Number(value))) {
1577
1775
  return Number(value);
1578
1776
  }
1579
1777
  }
@@ -1983,7 +2181,11 @@ var init_QvdDataFrame = __esm({
1983
2181
  *
1984
2182
  * @param {string} path The path to the QVD file.
1985
2183
  * @param {Object} [options] Optional writing options.
1986
- * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path must be within this directory.
2184
+ * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
2185
+ * must be within this directory, with symlinks resolved first, so a link inside it that points
2186
+ * outside it is rejected. Defaults to the current working directory. To permit an entire
2187
+ * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2188
+ * back to the working directory rather than removing the restriction.
1987
2189
  * @param {Function} [options.onProgress] Optional progress callback function that receives progress updates during write operations.
1988
2190
  */
1989
2191
  async toQvd(path3, options = {}) {
@@ -1999,11 +2201,17 @@ var init_QvdDataFrame = __esm({
1999
2201
  *
2000
2202
  * @param {string} path The path to the QVD file.
2001
2203
  * @param {Object} [options] Optional loading options.
2002
- * @param {number|null} [options.maxRows] The maximum number of rows to load. If not specified, all rows are loaded.
2003
- * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path must be within this directory.
2204
+ * @param {number|null} [options.maxRows] The maximum number of rows to load. Must be a non-negative
2205
+ * integer; if not specified or null, all rows are loaded. Anything else throws a QvdValidationError.
2206
+ * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
2207
+ * must be within this directory, with symlinks resolved first, so a link inside it that points
2208
+ * outside it is rejected. Defaults to the current working directory. To permit an entire
2209
+ * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2210
+ * back to the working directory rather than removing the restriction.
2004
2211
  * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines what percentage
2005
2212
  * of available memory (or V8 heap limit, whichever is smaller) can be used. Default is 0.3 (30%).
2006
2213
  * Increase this (e.g., to 0.5 or 0.7) when running with larger heap sizes via --max-old-space-size.
2214
+ * @throws {QvdValidationError} If options.maxRows is neither null/undefined nor a non-negative integer.
2007
2215
  * @return {Promise<QvdDataFrame>} The data frame of the QVD file.
2008
2216
  */
2009
2217
  static async fromQvd(path3, options = {}) {