qvdjs 0.9.0 → 0.9.1

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,4 +1,4 @@
1
- import path2 from 'path';
1
+ import path from 'path';
2
2
  import fs2 from 'fs';
3
3
  import crypto from 'crypto';
4
4
  import xml2 from 'xml2js';
@@ -250,20 +250,42 @@ var init_QvdSymbol = __esm({
250
250
  };
251
251
  }
252
252
  });
253
+ function isWithinDirectory(resolvedBaseDir, resolvedPath) {
254
+ const isCaseInsensitiveFS = process.platform === "win32" || process.platform === "darwin";
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
+ }
253
266
  function validatePath(filePath, allowedDir) {
267
+ if (typeof filePath !== "string" || filePath.length === 0) {
268
+ throw new QvdValidationError("filePath must be a non-empty string", {
269
+ provided: filePath,
270
+ type: typeof filePath
271
+ });
272
+ }
273
+ if (allowedDir !== void 0 && allowedDir !== null && typeof allowedDir !== "string") {
274
+ throw new QvdValidationError("allowedDir must be a string, null or undefined", {
275
+ provided: allowedDir,
276
+ type: typeof allowedDir
277
+ });
278
+ }
254
279
  if (filePath.includes("\0")) {
255
280
  throw new QvdSecurityError("Path traversal detected: Null byte in path", {
256
281
  path: filePath,
257
282
  reason: "null_byte"
258
283
  });
259
284
  }
285
+ const resolvedPath = path.resolve(filePath);
260
286
  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) {
287
+ const resolvedBaseDir = path.resolve(baseDir);
288
+ if (!isWithinDirectory(resolvedBaseDir, resolvedPath)) {
267
289
  throw new QvdSecurityError("Path traversal detected: Access denied", {
268
290
  path: filePath,
269
291
  resolvedPath,
@@ -297,7 +319,9 @@ var init_QvdFileWriter = __esm({
297
319
  * @param {QvdDataFrame} df The data frame to write to the QVD file.
298
320
  * @param {Object} [options={}] Options for the writer.
299
321
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
300
- * path must be within this directory. Defaults to current working directory.
322
+ * path must be within this directory. Defaults to the current working directory. To permit
323
+ * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
324
+ * empty value falls back to the working directory rather than removing the restriction.
301
325
  * @param {Function} [options.onProgress] Optional progress callback function.
302
326
  */
303
327
  constructor(filePath, df, options = {}) {
@@ -369,7 +393,7 @@ var init_QvdFileWriter = __esm({
369
393
  SourceFileUtcTime: existingMetadata.SourceFileUtcTime || "",
370
394
  SourceFileSize: existingMetadata.SourceFileSize || -1,
371
395
  StaleUtcTime: existingMetadata.StaleUtcTime || "",
372
- TableName: existingMetadata.TableName || path2.basename(this._path, path2.extname(this._path)),
396
+ TableName: existingMetadata.TableName || path.basename(this._path, path.extname(this._path)),
373
397
  Compression: existingMetadata.Compression || "",
374
398
  Comment: existingMetadata.Comment || "",
375
399
  EncryptionInfo: existingMetadata.EncryptionInfo || "",
@@ -389,7 +413,7 @@ var init_QvdFileWriter = __esm({
389
413
  SourceFileUtcTime: "",
390
414
  SourceFileSize: -1,
391
415
  StaleUtcTime: "",
392
- TableName: path2.basename(this._path, path2.extname(this._path)),
416
+ TableName: path.basename(this._path, path.extname(this._path)),
393
417
  Compression: "",
394
418
  Comment: "",
395
419
  EncryptionInfo: "",
@@ -559,21 +583,22 @@ var init_QvdFileWriter = __esm({
559
583
  this._emitProgress("index-table", processedRows, numRows);
560
584
  }
561
585
  });
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);
586
+ this._df.columns.forEach((column, columnIndex) => {
587
+ const bitOffset = this._indexTableMetadata?.slice(0, columnIndex).reduce((sum, metadata) => sum + metadata[1], 0);
564
588
  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];
589
+ const fieldContainsNull = this._symbolTableMetadata?.[columnIndex][2];
571
590
  const bias = fieldContainsNull ? -2 : 0;
591
+ const symbolCount = this._symbolTable?.[columnIndex].length ?? 0;
592
+ const maxStoredIndex = symbolCount === 0 ? 0 : symbolCount - 1 + (fieldContainsNull ? 2 : 0);
593
+ const bitWidth = maxStoredIndex === 0 ? 0 : 32 - Math.clz32(maxStoredIndex);
572
594
  this._indexTableMetadata?.push([bitOffset, bitWidth, bias]);
573
595
  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;
596
+ const bitString = indices[columnIndex];
597
+ assert(
598
+ bitString === "0" || bitString.length <= bitWidth,
599
+ `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.`
600
+ );
601
+ indices[columnIndex] = bitWidth === 0 ? "" : bitString.padStart(bitWidth, "0");
577
602
  });
578
603
  });
579
604
  this._indexBuffer = Buffer.concat(
@@ -581,6 +606,9 @@ var init_QvdFileWriter = __esm({
581
606
  this._indexTable.map((indices) => {
582
607
  indices.reverse();
583
608
  const bits = indices.join("");
609
+ if (bits.length === 0) {
610
+ return Buffer.from(Uint8Array.from([0]));
611
+ }
584
612
  const paddingWidth = (8 - bits.length % 8) % 8;
585
613
  const paddedBits = bits.padStart(bits.length + paddingWidth, "0");
586
614
  const bytes = paddedBits.match(/.{1,8}/g)?.map((byte) => parseInt(byte, 2));
@@ -793,7 +821,7 @@ function validateFieldMetadata(field, symbolBufferLength, filePath) {
793
821
  });
794
822
  }
795
823
  }
796
- function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath) {
824
+ function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath, fileSize = null) {
797
825
  if (isNaN(recordSize) || !Number.isSafeInteger(recordSize) || recordSize < 0) {
798
826
  throw new QvdCorruptedError("Invalid record byte size", {
799
827
  recordSize,
@@ -839,16 +867,40 @@ function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, ind
839
867
  stage: "parseIndexTable"
840
868
  });
841
869
  }
842
- const maxReasonableOverage = 100 * 1024 * 1024;
843
- if (indexTableLength > bufferLength + maxReasonableOverage) {
844
- throw new QvdCorruptedError("Index table length unreasonably large", {
870
+ if (indexTableLength !== totalRows * recordSize) {
871
+ throw new QvdCorruptedError("Index table length inconsistent with record count", {
845
872
  indexTableLength,
846
- bufferSize: bufferLength,
873
+ expectedLength: totalRows * recordSize,
874
+ totalRows,
875
+ recordSize,
847
876
  file: filePath,
848
877
  stage: "parseIndexTable"
849
878
  });
850
879
  }
880
+ if (fileSize !== null) {
881
+ if (indexTableOffset + indexTableLength > fileSize) {
882
+ throw new QvdCorruptedError("Index table extends beyond the end of the file", {
883
+ indexTableOffset,
884
+ indexTableLength,
885
+ fileSize,
886
+ file: filePath,
887
+ stage: "parseIndexTable"
888
+ });
889
+ }
890
+ }
851
891
  const requiredIndexBytes = rowsToLoad * recordSize;
892
+ if (indexTableOffset + requiredIndexBytes > bufferLength) {
893
+ throw new QvdCorruptedError("Index table truncated", {
894
+ indexTableOffset,
895
+ requiredBytes: requiredIndexBytes,
896
+ availableBytes: Math.max(0, bufferLength - indexTableOffset),
897
+ rowsToLoad,
898
+ recordSize,
899
+ bufferSize: bufferLength,
900
+ file: filePath,
901
+ stage: "parseIndexTable"
902
+ });
903
+ }
852
904
  if (indexTableLength < requiredIndexBytes) {
853
905
  throw new QvdCorruptedError("Index table length smaller than required", {
854
906
  indexTableLength,
@@ -1159,7 +1211,7 @@ var QvdFileReader_exports = {};
1159
1211
  __export(QvdFileReader_exports, {
1160
1212
  QvdFileReader: () => QvdFileReader
1161
1213
  });
1162
- var QvdFileReader;
1214
+ var MAX_HEADER_SIZE, READ_CHUNK_SIZE, QvdFileReader;
1163
1215
  var init_QvdFileReader = __esm({
1164
1216
  "src/QvdFileReader.js"() {
1165
1217
  init_QvdDataFrame();
@@ -1169,6 +1221,8 @@ var init_QvdFileReader = __esm({
1169
1221
  init_memoryUtils();
1170
1222
  init_validationUtils();
1171
1223
  init_symbolParser();
1224
+ MAX_HEADER_SIZE = 16 * 1024 * 1024;
1225
+ READ_CHUNK_SIZE = 512 * 1024 * 1024;
1172
1226
  QvdFileReader = class {
1173
1227
  /**
1174
1228
  * Constructs a new QVD file parser.
@@ -1176,7 +1230,9 @@ var init_QvdFileReader = __esm({
1176
1230
  * @param {string} filePath The path to the QVD file to load.
1177
1231
  * @param {Object} [options={}] Options for the reader.
1178
1232
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
1179
- * path must be within this directory. Defaults to current working directory.
1233
+ * path must be within this directory. Defaults to the current working directory. To permit
1234
+ * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
1235
+ * empty value falls back to the working directory rather than removing the restriction.
1180
1236
  * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines
1181
1237
  * what percentage of available memory (or V8 heap limit, whichever is smaller) can be used
1182
1238
  * for loading QVD files. Default is 0.3 (30%). Increase for larger heap configurations.
@@ -1192,6 +1248,7 @@ var init_QvdFileReader = __esm({
1192
1248
  this._header = null;
1193
1249
  this._symbolTable = null;
1194
1250
  this._indexTable = null;
1251
+ this._fileSize = null;
1195
1252
  }
1196
1253
  /**
1197
1254
  * Reads the binary data of the QVD file.
@@ -1223,6 +1280,7 @@ var init_QvdFileReader = __esm({
1223
1280
  async _readData(maxRows = null) {
1224
1281
  if (maxRows === null) {
1225
1282
  this._buffer = await fs2.promises.readFile(this._path);
1283
+ this._fileSize = this._buffer.length;
1226
1284
  return;
1227
1285
  }
1228
1286
  const HEADER_DELIMITER = "\r\n\0";
@@ -1230,19 +1288,41 @@ var init_QvdFileReader = __esm({
1230
1288
  const stream = fs2.createReadStream(this._path, {
1231
1289
  highWaterMark: CHUNK_SIZE
1232
1290
  });
1291
+ const headerChunks = [];
1292
+ let headerBytes = 0;
1293
+ let tail = Buffer.alloc(0);
1233
1294
  let headerBuffer = Buffer.alloc(0);
1234
1295
  let headerDelimiterIndex = -1;
1235
1296
  try {
1236
1297
  for await (const chunk of stream) {
1237
- headerBuffer = Buffer.concat([headerBuffer, chunk]);
1238
- headerDelimiterIndex = headerBuffer.indexOf(HEADER_DELIMITER);
1239
- if (headerDelimiterIndex !== -1) {
1298
+ const chunkStart = headerBytes;
1299
+ const searchBuffer = tail.length > 0 ? Buffer.concat([tail, chunk]) : chunk;
1300
+ const foundInSearch = searchBuffer.indexOf(HEADER_DELIMITER);
1301
+ headerChunks.push(chunk);
1302
+ headerBytes += chunk.length;
1303
+ if (foundInSearch !== -1) {
1304
+ headerDelimiterIndex = chunkStart - tail.length + foundInSearch;
1240
1305
  stream.destroy();
1241
1306
  break;
1242
1307
  }
1308
+ tail = Buffer.from(searchBuffer.subarray(-(HEADER_DELIMITER.length - 1)));
1309
+ if (headerBytes > MAX_HEADER_SIZE) {
1310
+ stream.destroy();
1311
+ throw new QvdCorruptedError(
1312
+ `The XML header delimiter was not found within the first ${MAX_HEADER_SIZE / (1024 * 1024)}MB of the file.`,
1313
+ {
1314
+ file: this._path,
1315
+ bytesSearched: headerBytes,
1316
+ maxHeaderSize: MAX_HEADER_SIZE,
1317
+ stage: "readData"
1318
+ }
1319
+ );
1320
+ }
1243
1321
  }
1244
1322
  } catch (error) {
1245
- if (error && typeof error === "object" && "code" in error && error.code !== "ERR_STREAM_PREMATURE_CLOSE") {
1323
+ const isExpectedEarlyClose = headerDelimiterIndex !== -1 && error !== null && typeof error === "object" && /** @type {{code?: unknown}} */
1324
+ error.code === "ERR_STREAM_PREMATURE_CLOSE";
1325
+ if (!isExpectedEarlyClose) {
1246
1326
  throw error;
1247
1327
  }
1248
1328
  }
@@ -1255,6 +1335,7 @@ var init_QvdFileReader = __esm({
1255
1335
  }
1256
1336
  );
1257
1337
  }
1338
+ headerBuffer = Buffer.concat(headerChunks);
1258
1339
  const headerEndIndex = headerDelimiterIndex + HEADER_DELIMITER.length;
1259
1340
  const headerXml = headerBuffer.subarray(0, headerEndIndex).toString();
1260
1341
  const headerObj = await xml2.parseStringPromise(headerXml, { explicitArray: false });
@@ -1271,12 +1352,50 @@ var init_QvdFileReader = __esm({
1271
1352
  const totalRows = parseInt(headerObj["QvdTableHeader"]["NoOfRecords"], 10);
1272
1353
  const rowsToLoad = Math.min(maxRows, totalRows);
1273
1354
  validateSymbolTableSizeEarly(symbolTableLength, this._path);
1355
+ for (const [name, value] of [
1356
+ ["Offset", symbolTableLength],
1357
+ ["RecordByteSize", recordSize],
1358
+ ["NoOfRecords", totalRows]
1359
+ ]) {
1360
+ if (!Number.isSafeInteger(value) || Number(value) < 0) {
1361
+ throw new QvdCorruptedError(`Invalid header value: ${name}`, {
1362
+ name,
1363
+ value,
1364
+ file: this._path,
1365
+ stage: "readData"
1366
+ });
1367
+ }
1368
+ }
1274
1369
  const indexTableBytesToRead = rowsToLoad * recordSize;
1275
1370
  const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
1276
1371
  const fd = await fs2.promises.open(this._path, "r");
1277
1372
  try {
1278
- this._buffer = Buffer.allocUnsafe(totalBytesToRead);
1279
- await fd.read(this._buffer, 0, totalBytesToRead, 0);
1373
+ const { size: fileSize } = await fd.stat();
1374
+ this._fileSize = fileSize;
1375
+ if (totalBytesToRead > fileSize) {
1376
+ throw new QvdCorruptedError("The file is shorter than its header claims.", {
1377
+ file: this._path,
1378
+ fileSize,
1379
+ requiredBytes: totalBytesToRead,
1380
+ stage: "readData"
1381
+ });
1382
+ }
1383
+ this._buffer = Buffer.alloc(totalBytesToRead);
1384
+ let position = 0;
1385
+ while (position < totalBytesToRead) {
1386
+ const length = Math.min(READ_CHUNK_SIZE, totalBytesToRead - position);
1387
+ const { bytesRead } = await fd.read(this._buffer, position, length, position);
1388
+ if (bytesRead === 0) {
1389
+ throw new QvdCorruptedError("Unexpected end of file while reading QVD data.", {
1390
+ file: this._path,
1391
+ fileSize,
1392
+ bytesRead: position,
1393
+ requiredBytes: totalBytesToRead,
1394
+ stage: "readData"
1395
+ });
1396
+ }
1397
+ position += bytesRead;
1398
+ }
1280
1399
  } finally {
1281
1400
  await fd.close();
1282
1401
  }
@@ -1378,7 +1497,10 @@ var init_QvdFileReader = __esm({
1378
1497
  } else {
1379
1498
  symbolIndex = this._convertBitsToInt32(mask.slice(bitOffset, bitOffset + bitWidth));
1380
1499
  }
1381
- symbolIndex -= bias;
1500
+ symbolIndex += bias;
1501
+ if (symbolIndex < 0) {
1502
+ return;
1503
+ }
1382
1504
  const fieldName = field["FieldName"];
1383
1505
  symbolUsage.get(fieldName)?.add(symbolIndex);
1384
1506
  });
@@ -1483,7 +1605,8 @@ var init_QvdFileReader = __esm({
1483
1605
  this._indexTableOffset,
1484
1606
  this._buffer.length,
1485
1607
  rowsToLoad,
1486
- this._path
1608
+ this._path,
1609
+ this._fileSize
1487
1610
  );
1488
1611
  const indexBuffer = this._buffer.subarray(this._indexTableOffset, this._indexTableOffset + indexTableLength + 1);
1489
1612
  for (const field of fields) {
@@ -1519,14 +1642,33 @@ var init_QvdFileReader = __esm({
1519
1642
  });
1520
1643
  this._indexTable.push(symbolIndices);
1521
1644
  }
1645
+ if (this._indexTable.length !== rowsToLoad) {
1646
+ throw new QvdCorruptedError("Index table contains fewer records than expected", {
1647
+ expectedRows: rowsToLoad,
1648
+ actualRows: this._indexTable.length,
1649
+ totalRows,
1650
+ recordSize,
1651
+ file: this._path,
1652
+ stage: "parseIndexTable"
1653
+ });
1654
+ }
1522
1655
  }
1523
1656
  /**
1524
1657
  * Loads the QVD file into memory and parses it.
1525
1658
  *
1526
1659
  * @param {number|null} maxRows The maximum number of rows to load. If null, all rows are loaded.
1660
+ * Must be a non-negative integer when given.
1661
+ * @throws {QvdValidationError} If maxRows is neither null nor a non-negative integer.
1527
1662
  * @return {Promise<QvdDataFrame>} The loaded QVD file.
1528
1663
  */
1529
1664
  async load(maxRows = null) {
1665
+ if (maxRows !== null && (typeof maxRows !== "number" || !Number.isInteger(maxRows) || maxRows < 0)) {
1666
+ throw new QvdValidationError("maxRows must be a non-negative integer, or null to load all rows", {
1667
+ provided: maxRows,
1668
+ type: typeof maxRows,
1669
+ file: this._path
1670
+ });
1671
+ }
1530
1672
  await this._readData(maxRows);
1531
1673
  await this._parseHeader();
1532
1674
  let symbolsToKeep = null;
@@ -1561,7 +1703,7 @@ var init_QvdFileReader = __esm({
1561
1703
  const symbol = this._symbolTable?.[fieldIndex]?.[symbolIndex];
1562
1704
  const value = symbol?.toPrimaryValue();
1563
1705
  if (typeof value === "string") {
1564
- if (!isNaN(Number(value))) {
1706
+ if (value.trim() !== "" && !isNaN(Number(value))) {
1565
1707
  return Number(value);
1566
1708
  }
1567
1709
  }
@@ -1971,7 +2113,10 @@ var init_QvdDataFrame = __esm({
1971
2113
  *
1972
2114
  * @param {string} path The path to the QVD file.
1973
2115
  * @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.
2116
+ * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
2117
+ * must be within this directory. Defaults to the current working directory. To permit an entire
2118
+ * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2119
+ * back to the working directory rather than removing the restriction.
1975
2120
  * @param {Function} [options.onProgress] Optional progress callback function that receives progress updates during write operations.
1976
2121
  */
1977
2122
  async toQvd(path3, options = {}) {
@@ -1987,11 +2132,16 @@ var init_QvdDataFrame = __esm({
1987
2132
  *
1988
2133
  * @param {string} path The path to the QVD file.
1989
2134
  * @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.
2135
+ * @param {number|null} [options.maxRows] The maximum number of rows to load. Must be a non-negative
2136
+ * integer; if not specified or null, all rows are loaded. Anything else throws a QvdValidationError.
2137
+ * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
2138
+ * must be within this directory. Defaults to the current working directory. To permit an entire
2139
+ * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2140
+ * back to the working directory rather than removing the restriction.
1992
2141
  * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines what percentage
1993
2142
  * of available memory (or V8 heap limit, whichever is smaller) can be used. Default is 0.3 (30%).
1994
2143
  * Increase this (e.g., to 0.5 or 0.7) when running with larger heap sizes via --max-old-space-size.
2144
+ * @throws {QvdValidationError} If options.maxRows is neither null/undefined nor a non-negative integer.
1995
2145
  * @return {Promise<QvdDataFrame>} The data frame of the QVD file.
1996
2146
  */
1997
2147
  static async fromQvd(path3, options = {}) {