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.js CHANGED
@@ -1,5 +1,5 @@
1
+ import fs from 'fs';
1
2
  import path from 'path';
2
- import fs2 from 'fs';
3
3
  import crypto from 'crypto';
4
4
  import xml2 from 'xml2js';
5
5
  import assert from 'assert';
@@ -250,8 +250,8 @@ var init_QvdSymbol = __esm({
250
250
  };
251
251
  }
252
252
  });
253
- function isWithinDirectory(resolvedBaseDir, resolvedPath) {
254
- const isCaseInsensitiveFS = process.platform === "win32" || process.platform === "darwin";
253
+ function isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) {
254
+ const isCaseInsensitiveFS = process.platform === "win32";
255
255
  const base = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
256
256
  const target = isCaseInsensitiveFS ? resolvedPath.toLowerCase() : resolvedPath;
257
257
  const relative = path.relative(base, target);
@@ -263,6 +263,55 @@ function isWithinDirectory(resolvedBaseDir, resolvedPath) {
263
263
  }
264
264
  return relative !== ".." && !relative.startsWith(`..${path.sep}`);
265
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
+ }
266
315
  function validatePath(filePath, allowedDir) {
267
316
  if (typeof filePath !== "string" || filePath.length === 0) {
268
317
  throw new QvdValidationError("filePath must be a non-empty string", {
@@ -285,12 +334,17 @@ function validatePath(filePath, allowedDir) {
285
334
  const resolvedPath = path.resolve(filePath);
286
335
  const baseDir = allowedDir || process.cwd();
287
336
  const resolvedBaseDir = path.resolve(baseDir);
288
- if (!isWithinDirectory(resolvedBaseDir, resolvedPath)) {
337
+ const onDisk = isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath);
338
+ const contained = onDisk === null ? isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) : onDisk;
339
+ if (!contained) {
289
340
  throw new QvdSecurityError("Path traversal detected: Access denied", {
290
341
  path: filePath,
291
342
  resolvedPath,
292
343
  allowedDir: resolvedBaseDir,
293
- 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"
294
348
  });
295
349
  }
296
350
  return resolvedPath;
@@ -319,7 +373,8 @@ var init_QvdFileWriter = __esm({
319
373
  * @param {QvdDataFrame} df The data frame to write to the QVD file.
320
374
  * @param {Object} [options={}] Options for the writer.
321
375
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
322
- * path must be within this directory. Defaults to the current working directory. To permit
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
323
378
  * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
324
379
  * empty value falls back to the working directory rather than removing the restriction.
325
380
  * @param {Function} [options.onProgress] Optional progress callback function.
@@ -367,7 +422,7 @@ var init_QvdFileWriter = __esm({
367
422
  const headerBuffer = Buffer.concat([Buffer.from(this._header, "utf-8"), Buffer.from([0])]);
368
423
  let fd;
369
424
  try {
370
- fd = await fs2.promises.open(this._path, "w");
425
+ fd = await fs.promises.open(this._path, "w");
371
426
  await fd.write(headerBuffer, 0, headerBuffer.length, 0);
372
427
  await fd.write(this._symbolBuffer, 0, this._symbolBuffer.length, headerBuffer.length);
373
428
  await fd.write(this._indexBuffer, 0, this._indexBuffer.length, headerBuffer.length + this._symbolBuffer.length);
@@ -679,6 +734,28 @@ var init_bitUtils = __esm({
679
734
  function getHeapLimit() {
680
735
  return v8.getHeapStatistics().heap_size_limit;
681
736
  }
737
+ function heapLimitIsMeaningful() {
738
+ return !process.versions.bun && !process.versions.deno;
739
+ }
740
+ function getMemoryBudget() {
741
+ const candidates = [];
742
+ if (heapLimitIsMeaningful()) {
743
+ candidates.push({ source: "V8 heap limit", bytes: getHeapLimit() });
744
+ }
745
+ const constrained = typeof process.constrainedMemory === "function" ? process.constrainedMemory() : 0;
746
+ if (constrained > 0 && constrained < os.totalmem()) {
747
+ candidates.push({ source: "container memory limit", bytes: constrained });
748
+ }
749
+ if (candidates.length === 0) {
750
+ candidates.push({ source: "total system memory", bytes: os.totalmem() });
751
+ }
752
+ const observed = [{ source: "free memory (os.freemem)", bytes: os.freemem() }];
753
+ if (typeof process.availableMemory === "function") {
754
+ observed.push({ source: "available memory", bytes: process.availableMemory() });
755
+ }
756
+ const binding = candidates.reduce((lowest, candidate) => candidate.bytes < lowest.bytes ? candidate : lowest);
757
+ return { bytes: binding.bytes, limitedBy: binding.source, candidates, observed };
758
+ }
682
759
  function estimateMemoryUsage(symbolTableSize, maxRows, totalRows) {
683
760
  const FULL_PARSE_OVERHEAD = 6;
684
761
  const MINIMAL_OVERHEAD = 0.01;
@@ -695,11 +772,14 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
695
772
  if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
696
773
  throw new QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", { safetyFactor });
697
774
  }
698
- const availableMemory = os.freemem();
775
+ if (safetyFactor === 0) {
776
+ return;
777
+ }
778
+ const budget = getMemoryBudget();
699
779
  const heapLimit = getHeapLimit();
780
+ const availableMemory = budget.bytes;
700
781
  const estimatedMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows);
701
- const effectiveLimit = Math.min(availableMemory, heapLimit);
702
- const maxAllowedMemory = effectiveLimit * safetyFactor;
782
+ const maxAllowedMemory = budget.bytes * safetyFactor;
703
783
  if (estimatedMemory > maxAllowedMemory) {
704
784
  const safeSymbolPercentage = maxAllowedMemory / (symbolTableSize * 6);
705
785
  const safeRowPercentage = Math.pow(safeSymbolPercentage, 2);
@@ -709,9 +789,12 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
709
789
  const availableMB = Math.round(maxAllowedMemory / 1024 / 1024);
710
790
  const heapLimitMB = Math.round(heapLimit / 1024 / 1024);
711
791
  const availableRamMB = Math.round(availableMemory / 1024 / 1024);
712
- const limitingFactor = heapLimit < availableMemory ? "V8 heap limit" : "available RAM";
792
+ const limitingFactor = budget.limitedBy;
793
+ const budgetBreakdown = budget.candidates.map((candidate) => `${candidate.source} ${Math.round(candidate.bytes / 1024 / 1024)}MB`).join(", ");
794
+ const observedBreakdown = budget.observed.map((entry) => `${entry.source} ${Math.round(entry.bytes / 1024 / 1024)}MB`).join(", ");
795
+ 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.`;
713
796
  throw new QvdValidationError(
714
- `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).`,
797
+ `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,
715
798
  {
716
799
  file: filePath,
717
800
  symbolTableSize,
@@ -721,6 +804,8 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
721
804
  heapLimitMB,
722
805
  availableRamMB,
723
806
  limitingFactor,
807
+ memoryBudget: budget.candidates,
808
+ memoryObserved: budget.observed,
724
809
  totalRows,
725
810
  maxRows,
726
811
  recommendedMaxRows
@@ -1230,17 +1315,26 @@ var init_QvdFileReader = __esm({
1230
1315
  * @param {string} filePath The path to the QVD file to load.
1231
1316
  * @param {Object} [options={}] Options for the reader.
1232
1317
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file
1233
- * path must be within this directory. Defaults to the current working directory. To permit
1318
+ * path must be within this directory, with symlinks resolved first, so a link inside it that
1319
+ * points outside it is rejected. Defaults to the current working directory. To permit
1234
1320
  * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
1235
1321
  * empty value falls back to the working directory rather than removing the restriction.
1236
- * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines
1237
- * what percentage of available memory (or V8 heap limit, whichever is smaller) can be used
1238
- * for loading QVD files. Default is 0.3 (30%). Increase for larger heap configurations.
1322
+ * @param {number} [options.memorySafetyFactor=0.3] Fraction (0.0-1.0) of the memory budget a
1323
+ * load may use. The budget is the smallest of the V8 heap limit, any container memory limit,
1324
+ * and the memory the OS reports as available. Default is 0.3. **Zero disables the memory
1325
+ * check entirely**, which is the escape hatch for runtimes whose limits cannot be measured -
1326
+ * Bun reports its current heap as its heap limit - and for callers who would rather manage
1327
+ * memory themselves than trust the estimate.
1328
+ * @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes,
1329
+ * above which a lazy load switches to the two-pass filtering path. The default of 50MB is
1330
+ * the point where the extra analysis pass pays for itself; lower it to use filtering on
1331
+ * smaller files, raise it to keep the simpler single-pass read for longer.
1239
1332
  */
1240
1333
  constructor(filePath, options = {}) {
1241
- const { allowedDir, memorySafetyFactor = 0.3 } = options;
1334
+ const { allowedDir, memorySafetyFactor = 0.3, symbolFilteringThreshold = 50 * 1024 * 1024 } = options;
1242
1335
  this._path = validatePath(filePath, allowedDir);
1243
1336
  this._memorySafetyFactor = memorySafetyFactor;
1337
+ this._symbolFilteringThreshold = symbolFilteringThreshold;
1244
1338
  this._buffer = null;
1245
1339
  this._headerOffset = null;
1246
1340
  this._symbolTableOffset = null;
@@ -1279,13 +1373,13 @@ var init_QvdFileReader = __esm({
1279
1373
  */
1280
1374
  async _readData(maxRows = null) {
1281
1375
  if (maxRows === null) {
1282
- this._buffer = await fs2.promises.readFile(this._path);
1376
+ this._buffer = await fs.promises.readFile(this._path);
1283
1377
  this._fileSize = this._buffer.length;
1284
1378
  return;
1285
1379
  }
1286
1380
  const HEADER_DELIMITER = "\r\n\0";
1287
1381
  const CHUNK_SIZE = 64 * 1024;
1288
- const stream = fs2.createReadStream(this._path, {
1382
+ const stream = fs.createReadStream(this._path, {
1289
1383
  highWaterMark: CHUNK_SIZE
1290
1384
  });
1291
1385
  const headerChunks = [];
@@ -1368,7 +1462,7 @@ var init_QvdFileReader = __esm({
1368
1462
  }
1369
1463
  const indexTableBytesToRead = rowsToLoad * recordSize;
1370
1464
  const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
1371
- const fd = await fs2.promises.open(this._path, "r");
1465
+ const fd = await fs.promises.open(this._path, "r");
1372
1466
  try {
1373
1467
  const { size: fileSize } = await fd.stat();
1374
1468
  this._fileSize = fileSize;
@@ -1672,15 +1766,12 @@ var init_QvdFileReader = __esm({
1672
1766
  await this._readData(maxRows);
1673
1767
  await this._parseHeader();
1674
1768
  let symbolsToKeep = null;
1769
+ let symbolsKept = null;
1675
1770
  if (maxRows !== null && this._header) {
1676
1771
  const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
1677
- const SYMBOL_FILTERING_THRESHOLD = 50 * 1024 * 1024;
1678
- if (symbolTableLength > SYMBOL_FILTERING_THRESHOLD) {
1772
+ if (symbolTableLength > this._symbolFilteringThreshold) {
1679
1773
  symbolsToKeep = await this._analyzeIndexTableSymbolUsage(maxRows);
1680
- const totalSymbols = Array.from(symbolsToKeep.values()).reduce((sum, set) => sum + set.size, 0);
1681
- console.log(
1682
- `[Phase 2.5 Optimization] Using stream-and-skip parsing: keeping ${totalSymbols} symbols from ${(symbolTableLength / 1024 / 1024).toFixed(1)}MB symbol table`
1683
- );
1774
+ symbolsKept = Array.from(symbolsToKeep.values()).reduce((sum, set) => sum + set.size, 0);
1684
1775
  }
1685
1776
  }
1686
1777
  await this._parseSymbolTable(symbolsToKeep, maxRows);
@@ -1717,7 +1808,14 @@ var init_QvdFileReader = __esm({
1717
1808
  const columns = fields.map((field) => field["FieldName"]);
1718
1809
  const data = this._indexTable.map((_, index) => getRow(index));
1719
1810
  const metadata = this._header["QvdTableHeader"];
1720
- return new QvdDataFrame(data, columns, metadata);
1811
+ const loadStats = {
1812
+ symbolTableBytes: parseInt(this._header["QvdTableHeader"]["Offset"], 10),
1813
+ totalRows: parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10),
1814
+ rowsLoaded: data.length,
1815
+ symbolFiltering: symbolsToKeep !== null,
1816
+ symbolsKept
1817
+ };
1818
+ return new QvdDataFrame(data, columns, metadata, loadStats);
1721
1819
  }
1722
1820
  };
1723
1821
  }
@@ -1734,11 +1832,13 @@ var init_QvdDataFrame = __esm({
1734
1832
  * @param {Array<Array<any>>} data The data of the data frame.
1735
1833
  * @param {Array<string>} columns The columns of the data frame.
1736
1834
  * @param {QvdMetadata|null} metadata The metadata from the QVD file header (optional).
1835
+ * @param {QvdLoadStats|null} loadStats Statistics about the read (optional).
1737
1836
  */
1738
- constructor(data, columns, metadata = null) {
1837
+ constructor(data, columns, metadata = null, loadStats = null) {
1739
1838
  this._data = data;
1740
1839
  this._columns = columns;
1741
1840
  this._metadata = metadata;
1841
+ this._loadStats = loadStats;
1742
1842
  }
1743
1843
  /**
1744
1844
  * Returns the data of the data frame.
@@ -1765,6 +1865,21 @@ var init_QvdDataFrame = __esm({
1765
1865
  get metadata() {
1766
1866
  return this._metadata;
1767
1867
  }
1868
+ /**
1869
+ * Returns statistics about the read that produced this data frame.
1870
+ *
1871
+ * Only a frame returned by fromQvd() carries these; fromDict(), head() and tail() produce
1872
+ * frames that describe no particular read, and report null rather than a stale figure.
1873
+ *
1874
+ * The main use is confirming that a lazy load actually filtered the symbol table:
1875
+ * `symbolFiltering` says whether the two-pass path ran, and `symbolsKept` how many symbols
1876
+ * survived it, which for a small maxRows should be a tiny fraction of the file's total.
1877
+ *
1878
+ * @return {QvdLoadStats|null} Load statistics, or null if this frame did not come from a file.
1879
+ */
1880
+ get loadStats() {
1881
+ return this._loadStats;
1882
+ }
1768
1883
  /**
1769
1884
  * Returns file-level metadata from the QVD header.
1770
1885
  * @return {Object} File-level metadata properties.
@@ -2114,7 +2229,8 @@ var init_QvdDataFrame = __esm({
2114
2229
  * @param {string} path The path to the QVD file.
2115
2230
  * @param {Object} [options] Optional writing options.
2116
2231
  * @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
2232
+ * must be within this directory, with symlinks resolved first, so a link inside it that points
2233
+ * outside it is rejected. Defaults to the current working directory. To permit an entire
2118
2234
  * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2119
2235
  * back to the working directory rather than removing the restriction.
2120
2236
  * @param {Function} [options.onProgress] Optional progress callback function that receives progress updates during write operations.
@@ -2135,12 +2251,16 @@ var init_QvdDataFrame = __esm({
2135
2251
  * @param {number|null} [options.maxRows] The maximum number of rows to load. Must be a non-negative
2136
2252
  * integer; if not specified or null, all rows are loaded. Anything else throws a QvdValidationError.
2137
2253
  * @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
2254
+ * must be within this directory, with symlinks resolved first, so a link inside it that points
2255
+ * outside it is rejected. Defaults to the current working directory. To permit an entire
2139
2256
  * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2140
2257
  * back to the working directory rather than removing the restriction.
2141
- * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines what percentage
2142
- * of available memory (or V8 heap limit, whichever is smaller) can be used. Default is 0.3 (30%).
2143
- * Increase this (e.g., to 0.5 or 0.7) when running with larger heap sizes via --max-old-space-size.
2258
+ * @param {number} [options.memorySafetyFactor=0.3] Fraction (0.0-1.0) of the memory budget a load
2259
+ * may use. The budget is the smallest of the V8 heap limit, any container memory limit, and the
2260
+ * memory the OS reports as available. Default is 0.3; raise it when running with a larger heap via
2261
+ * --max-old-space-size. **Zero disables the memory check entirely.**
2262
+ * @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes, above which
2263
+ * a lazy load switches to the two-pass filtering path. Defaults to 50MB.
2144
2264
  * @throws {QvdValidationError} If options.maxRows is neither null/undefined nor a non-negative integer.
2145
2265
  * @return {Promise<QvdDataFrame>} The data frame of the QVD file.
2146
2266
  */
@@ -2148,7 +2268,8 @@ var init_QvdDataFrame = __esm({
2148
2268
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
2149
2269
  const readerOptions = {
2150
2270
  allowedDir: options.allowedDir,
2151
- memorySafetyFactor: options.memorySafetyFactor
2271
+ memorySafetyFactor: options.memorySafetyFactor,
2272
+ symbolFilteringThreshold: options.symbolFilteringThreshold
2152
2273
  };
2153
2274
  return await new QvdFileReader2(path3, readerOptions).load(options.maxRows !== void 0 ? options.maxRows : null);
2154
2275
  }