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.js CHANGED
@@ -1,5 +1,5 @@
1
- import path2 from 'path';
2
- import fs2 from 'fs';
1
+ import fs from 'fs';
2
+ import path from 'path';
3
3
  import crypto from 'crypto';
4
4
  import xml2 from 'xml2js';
5
5
  import assert from 'assert';
@@ -250,25 +250,101 @@ var init_QvdSymbol = __esm({
250
250
  };
251
251
  }
252
252
  });
253
+ function isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) {
254
+ const isCaseInsensitiveFS = process.platform === "win32";
255
+ const base = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
256
+ const target = isCaseInsensitiveFS ? resolvedPath.toLowerCase() : resolvedPath;
257
+ const relative = path.relative(base, target);
258
+ if (relative === "") {
259
+ return true;
260
+ }
261
+ if (path.isAbsolute(relative)) {
262
+ return false;
263
+ }
264
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`);
265
+ }
266
+ function resolveDeepestExisting(target) {
267
+ let current = target;
268
+ for (; ; ) {
269
+ try {
270
+ return fs.realpathSync(current);
271
+ } catch (error) {
272
+ const code = (
273
+ /** @type {{code?: string}} */
274
+ error?.code
275
+ );
276
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
277
+ return null;
278
+ }
279
+ const parent = path.dirname(current);
280
+ if (parent === current) {
281
+ return null;
282
+ }
283
+ current = parent;
284
+ }
285
+ }
286
+ }
287
+ function isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath) {
288
+ let baseStat;
289
+ try {
290
+ baseStat = fs.statSync(fs.realpathSync(resolvedBaseDir));
291
+ } catch {
292
+ return null;
293
+ }
294
+ let current = resolveDeepestExisting(resolvedPath);
295
+ if (current === null) {
296
+ return null;
297
+ }
298
+ for (; ; ) {
299
+ let stat;
300
+ try {
301
+ stat = fs.statSync(current);
302
+ } catch {
303
+ return null;
304
+ }
305
+ if (stat.dev === baseStat.dev && stat.ino === baseStat.ino) {
306
+ return true;
307
+ }
308
+ const parent = path.dirname(current);
309
+ if (parent === current) {
310
+ return false;
311
+ }
312
+ current = parent;
313
+ }
314
+ }
253
315
  function validatePath(filePath, allowedDir) {
316
+ if (typeof filePath !== "string" || filePath.length === 0) {
317
+ throw new QvdValidationError("filePath must be a non-empty string", {
318
+ provided: filePath,
319
+ type: typeof filePath
320
+ });
321
+ }
322
+ if (allowedDir !== void 0 && allowedDir !== null && typeof allowedDir !== "string") {
323
+ throw new QvdValidationError("allowedDir must be a string, null or undefined", {
324
+ provided: allowedDir,
325
+ type: typeof allowedDir
326
+ });
327
+ }
254
328
  if (filePath.includes("\0")) {
255
329
  throw new QvdSecurityError("Path traversal detected: Null byte in path", {
256
330
  path: filePath,
257
331
  reason: "null_byte"
258
332
  });
259
333
  }
334
+ const resolvedPath = path.resolve(filePath);
260
335
  const baseDir = allowedDir || process.cwd();
261
- const resolvedBaseDir = path2.resolve(baseDir);
262
- const resolvedPath = path2.resolve(filePath);
263
- const isCaseInsensitiveFS = process.platform === "win32" || process.platform === "darwin";
264
- const baseForComparison = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
265
- const pathForComparison = isCaseInsensitiveFS ? resolvedPath.toLowerCase() : resolvedPath;
266
- if (!pathForComparison.startsWith(baseForComparison + path2.sep) && pathForComparison !== baseForComparison) {
336
+ const resolvedBaseDir = path.resolve(baseDir);
337
+ const onDisk = isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath);
338
+ const contained = onDisk === null ? isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) : onDisk;
339
+ if (!contained) {
267
340
  throw new QvdSecurityError("Path traversal detected: Access denied", {
268
341
  path: filePath,
269
342
  resolvedPath,
270
343
  allowedDir: resolvedBaseDir,
271
- reason: "outside_allowed_directory"
344
+ reason: "outside_allowed_directory",
345
+ // Says which check refused, so a rejection of a path that looks contained is traceable to
346
+ // a symlink or a case difference rather than looking like a bug.
347
+ check: onDisk === null ? "lexical" : "filesystem"
272
348
  });
273
349
  }
274
350
  return resolvedPath;
@@ -297,7 +373,10 @@ var init_QvdFileWriter = __esm({
297
373
  * @param {QvdDataFrame} df The data frame to write to the QVD file.
298
374
  * @param {Object} [options={}] Options for the writer.
299
375
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
300
- * path must be within this directory. Defaults to current working directory.
376
+ * path must be within this directory, with symlinks resolved first, so a link inside it that
377
+ * points outside it is rejected. Defaults to the current working directory. To permit
378
+ * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
379
+ * empty value falls back to the working directory rather than removing the restriction.
301
380
  * @param {Function} [options.onProgress] Optional progress callback function.
302
381
  */
303
382
  constructor(filePath, df, options = {}) {
@@ -343,7 +422,7 @@ var init_QvdFileWriter = __esm({
343
422
  const headerBuffer = Buffer.concat([Buffer.from(this._header, "utf-8"), Buffer.from([0])]);
344
423
  let fd;
345
424
  try {
346
- fd = await fs2.promises.open(this._path, "w");
425
+ fd = await fs.promises.open(this._path, "w");
347
426
  await fd.write(headerBuffer, 0, headerBuffer.length, 0);
348
427
  await fd.write(this._symbolBuffer, 0, this._symbolBuffer.length, headerBuffer.length);
349
428
  await fd.write(this._indexBuffer, 0, this._indexBuffer.length, headerBuffer.length + this._symbolBuffer.length);
@@ -369,7 +448,7 @@ var init_QvdFileWriter = __esm({
369
448
  SourceFileUtcTime: existingMetadata.SourceFileUtcTime || "",
370
449
  SourceFileSize: existingMetadata.SourceFileSize || -1,
371
450
  StaleUtcTime: existingMetadata.StaleUtcTime || "",
372
- TableName: existingMetadata.TableName || path2.basename(this._path, path2.extname(this._path)),
451
+ TableName: existingMetadata.TableName || path.basename(this._path, path.extname(this._path)),
373
452
  Compression: existingMetadata.Compression || "",
374
453
  Comment: existingMetadata.Comment || "",
375
454
  EncryptionInfo: existingMetadata.EncryptionInfo || "",
@@ -389,7 +468,7 @@ var init_QvdFileWriter = __esm({
389
468
  SourceFileUtcTime: "",
390
469
  SourceFileSize: -1,
391
470
  StaleUtcTime: "",
392
- TableName: path2.basename(this._path, path2.extname(this._path)),
471
+ TableName: path.basename(this._path, path.extname(this._path)),
393
472
  Compression: "",
394
473
  Comment: "",
395
474
  EncryptionInfo: "",
@@ -559,21 +638,22 @@ var init_QvdFileWriter = __esm({
559
638
  this._emitProgress("index-table", processedRows, numRows);
560
639
  }
561
640
  });
562
- this._df.columns.forEach((column) => {
563
- const bitOffset = this._indexTableMetadata?.slice(0, this._df.columns.indexOf(column)).reduce((sum, metadata) => sum + metadata[1], 0);
641
+ this._df.columns.forEach((column, columnIndex) => {
642
+ const bitOffset = this._indexTableMetadata?.slice(0, columnIndex).reduce((sum, metadata) => sum + metadata[1], 0);
564
643
  assert(this._indexTable, "The QVD file header has not been parsed.");
565
- const bitWidth = this._indexTable.length > 0 ? Math.max(
566
- ...this._indexTable.map(
567
- (indices) => indices[this._df.columns.indexOf(column)].length
568
- )
569
- ) : 0;
570
- const fieldContainsNull = this._symbolTableMetadata?.[this._df.columns.indexOf(column)][2];
644
+ const fieldContainsNull = this._symbolTableMetadata?.[columnIndex][2];
571
645
  const bias = fieldContainsNull ? -2 : 0;
646
+ const symbolCount = this._symbolTable?.[columnIndex].length ?? 0;
647
+ const maxStoredIndex = symbolCount === 0 ? 0 : symbolCount - 1 + (fieldContainsNull ? 2 : 0);
648
+ const bitWidth = maxStoredIndex === 0 ? 0 : 32 - Math.clz32(maxStoredIndex);
572
649
  this._indexTableMetadata?.push([bitOffset, bitWidth, bias]);
573
650
  this._indexTable.forEach((indices) => {
574
- const bitString = indices[this._df.columns.indexOf(column)];
575
- const paddedBitString = bitString.padStart(bitWidth, "0");
576
- indices[this._df.columns.indexOf(column)] = paddedBitString;
651
+ const bitString = indices[columnIndex];
652
+ assert(
653
+ bitString === "0" || bitString.length <= bitWidth,
654
+ `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.`
655
+ );
656
+ indices[columnIndex] = bitWidth === 0 ? "" : bitString.padStart(bitWidth, "0");
577
657
  });
578
658
  });
579
659
  this._indexBuffer = Buffer.concat(
@@ -581,6 +661,9 @@ var init_QvdFileWriter = __esm({
581
661
  this._indexTable.map((indices) => {
582
662
  indices.reverse();
583
663
  const bits = indices.join("");
664
+ if (bits.length === 0) {
665
+ return Buffer.from(Uint8Array.from([0]));
666
+ }
584
667
  const paddingWidth = (8 - bits.length % 8) % 8;
585
668
  const paddedBits = bits.padStart(bits.length + paddingWidth, "0");
586
669
  const bytes = paddedBits.match(/.{1,8}/g)?.map((byte) => parseInt(byte, 2));
@@ -793,7 +876,7 @@ function validateFieldMetadata(field, symbolBufferLength, filePath) {
793
876
  });
794
877
  }
795
878
  }
796
- function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath) {
879
+ function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath, fileSize = null) {
797
880
  if (isNaN(recordSize) || !Number.isSafeInteger(recordSize) || recordSize < 0) {
798
881
  throw new QvdCorruptedError("Invalid record byte size", {
799
882
  recordSize,
@@ -839,16 +922,40 @@ function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, ind
839
922
  stage: "parseIndexTable"
840
923
  });
841
924
  }
842
- const maxReasonableOverage = 100 * 1024 * 1024;
843
- if (indexTableLength > bufferLength + maxReasonableOverage) {
844
- throw new QvdCorruptedError("Index table length unreasonably large", {
925
+ if (indexTableLength !== totalRows * recordSize) {
926
+ throw new QvdCorruptedError("Index table length inconsistent with record count", {
845
927
  indexTableLength,
846
- bufferSize: bufferLength,
928
+ expectedLength: totalRows * recordSize,
929
+ totalRows,
930
+ recordSize,
847
931
  file: filePath,
848
932
  stage: "parseIndexTable"
849
933
  });
850
934
  }
935
+ if (fileSize !== null) {
936
+ if (indexTableOffset + indexTableLength > fileSize) {
937
+ throw new QvdCorruptedError("Index table extends beyond the end of the file", {
938
+ indexTableOffset,
939
+ indexTableLength,
940
+ fileSize,
941
+ file: filePath,
942
+ stage: "parseIndexTable"
943
+ });
944
+ }
945
+ }
851
946
  const requiredIndexBytes = rowsToLoad * recordSize;
947
+ if (indexTableOffset + requiredIndexBytes > bufferLength) {
948
+ throw new QvdCorruptedError("Index table truncated", {
949
+ indexTableOffset,
950
+ requiredBytes: requiredIndexBytes,
951
+ availableBytes: Math.max(0, bufferLength - indexTableOffset),
952
+ rowsToLoad,
953
+ recordSize,
954
+ bufferSize: bufferLength,
955
+ file: filePath,
956
+ stage: "parseIndexTable"
957
+ });
958
+ }
852
959
  if (indexTableLength < requiredIndexBytes) {
853
960
  throw new QvdCorruptedError("Index table length smaller than required", {
854
961
  indexTableLength,
@@ -1159,7 +1266,7 @@ var QvdFileReader_exports = {};
1159
1266
  __export(QvdFileReader_exports, {
1160
1267
  QvdFileReader: () => QvdFileReader
1161
1268
  });
1162
- var QvdFileReader;
1269
+ var MAX_HEADER_SIZE, READ_CHUNK_SIZE, QvdFileReader;
1163
1270
  var init_QvdFileReader = __esm({
1164
1271
  "src/QvdFileReader.js"() {
1165
1272
  init_QvdDataFrame();
@@ -1169,6 +1276,8 @@ var init_QvdFileReader = __esm({
1169
1276
  init_memoryUtils();
1170
1277
  init_validationUtils();
1171
1278
  init_symbolParser();
1279
+ MAX_HEADER_SIZE = 16 * 1024 * 1024;
1280
+ READ_CHUNK_SIZE = 512 * 1024 * 1024;
1172
1281
  QvdFileReader = class {
1173
1282
  /**
1174
1283
  * Constructs a new QVD file parser.
@@ -1176,7 +1285,10 @@ var init_QvdFileReader = __esm({
1176
1285
  * @param {string} filePath The path to the QVD file to load.
1177
1286
  * @param {Object} [options={}] Options for the reader.
1178
1287
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
1179
- * path must be within this directory. Defaults to current working directory.
1288
+ * path must be within this directory, with symlinks resolved first, so a link inside it that
1289
+ * points outside it is rejected. Defaults to the current working directory. To permit
1290
+ * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
1291
+ * empty value falls back to the working directory rather than removing the restriction.
1180
1292
  * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines
1181
1293
  * what percentage of available memory (or V8 heap limit, whichever is smaller) can be used
1182
1294
  * for loading QVD files. Default is 0.3 (30%). Increase for larger heap configurations.
@@ -1192,6 +1304,7 @@ var init_QvdFileReader = __esm({
1192
1304
  this._header = null;
1193
1305
  this._symbolTable = null;
1194
1306
  this._indexTable = null;
1307
+ this._fileSize = null;
1195
1308
  }
1196
1309
  /**
1197
1310
  * Reads the binary data of the QVD file.
@@ -1222,27 +1335,50 @@ var init_QvdFileReader = __esm({
1222
1335
  */
1223
1336
  async _readData(maxRows = null) {
1224
1337
  if (maxRows === null) {
1225
- this._buffer = await fs2.promises.readFile(this._path);
1338
+ this._buffer = await fs.promises.readFile(this._path);
1339
+ this._fileSize = this._buffer.length;
1226
1340
  return;
1227
1341
  }
1228
1342
  const HEADER_DELIMITER = "\r\n\0";
1229
1343
  const CHUNK_SIZE = 64 * 1024;
1230
- const stream = fs2.createReadStream(this._path, {
1344
+ const stream = fs.createReadStream(this._path, {
1231
1345
  highWaterMark: CHUNK_SIZE
1232
1346
  });
1347
+ const headerChunks = [];
1348
+ let headerBytes = 0;
1349
+ let tail = Buffer.alloc(0);
1233
1350
  let headerBuffer = Buffer.alloc(0);
1234
1351
  let headerDelimiterIndex = -1;
1235
1352
  try {
1236
1353
  for await (const chunk of stream) {
1237
- headerBuffer = Buffer.concat([headerBuffer, chunk]);
1238
- headerDelimiterIndex = headerBuffer.indexOf(HEADER_DELIMITER);
1239
- if (headerDelimiterIndex !== -1) {
1354
+ const chunkStart = headerBytes;
1355
+ const searchBuffer = tail.length > 0 ? Buffer.concat([tail, chunk]) : chunk;
1356
+ const foundInSearch = searchBuffer.indexOf(HEADER_DELIMITER);
1357
+ headerChunks.push(chunk);
1358
+ headerBytes += chunk.length;
1359
+ if (foundInSearch !== -1) {
1360
+ headerDelimiterIndex = chunkStart - tail.length + foundInSearch;
1240
1361
  stream.destroy();
1241
1362
  break;
1242
1363
  }
1364
+ tail = Buffer.from(searchBuffer.subarray(-(HEADER_DELIMITER.length - 1)));
1365
+ if (headerBytes > MAX_HEADER_SIZE) {
1366
+ stream.destroy();
1367
+ throw new QvdCorruptedError(
1368
+ `The XML header delimiter was not found within the first ${MAX_HEADER_SIZE / (1024 * 1024)}MB of the file.`,
1369
+ {
1370
+ file: this._path,
1371
+ bytesSearched: headerBytes,
1372
+ maxHeaderSize: MAX_HEADER_SIZE,
1373
+ stage: "readData"
1374
+ }
1375
+ );
1376
+ }
1243
1377
  }
1244
1378
  } catch (error) {
1245
- if (error && typeof error === "object" && "code" in error && error.code !== "ERR_STREAM_PREMATURE_CLOSE") {
1379
+ const isExpectedEarlyClose = headerDelimiterIndex !== -1 && error !== null && typeof error === "object" && /** @type {{code?: unknown}} */
1380
+ error.code === "ERR_STREAM_PREMATURE_CLOSE";
1381
+ if (!isExpectedEarlyClose) {
1246
1382
  throw error;
1247
1383
  }
1248
1384
  }
@@ -1255,6 +1391,7 @@ var init_QvdFileReader = __esm({
1255
1391
  }
1256
1392
  );
1257
1393
  }
1394
+ headerBuffer = Buffer.concat(headerChunks);
1258
1395
  const headerEndIndex = headerDelimiterIndex + HEADER_DELIMITER.length;
1259
1396
  const headerXml = headerBuffer.subarray(0, headerEndIndex).toString();
1260
1397
  const headerObj = await xml2.parseStringPromise(headerXml, { explicitArray: false });
@@ -1271,12 +1408,50 @@ var init_QvdFileReader = __esm({
1271
1408
  const totalRows = parseInt(headerObj["QvdTableHeader"]["NoOfRecords"], 10);
1272
1409
  const rowsToLoad = Math.min(maxRows, totalRows);
1273
1410
  validateSymbolTableSizeEarly(symbolTableLength, this._path);
1411
+ for (const [name, value] of [
1412
+ ["Offset", symbolTableLength],
1413
+ ["RecordByteSize", recordSize],
1414
+ ["NoOfRecords", totalRows]
1415
+ ]) {
1416
+ if (!Number.isSafeInteger(value) || Number(value) < 0) {
1417
+ throw new QvdCorruptedError(`Invalid header value: ${name}`, {
1418
+ name,
1419
+ value,
1420
+ file: this._path,
1421
+ stage: "readData"
1422
+ });
1423
+ }
1424
+ }
1274
1425
  const indexTableBytesToRead = rowsToLoad * recordSize;
1275
1426
  const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
1276
- const fd = await fs2.promises.open(this._path, "r");
1427
+ const fd = await fs.promises.open(this._path, "r");
1277
1428
  try {
1278
- this._buffer = Buffer.allocUnsafe(totalBytesToRead);
1279
- await fd.read(this._buffer, 0, totalBytesToRead, 0);
1429
+ const { size: fileSize } = await fd.stat();
1430
+ this._fileSize = fileSize;
1431
+ if (totalBytesToRead > fileSize) {
1432
+ throw new QvdCorruptedError("The file is shorter than its header claims.", {
1433
+ file: this._path,
1434
+ fileSize,
1435
+ requiredBytes: totalBytesToRead,
1436
+ stage: "readData"
1437
+ });
1438
+ }
1439
+ this._buffer = Buffer.alloc(totalBytesToRead);
1440
+ let position = 0;
1441
+ while (position < totalBytesToRead) {
1442
+ const length = Math.min(READ_CHUNK_SIZE, totalBytesToRead - position);
1443
+ const { bytesRead } = await fd.read(this._buffer, position, length, position);
1444
+ if (bytesRead === 0) {
1445
+ throw new QvdCorruptedError("Unexpected end of file while reading QVD data.", {
1446
+ file: this._path,
1447
+ fileSize,
1448
+ bytesRead: position,
1449
+ requiredBytes: totalBytesToRead,
1450
+ stage: "readData"
1451
+ });
1452
+ }
1453
+ position += bytesRead;
1454
+ }
1280
1455
  } finally {
1281
1456
  await fd.close();
1282
1457
  }
@@ -1378,7 +1553,10 @@ var init_QvdFileReader = __esm({
1378
1553
  } else {
1379
1554
  symbolIndex = this._convertBitsToInt32(mask.slice(bitOffset, bitOffset + bitWidth));
1380
1555
  }
1381
- symbolIndex -= bias;
1556
+ symbolIndex += bias;
1557
+ if (symbolIndex < 0) {
1558
+ return;
1559
+ }
1382
1560
  const fieldName = field["FieldName"];
1383
1561
  symbolUsage.get(fieldName)?.add(symbolIndex);
1384
1562
  });
@@ -1483,7 +1661,8 @@ var init_QvdFileReader = __esm({
1483
1661
  this._indexTableOffset,
1484
1662
  this._buffer.length,
1485
1663
  rowsToLoad,
1486
- this._path
1664
+ this._path,
1665
+ this._fileSize
1487
1666
  );
1488
1667
  const indexBuffer = this._buffer.subarray(this._indexTableOffset, this._indexTableOffset + indexTableLength + 1);
1489
1668
  for (const field of fields) {
@@ -1519,14 +1698,33 @@ var init_QvdFileReader = __esm({
1519
1698
  });
1520
1699
  this._indexTable.push(symbolIndices);
1521
1700
  }
1701
+ if (this._indexTable.length !== rowsToLoad) {
1702
+ throw new QvdCorruptedError("Index table contains fewer records than expected", {
1703
+ expectedRows: rowsToLoad,
1704
+ actualRows: this._indexTable.length,
1705
+ totalRows,
1706
+ recordSize,
1707
+ file: this._path,
1708
+ stage: "parseIndexTable"
1709
+ });
1710
+ }
1522
1711
  }
1523
1712
  /**
1524
1713
  * Loads the QVD file into memory and parses it.
1525
1714
  *
1526
1715
  * @param {number|null} maxRows The maximum number of rows to load. If null, all rows are loaded.
1716
+ * Must be a non-negative integer when given.
1717
+ * @throws {QvdValidationError} If maxRows is neither null nor a non-negative integer.
1527
1718
  * @return {Promise<QvdDataFrame>} The loaded QVD file.
1528
1719
  */
1529
1720
  async load(maxRows = null) {
1721
+ if (maxRows !== null && (typeof maxRows !== "number" || !Number.isInteger(maxRows) || maxRows < 0)) {
1722
+ throw new QvdValidationError("maxRows must be a non-negative integer, or null to load all rows", {
1723
+ provided: maxRows,
1724
+ type: typeof maxRows,
1725
+ file: this._path
1726
+ });
1727
+ }
1530
1728
  await this._readData(maxRows);
1531
1729
  await this._parseHeader();
1532
1730
  let symbolsToKeep = null;
@@ -1561,7 +1759,7 @@ var init_QvdFileReader = __esm({
1561
1759
  const symbol = this._symbolTable?.[fieldIndex]?.[symbolIndex];
1562
1760
  const value = symbol?.toPrimaryValue();
1563
1761
  if (typeof value === "string") {
1564
- if (!isNaN(Number(value))) {
1762
+ if (value.trim() !== "" && !isNaN(Number(value))) {
1565
1763
  return Number(value);
1566
1764
  }
1567
1765
  }
@@ -1971,7 +2169,11 @@ var init_QvdDataFrame = __esm({
1971
2169
  *
1972
2170
  * @param {string} path The path to the QVD file.
1973
2171
  * @param {Object} [options] Optional writing options.
1974
- * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path must be within this directory.
2172
+ * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
2173
+ * must be within this directory, with symlinks resolved first, so a link inside it that points
2174
+ * outside it is rejected. Defaults to the current working directory. To permit an entire
2175
+ * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2176
+ * back to the working directory rather than removing the restriction.
1975
2177
  * @param {Function} [options.onProgress] Optional progress callback function that receives progress updates during write operations.
1976
2178
  */
1977
2179
  async toQvd(path3, options = {}) {
@@ -1987,11 +2189,17 @@ var init_QvdDataFrame = __esm({
1987
2189
  *
1988
2190
  * @param {string} path The path to the QVD file.
1989
2191
  * @param {Object} [options] Optional loading options.
1990
- * @param {number|null} [options.maxRows] The maximum number of rows to load. If not specified, all rows are loaded.
1991
- * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path must be within this directory.
2192
+ * @param {number|null} [options.maxRows] The maximum number of rows to load. Must be a non-negative
2193
+ * integer; if not specified or null, all rows are loaded. Anything else throws a QvdValidationError.
2194
+ * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
2195
+ * must be within this directory, with symlinks resolved first, so a link inside it that points
2196
+ * outside it is rejected. Defaults to the current working directory. To permit an entire
2197
+ * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2198
+ * back to the working directory rather than removing the restriction.
1992
2199
  * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines what percentage
1993
2200
  * of available memory (or V8 heap limit, whichever is smaller) can be used. Default is 0.3 (30%).
1994
2201
  * Increase this (e.g., to 0.5 or 0.7) when running with larger heap sizes via --max-old-space-size.
2202
+ * @throws {QvdValidationError} If options.maxRows is neither null/undefined nor a non-negative integer.
1995
2203
  * @return {Promise<QvdDataFrame>} The data frame of the QVD file.
1996
2204
  */
1997
2205
  static async fromQvd(path3, options = {}) {