qvdjs 0.9.1 → 0.9.3

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 fs = require('fs');
3
4
  var path = require('path');
4
- var fs2 = require('fs');
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 fs__default = /*#__PURE__*/_interopDefault(fs);
13
14
  var path__default = /*#__PURE__*/_interopDefault(path);
14
- var fs2__default = /*#__PURE__*/_interopDefault(fs2);
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,8 +262,8 @@ var init_QvdSymbol = __esm({
262
262
  };
263
263
  }
264
264
  });
265
- function isWithinDirectory(resolvedBaseDir, resolvedPath) {
266
- const isCaseInsensitiveFS = process.platform === "win32" || process.platform === "darwin";
265
+ function isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) {
266
+ const isCaseInsensitiveFS = process.platform === "win32";
267
267
  const base = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
268
268
  const target = isCaseInsensitiveFS ? resolvedPath.toLowerCase() : resolvedPath;
269
269
  const relative = path__default.default.relative(base, target);
@@ -275,6 +275,55 @@ function isWithinDirectory(resolvedBaseDir, resolvedPath) {
275
275
  }
276
276
  return relative !== ".." && !relative.startsWith(`..${path__default.default.sep}`);
277
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
+ }
278
327
  function validatePath(filePath, allowedDir) {
279
328
  if (typeof filePath !== "string" || filePath.length === 0) {
280
329
  throw new exports.QvdValidationError("filePath must be a non-empty string", {
@@ -297,12 +346,17 @@ function validatePath(filePath, allowedDir) {
297
346
  const resolvedPath = path__default.default.resolve(filePath);
298
347
  const baseDir = allowedDir || process.cwd();
299
348
  const resolvedBaseDir = path__default.default.resolve(baseDir);
300
- if (!isWithinDirectory(resolvedBaseDir, resolvedPath)) {
349
+ const onDisk = isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath);
350
+ const contained = onDisk === null ? isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) : onDisk;
351
+ if (!contained) {
301
352
  throw new exports.QvdSecurityError("Path traversal detected: Access denied", {
302
353
  path: filePath,
303
354
  resolvedPath,
304
355
  allowedDir: resolvedBaseDir,
305
- 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"
306
360
  });
307
361
  }
308
362
  return resolvedPath;
@@ -331,7 +385,8 @@ var init_QvdFileWriter = __esm({
331
385
  * @param {QvdDataFrame} df The data frame to write to the QVD file.
332
386
  * @param {Object} [options={}] Options for the writer.
333
387
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
334
- * path must be within this directory. Defaults to the current working directory. To permit
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
335
390
  * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
336
391
  * empty value falls back to the working directory rather than removing the restriction.
337
392
  * @param {Function} [options.onProgress] Optional progress callback function.
@@ -379,7 +434,7 @@ var init_QvdFileWriter = __esm({
379
434
  const headerBuffer = Buffer.concat([Buffer.from(this._header, "utf-8"), Buffer.from([0])]);
380
435
  let fd;
381
436
  try {
382
- fd = await fs2__default.default.promises.open(this._path, "w");
437
+ fd = await fs__default.default.promises.open(this._path, "w");
383
438
  await fd.write(headerBuffer, 0, headerBuffer.length, 0);
384
439
  await fd.write(this._symbolBuffer, 0, this._symbolBuffer.length, headerBuffer.length);
385
440
  await fd.write(this._indexBuffer, 0, this._indexBuffer.length, headerBuffer.length + this._symbolBuffer.length);
@@ -691,6 +746,28 @@ var init_bitUtils = __esm({
691
746
  function getHeapLimit() {
692
747
  return v8__default.default.getHeapStatistics().heap_size_limit;
693
748
  }
749
+ function heapLimitIsMeaningful() {
750
+ return !process.versions.bun && !process.versions.deno;
751
+ }
752
+ function getMemoryBudget() {
753
+ const candidates = [];
754
+ if (heapLimitIsMeaningful()) {
755
+ candidates.push({ source: "V8 heap limit", bytes: getHeapLimit() });
756
+ }
757
+ const constrained = typeof process.constrainedMemory === "function" ? process.constrainedMemory() : 0;
758
+ if (constrained > 0 && constrained < os__default.default.totalmem()) {
759
+ candidates.push({ source: "container memory limit", bytes: constrained });
760
+ }
761
+ if (candidates.length === 0) {
762
+ candidates.push({ source: "total system memory", bytes: os__default.default.totalmem() });
763
+ }
764
+ const observed = [{ source: "free memory (os.freemem)", bytes: os__default.default.freemem() }];
765
+ if (typeof process.availableMemory === "function") {
766
+ observed.push({ source: "available memory", bytes: process.availableMemory() });
767
+ }
768
+ const binding = candidates.reduce((lowest, candidate) => candidate.bytes < lowest.bytes ? candidate : lowest);
769
+ return { bytes: binding.bytes, limitedBy: binding.source, candidates, observed };
770
+ }
694
771
  function estimateMemoryUsage(symbolTableSize, maxRows, totalRows) {
695
772
  const FULL_PARSE_OVERHEAD = 6;
696
773
  const MINIMAL_OVERHEAD = 0.01;
@@ -707,11 +784,14 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
707
784
  if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
708
785
  throw new exports.QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", { safetyFactor });
709
786
  }
710
- const availableMemory = os__default.default.freemem();
787
+ if (safetyFactor === 0) {
788
+ return;
789
+ }
790
+ const budget = getMemoryBudget();
711
791
  const heapLimit = getHeapLimit();
792
+ const availableMemory = budget.bytes;
712
793
  const estimatedMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows);
713
- const effectiveLimit = Math.min(availableMemory, heapLimit);
714
- const maxAllowedMemory = effectiveLimit * safetyFactor;
794
+ const maxAllowedMemory = budget.bytes * safetyFactor;
715
795
  if (estimatedMemory > maxAllowedMemory) {
716
796
  const safeSymbolPercentage = maxAllowedMemory / (symbolTableSize * 6);
717
797
  const safeRowPercentage = Math.pow(safeSymbolPercentage, 2);
@@ -721,9 +801,12 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
721
801
  const availableMB = Math.round(maxAllowedMemory / 1024 / 1024);
722
802
  const heapLimitMB = Math.round(heapLimit / 1024 / 1024);
723
803
  const availableRamMB = Math.round(availableMemory / 1024 / 1024);
724
- const limitingFactor = heapLimit < availableMemory ? "V8 heap limit" : "available RAM";
804
+ const limitingFactor = budget.limitedBy;
805
+ const budgetBreakdown = budget.candidates.map((candidate) => `${candidate.source} ${Math.round(candidate.bytes / 1024 / 1024)}MB`).join(", ");
806
+ const observedBreakdown = budget.observed.map((entry) => `${entry.source} ${Math.round(entry.bytes / 1024 / 1024)}MB`).join(", ");
807
+ const advice = limitingFactor === "container memory limit" ? `The binding limit is the container's, so raising --max-old-space-size would let V8 grow past it and be killed by the OOM killer instead. Set it below the container limit, raise the limit, or load fewer rows with maxRows (recommended: ${recommendedMaxRows.toLocaleString()} rows or less).` : `Try loading fewer rows using the maxRows parameter (recommended: ${recommendedMaxRows.toLocaleString()} rows or less), or raise the heap with --max-old-space-size.`;
725
808
  throw new exports.QvdValidationError(
726
- `Insufficient memory to load file safely. Symbol table: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}: ${heapLimitMB}MB heap / ${availableRamMB}MB RAM). Try loading fewer rows using the maxRows parameter (recommended: ${recommendedMaxRows.toLocaleString()} rows or less).`,
809
+ `Insufficient memory to load file safely. Symbol table: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}; considered: ${budgetBreakdown}; observed but not used: ${observedBreakdown}). ` + advice,
727
810
  {
728
811
  file: filePath,
729
812
  symbolTableSize,
@@ -733,6 +816,8 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
733
816
  heapLimitMB,
734
817
  availableRamMB,
735
818
  limitingFactor,
819
+ memoryBudget: budget.candidates,
820
+ memoryObserved: budget.observed,
736
821
  totalRows,
737
822
  maxRows,
738
823
  recommendedMaxRows
@@ -1242,17 +1327,26 @@ var init_QvdFileReader = __esm({
1242
1327
  * @param {string} filePath The path to the QVD file to load.
1243
1328
  * @param {Object} [options={}] Options for the reader.
1244
1329
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
1245
- * path must be within this directory. Defaults to the current working directory. To permit
1330
+ * path must be within this directory, with symlinks resolved first, so a link inside it that
1331
+ * points outside it is rejected. Defaults to the current working directory. To permit
1246
1332
  * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
1247
1333
  * empty value falls back to the working directory rather than removing the restriction.
1248
- * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines
1249
- * what percentage of available memory (or V8 heap limit, whichever is smaller) can be used
1250
- * for loading QVD files. Default is 0.3 (30%). Increase for larger heap configurations.
1334
+ * @param {number} [options.memorySafetyFactor=0.3] Fraction (0.0-1.0) of the memory budget a
1335
+ * load may use. The budget is the smallest of the V8 heap limit, any container memory limit,
1336
+ * and the memory the OS reports as available. Default is 0.3. **Zero disables the memory
1337
+ * check entirely**, which is the escape hatch for runtimes whose limits cannot be measured -
1338
+ * Bun reports its current heap as its heap limit - and for callers who would rather manage
1339
+ * memory themselves than trust the estimate.
1340
+ * @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes,
1341
+ * above which a lazy load switches to the two-pass filtering path. The default of 50MB is
1342
+ * the point where the extra analysis pass pays for itself; lower it to use filtering on
1343
+ * smaller files, raise it to keep the simpler single-pass read for longer.
1251
1344
  */
1252
1345
  constructor(filePath, options = {}) {
1253
- const { allowedDir, memorySafetyFactor = 0.3 } = options;
1346
+ const { allowedDir, memorySafetyFactor = 0.3, symbolFilteringThreshold = 50 * 1024 * 1024 } = options;
1254
1347
  this._path = validatePath(filePath, allowedDir);
1255
1348
  this._memorySafetyFactor = memorySafetyFactor;
1349
+ this._symbolFilteringThreshold = symbolFilteringThreshold;
1256
1350
  this._buffer = null;
1257
1351
  this._headerOffset = null;
1258
1352
  this._symbolTableOffset = null;
@@ -1291,13 +1385,13 @@ var init_QvdFileReader = __esm({
1291
1385
  */
1292
1386
  async _readData(maxRows = null) {
1293
1387
  if (maxRows === null) {
1294
- this._buffer = await fs2__default.default.promises.readFile(this._path);
1388
+ this._buffer = await fs__default.default.promises.readFile(this._path);
1295
1389
  this._fileSize = this._buffer.length;
1296
1390
  return;
1297
1391
  }
1298
1392
  const HEADER_DELIMITER = "\r\n\0";
1299
1393
  const CHUNK_SIZE = 64 * 1024;
1300
- const stream = fs2__default.default.createReadStream(this._path, {
1394
+ const stream = fs__default.default.createReadStream(this._path, {
1301
1395
  highWaterMark: CHUNK_SIZE
1302
1396
  });
1303
1397
  const headerChunks = [];
@@ -1380,7 +1474,7 @@ var init_QvdFileReader = __esm({
1380
1474
  }
1381
1475
  const indexTableBytesToRead = rowsToLoad * recordSize;
1382
1476
  const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
1383
- const fd = await fs2__default.default.promises.open(this._path, "r");
1477
+ const fd = await fs__default.default.promises.open(this._path, "r");
1384
1478
  try {
1385
1479
  const { size: fileSize } = await fd.stat();
1386
1480
  this._fileSize = fileSize;
@@ -1684,15 +1778,12 @@ var init_QvdFileReader = __esm({
1684
1778
  await this._readData(maxRows);
1685
1779
  await this._parseHeader();
1686
1780
  let symbolsToKeep = null;
1781
+ let symbolsKept = null;
1687
1782
  if (maxRows !== null && this._header) {
1688
1783
  const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
1689
- const SYMBOL_FILTERING_THRESHOLD = 50 * 1024 * 1024;
1690
- if (symbolTableLength > SYMBOL_FILTERING_THRESHOLD) {
1784
+ if (symbolTableLength > this._symbolFilteringThreshold) {
1691
1785
  symbolsToKeep = await this._analyzeIndexTableSymbolUsage(maxRows);
1692
- const totalSymbols = Array.from(symbolsToKeep.values()).reduce((sum, set) => sum + set.size, 0);
1693
- console.log(
1694
- `[Phase 2.5 Optimization] Using stream-and-skip parsing: keeping ${totalSymbols} symbols from ${(symbolTableLength / 1024 / 1024).toFixed(1)}MB symbol table`
1695
- );
1786
+ symbolsKept = Array.from(symbolsToKeep.values()).reduce((sum, set) => sum + set.size, 0);
1696
1787
  }
1697
1788
  }
1698
1789
  await this._parseSymbolTable(symbolsToKeep, maxRows);
@@ -1729,7 +1820,14 @@ var init_QvdFileReader = __esm({
1729
1820
  const columns = fields.map((field) => field["FieldName"]);
1730
1821
  const data = this._indexTable.map((_, index) => getRow(index));
1731
1822
  const metadata = this._header["QvdTableHeader"];
1732
- return new exports.QvdDataFrame(data, columns, metadata);
1823
+ const loadStats = {
1824
+ symbolTableBytes: parseInt(this._header["QvdTableHeader"]["Offset"], 10),
1825
+ totalRows: parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10),
1826
+ rowsLoaded: data.length,
1827
+ symbolFiltering: symbolsToKeep !== null,
1828
+ symbolsKept
1829
+ };
1830
+ return new exports.QvdDataFrame(data, columns, metadata, loadStats);
1733
1831
  }
1734
1832
  };
1735
1833
  }
@@ -1746,11 +1844,13 @@ var init_QvdDataFrame = __esm({
1746
1844
  * @param {Array<Array<any>>} data The data of the data frame.
1747
1845
  * @param {Array<string>} columns The columns of the data frame.
1748
1846
  * @param {QvdMetadata|null} metadata The metadata from the QVD file header (optional).
1847
+ * @param {QvdLoadStats|null} loadStats Statistics about the read (optional).
1749
1848
  */
1750
- constructor(data, columns, metadata = null) {
1849
+ constructor(data, columns, metadata = null, loadStats = null) {
1751
1850
  this._data = data;
1752
1851
  this._columns = columns;
1753
1852
  this._metadata = metadata;
1853
+ this._loadStats = loadStats;
1754
1854
  }
1755
1855
  /**
1756
1856
  * Returns the data of the data frame.
@@ -1777,6 +1877,21 @@ var init_QvdDataFrame = __esm({
1777
1877
  get metadata() {
1778
1878
  return this._metadata;
1779
1879
  }
1880
+ /**
1881
+ * Returns statistics about the read that produced this data frame.
1882
+ *
1883
+ * Only a frame returned by fromQvd() carries these; fromDict(), head() and tail() produce
1884
+ * frames that describe no particular read, and report null rather than a stale figure.
1885
+ *
1886
+ * The main use is confirming that a lazy load actually filtered the symbol table:
1887
+ * `symbolFiltering` says whether the two-pass path ran, and `symbolsKept` how many symbols
1888
+ * survived it, which for a small maxRows should be a tiny fraction of the file's total.
1889
+ *
1890
+ * @return {QvdLoadStats|null} Load statistics, or null if this frame did not come from a file.
1891
+ */
1892
+ get loadStats() {
1893
+ return this._loadStats;
1894
+ }
1780
1895
  /**
1781
1896
  * Returns file-level metadata from the QVD header.
1782
1897
  * @return {Object} File-level metadata properties.
@@ -2126,7 +2241,8 @@ var init_QvdDataFrame = __esm({
2126
2241
  * @param {string} path The path to the QVD file.
2127
2242
  * @param {Object} [options] Optional writing options.
2128
2243
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
2129
- * must be within this directory. Defaults to the current working directory. To permit an entire
2244
+ * must be within this directory, with symlinks resolved first, so a link inside it that points
2245
+ * outside it is rejected. Defaults to the current working directory. To permit an entire
2130
2246
  * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2131
2247
  * back to the working directory rather than removing the restriction.
2132
2248
  * @param {Function} [options.onProgress] Optional progress callback function that receives progress updates during write operations.
@@ -2147,12 +2263,16 @@ var init_QvdDataFrame = __esm({
2147
2263
  * @param {number|null} [options.maxRows] The maximum number of rows to load. Must be a non-negative
2148
2264
  * integer; if not specified or null, all rows are loaded. Anything else throws a QvdValidationError.
2149
2265
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
2150
- * must be within this directory. Defaults to the current working directory. To permit an entire
2266
+ * must be within this directory, with symlinks resolved first, so a link inside it that points
2267
+ * outside it is rejected. Defaults to the current working directory. To permit an entire
2151
2268
  * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2152
2269
  * back to the working directory rather than removing the restriction.
2153
- * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines what percentage
2154
- * of available memory (or V8 heap limit, whichever is smaller) can be used. Default is 0.3 (30%).
2155
- * Increase this (e.g., to 0.5 or 0.7) when running with larger heap sizes via --max-old-space-size.
2270
+ * @param {number} [options.memorySafetyFactor=0.3] Fraction (0.0-1.0) of the memory budget a load
2271
+ * may use. The budget is the smallest of the V8 heap limit, any container memory limit, and the
2272
+ * memory the OS reports as available. Default is 0.3; raise it when running with a larger heap via
2273
+ * --max-old-space-size. **Zero disables the memory check entirely.**
2274
+ * @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes, above which
2275
+ * a lazy load switches to the two-pass filtering path. Defaults to 50MB.
2156
2276
  * @throws {QvdValidationError} If options.maxRows is neither null/undefined nor a non-negative integer.
2157
2277
  * @return {Promise<QvdDataFrame>} The data frame of the QVD file.
2158
2278
  */
@@ -2160,7 +2280,8 @@ var init_QvdDataFrame = __esm({
2160
2280
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
2161
2281
  const readerOptions = {
2162
2282
  allowedDir: options.allowedDir,
2163
- memorySafetyFactor: options.memorySafetyFactor
2283
+ memorySafetyFactor: options.memorySafetyFactor,
2284
+ symbolFilteringThreshold: options.symbolFilteringThreshold
2164
2285
  };
2165
2286
  return await new QvdFileReader2(path3, readerOptions).load(options.maxRows !== void 0 ? options.maxRows : null);
2166
2287
  }