qvdjs 1.0.0 → 2.0.0

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,8 +1,9 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import crypto from 'crypto';
1
+ import fs2 from 'fs';
2
+ import path2 from 'path';
3
+ import assert3 from 'assert';
4
+ import crypto2 from 'crypto';
5
+ import { setTimeout } from 'timers/promises';
4
6
  import xml2 from 'xml2js';
5
- import assert2 from 'assert';
6
7
  import os from 'os';
7
8
  import v8 from 'v8';
8
9
  import { types } from 'util';
@@ -33,9 +34,11 @@ var init_QvdErrors = __esm({
33
34
  * @param {string} message The error message.
34
35
  * @param {string} code The error code.
35
36
  * @param {Object} [context={}] Additional context about the error.
37
+ * @param {{cause?: unknown}} [options] Passed on to `Error`, so a `cause` becomes `error.cause`: the
38
+ * error this one reports, such as the operating system's refusal behind a `QvdIOError`.
36
39
  */
37
- constructor(message, code, context = {}) {
38
- super(message);
40
+ constructor(message, code, context = {}, options = void 0) {
41
+ super(message, options);
39
42
  this.name = ERROR_NAMES.get(new.target) ?? new.target.name;
40
43
  this.code = code;
41
44
  this.context = context;
@@ -51,9 +54,10 @@ var init_QvdErrors = __esm({
51
54
  *
52
55
  * @param {string} message The error message.
53
56
  * @param {Object} [context={}] Additional context about the error.
57
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
54
58
  */
55
- constructor(message, context = {}) {
56
- super(message, "QVD_PARSE_ERROR", context);
59
+ constructor(message, context = {}, options = void 0) {
60
+ super(message, "QVD_PARSE_ERROR", context, options);
57
61
  }
58
62
  };
59
63
  QvdValidationError = class extends QvdError {
@@ -65,9 +69,10 @@ var init_QvdErrors = __esm({
65
69
  *
66
70
  * @param {string} message The error message.
67
71
  * @param {Object} [context={}] Additional context about the error.
72
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
68
73
  */
69
- constructor(message, context = {}) {
70
- super(message, "QVD_VALIDATION_ERROR", context);
74
+ constructor(message, context = {}, options = void 0) {
75
+ super(message, "QVD_VALIDATION_ERROR", context, options);
71
76
  }
72
77
  };
73
78
  QvdIOError = class extends QvdError {
@@ -79,9 +84,10 @@ var init_QvdErrors = __esm({
79
84
  *
80
85
  * @param {string} message The error message.
81
86
  * @param {Object} [context={}] Additional context about the error.
87
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
82
88
  */
83
- constructor(message, context = {}) {
84
- super(message, "QVD_IO_ERROR", context);
89
+ constructor(message, context = {}, options = void 0) {
90
+ super(message, "QVD_IO_ERROR", context, options);
85
91
  }
86
92
  };
87
93
  QvdCorruptedError = class extends QvdError {
@@ -93,9 +99,10 @@ var init_QvdErrors = __esm({
93
99
  *
94
100
  * @param {string} message The error message.
95
101
  * @param {Object} [context={}] Additional context about the error.
102
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
96
103
  */
97
- constructor(message, context = {}) {
98
- super(message, "QVD_CORRUPTED_ERROR", context);
104
+ constructor(message, context = {}, options = void 0) {
105
+ super(message, "QVD_CORRUPTED_ERROR", context, options);
99
106
  }
100
107
  };
101
108
  QvdSecurityError = class extends QvdError {
@@ -107,9 +114,10 @@ var init_QvdErrors = __esm({
107
114
  *
108
115
  * @param {string} message The error message.
109
116
  * @param {Object} [context={}] Additional context about the error.
117
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
110
118
  */
111
- constructor(message, context = {}) {
112
- super(message, "QVD_SECURITY_ERROR", context);
119
+ constructor(message, context = {}, options = void 0) {
120
+ super(message, "QVD_SECURITY_ERROR", context, options);
113
121
  }
114
122
  };
115
123
  ERROR_NAMES.set(QvdError, "QvdError");
@@ -407,6 +415,28 @@ var init_QvdDual = __esm({
407
415
  }
408
416
  });
409
417
 
418
+ // src/util/optionTypes.js
419
+ function booleanOption(value, { option, file, whenUnset }) {
420
+ if (value === void 0 || value === null) {
421
+ return whenUnset;
422
+ }
423
+ if (typeof value !== "boolean") {
424
+ throw new QvdValidationError(`${option} must be true or false`, {
425
+ option,
426
+ provided: value,
427
+ type: typeof value,
428
+ file
429
+ });
430
+ }
431
+ return value;
432
+ }
433
+ var init_optionTypes = __esm({
434
+ "src/util/optionTypes.js"() {
435
+ init_QvdErrors();
436
+ __name(booleanOption, "booleanOption");
437
+ }
438
+ });
439
+
410
440
  // src/util/readOptions.js
411
441
  function requireRowCount(value, name, filePath) {
412
442
  if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
@@ -517,18 +547,7 @@ function normaliseDuals(value, filePath) {
517
547
  return value;
518
548
  }
519
549
  function normaliseCoerceNumericStrings(value, filePath) {
520
- if (value === void 0 || value === null) {
521
- return false;
522
- }
523
- if (typeof value !== "boolean") {
524
- throw new QvdValidationError("coerceNumericStrings must be true or false", {
525
- option: "coerceNumericStrings",
526
- provided: value,
527
- type: typeof value,
528
- file: filePath
529
- });
530
- }
531
- return value;
550
+ return booleanOption(value, { option: "coerceNumericStrings", file: filePath, whenUnset: false });
532
551
  }
533
552
  function readerOptionsFrom(options) {
534
553
  return {
@@ -556,6 +575,7 @@ var DUAL_MODES;
556
575
  var init_readOptions = __esm({
557
576
  "src/util/readOptions.js"() {
558
577
  init_QvdErrors();
578
+ init_optionTypes();
559
579
  __name(requireRowCount, "requireRowCount");
560
580
  __name(normaliseWindow, "normaliseWindow");
561
581
  __name(resolveWindow, "resolveWindow");
@@ -699,24 +719,64 @@ var init_storedSymbols = __esm({
699
719
  __name(sameValueZero, "sameValueZero");
700
720
  }
701
721
  });
722
+ function readDanglingLink(target) {
723
+ let link;
724
+ try {
725
+ link = fs2.lstatSync(target);
726
+ } catch {
727
+ return NOT_A_LINK;
728
+ }
729
+ if (!link.isSymbolicLink()) {
730
+ return NOT_A_LINK;
731
+ }
732
+ let destination;
733
+ try {
734
+ destination = fs2.readlinkSync(target);
735
+ } catch {
736
+ return UNREADABLE_LINK;
737
+ }
738
+ if (path2.isAbsolute(destination)) {
739
+ return destination;
740
+ }
741
+ try {
742
+ return path2.resolve(fs2.realpathSync(path2.dirname(target)), destination);
743
+ } catch {
744
+ return UNREADABLE_LINK;
745
+ }
746
+ }
747
+ var MAX_LINK_HOPS, NOT_A_LINK, UNREADABLE_LINK;
748
+ var init_linkTarget = __esm({
749
+ "src/util/linkTarget.js"() {
750
+ MAX_LINK_HOPS = 40;
751
+ NOT_A_LINK = /* @__PURE__ */ Symbol("not a link");
752
+ UNREADABLE_LINK = /* @__PURE__ */ Symbol("unreadable link");
753
+ __name(readDanglingLink, "readDanglingLink");
754
+ }
755
+ });
702
756
  function isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) {
703
757
  const isCaseInsensitiveFS = process.platform === "win32";
704
758
  const base = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
705
759
  const target = isCaseInsensitiveFS ? resolvedPath.toLowerCase() : resolvedPath;
706
- const relative = path.relative(base, target);
760
+ const relative = path2.relative(base, target);
707
761
  if (relative === "") {
708
762
  return true;
709
763
  }
710
- if (path.isAbsolute(relative)) {
764
+ if (path2.isAbsolute(relative)) {
711
765
  return false;
712
766
  }
713
- return relative !== ".." && !relative.startsWith(`..${path.sep}`);
767
+ return relative !== ".." && !relative.startsWith(`..${path2.sep}`);
714
768
  }
715
769
  function resolveDeepestExisting(target) {
716
770
  let current = target;
771
+ let farEnd = target;
772
+ let walkedUp = false;
773
+ let followedWhileClimbing = false;
774
+ let hops = 0;
717
775
  for (; ; ) {
718
776
  try {
719
- return fs.realpathSync(current);
777
+ const deepest = fs2.realpathSync(current);
778
+ const canonicalFarEnd = walkedUp && !followedWhileClimbing ? path2.join(deepest, path2.relative(current, farEnd)) : farEnd;
779
+ return { deepest, exists: !walkedUp, farEnd: canonicalFarEnd };
720
780
  } catch (error) {
721
781
  const code = (
722
782
  /** @type {{code?: string}} */
@@ -725,10 +785,27 @@ function resolveDeepestExisting(target) {
725
785
  if (code !== "ENOENT" && code !== "ENOTDIR") {
726
786
  return null;
727
787
  }
728
- const parent = path.dirname(current);
788
+ const followed = readDanglingLink(current);
789
+ if (followed === UNREADABLE_LINK) {
790
+ return REFUSED;
791
+ }
792
+ if (followed !== NOT_A_LINK) {
793
+ if (++hops > MAX_LINK_HOPS) {
794
+ return REFUSED;
795
+ }
796
+ current = followed;
797
+ if (walkedUp) {
798
+ followedWhileClimbing = true;
799
+ } else {
800
+ farEnd = followed;
801
+ }
802
+ continue;
803
+ }
804
+ const parent = path2.dirname(current);
729
805
  if (parent === current) {
730
806
  return null;
731
807
  }
808
+ walkedUp = true;
732
809
  current = parent;
733
810
  }
734
811
  }
@@ -736,32 +813,42 @@ function resolveDeepestExisting(target) {
736
813
  function isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath) {
737
814
  let baseStat;
738
815
  try {
739
- baseStat = fs.statSync(fs.realpathSync(resolvedBaseDir));
816
+ baseStat = fs2.statSync(fs2.realpathSync(resolvedBaseDir), { bigint: true });
740
817
  } catch {
741
818
  return null;
742
819
  }
743
- let current = resolveDeepestExisting(resolvedPath);
744
- if (current === null) {
820
+ const found = resolveDeepestExisting(resolvedPath);
821
+ if (found === REFUSED) {
822
+ return { contained: false, target: resolvedPath, stats: null };
823
+ }
824
+ if (found === null) {
745
825
  return null;
746
826
  }
747
- for (; ; ) {
827
+ const target = found.exists ? found.deepest : found.farEnd;
828
+ let stats = null;
829
+ let current = found.deepest;
830
+ for (let first = true; ; first = false) {
831
+ const ownIdentity = first && found.exists;
748
832
  let stat;
749
833
  try {
750
- stat = fs.statSync(current);
834
+ stat = ownIdentity ? fs2.lstatSync(current, { bigint: true }) : fs2.statSync(current, { bigint: true });
751
835
  } catch {
752
836
  return null;
753
837
  }
838
+ if (ownIdentity) {
839
+ stats = stat;
840
+ }
754
841
  if (stat.dev === baseStat.dev && stat.ino === baseStat.ino) {
755
- return true;
842
+ return { contained: true, target, stats };
756
843
  }
757
- const parent = path.dirname(current);
844
+ const parent = path2.dirname(current);
758
845
  if (parent === current) {
759
- return false;
846
+ return { contained: false, target, stats };
760
847
  }
761
848
  current = parent;
762
849
  }
763
850
  }
764
- function validatePath(filePath, allowedDir) {
851
+ function checkPath(filePath, allowedDir) {
765
852
  if (typeof filePath !== "string" || filePath.length === 0) {
766
853
  throw new QvdValidationError("filePath must be a non-empty string", {
767
854
  provided: filePath,
@@ -780,11 +867,11 @@ function validatePath(filePath, allowedDir) {
780
867
  reason: "null_byte"
781
868
  });
782
869
  }
783
- const resolvedPath = path.resolve(filePath);
870
+ const resolvedPath = path2.resolve(filePath);
784
871
  const baseDir = allowedDir || process.cwd();
785
- const resolvedBaseDir = path.resolve(baseDir);
872
+ const resolvedBaseDir = path2.resolve(baseDir);
786
873
  const onDisk = isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath);
787
- const contained = onDisk === null ? isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) : onDisk;
874
+ const contained = onDisk === null ? isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) : onDisk.contained;
788
875
  if (!contained) {
789
876
  throw new QvdSecurityError("Path traversal detected: Access denied", {
790
877
  path: filePath,
@@ -796,15 +883,197 @@ function validatePath(filePath, allowedDir) {
796
883
  check: onDisk === null ? "lexical" : "filesystem"
797
884
  });
798
885
  }
799
- return resolvedPath;
886
+ if (onDisk === null) {
887
+ return { path: resolvedPath, base: resolvedBaseDir, target: resolvedPath, stats: null, onDisk: false };
888
+ }
889
+ return { path: resolvedPath, base: resolvedBaseDir, target: onDisk.target, stats: onDisk.stats, onDisk: true };
800
890
  }
891
+ var REFUSED;
801
892
  var init_validatePath = __esm({
802
893
  "src/util/validatePath.js"() {
803
894
  init_QvdErrors();
895
+ init_linkTarget();
804
896
  __name(isWithinDirectoryLexically, "isWithinDirectoryLexically");
897
+ REFUSED = /* @__PURE__ */ Symbol("refused");
805
898
  __name(resolveDeepestExisting, "resolveDeepestExisting");
806
899
  __name(isWithinDirectoryOnDisk, "isWithinDirectoryOnDisk");
807
- __name(validatePath, "validatePath");
900
+ __name(checkPath, "checkPath");
901
+ }
902
+ });
903
+
904
+ // src/util/ioErrors.js
905
+ function isSystemError(error) {
906
+ const candidate = (
907
+ /** @type {{code?: unknown, syscall?: unknown}|null} */
908
+ error
909
+ );
910
+ return candidate !== null && typeof candidate === "object" && typeof candidate.syscall === "string" && typeof candidate.code === "string";
911
+ }
912
+ function asIoError(error, file, direction) {
913
+ if (!isSystemError(error)) {
914
+ return error;
915
+ }
916
+ const { message, syscall, code } = (
917
+ /** @type {{message: string, syscall: string, code: string}} */
918
+ error
919
+ );
920
+ return new QvdIOError(
921
+ `Could not ${direction} the QVD file: ${message}`,
922
+ { file, operation: syscall, code },
923
+ { cause: error }
924
+ );
925
+ }
926
+ function rethrowAsIoError(file, direction) {
927
+ return (error) => {
928
+ throw asIoError(error, file, direction);
929
+ };
930
+ }
931
+ async function closeAfter(handle, failed, use) {
932
+ let result;
933
+ try {
934
+ result = await use();
935
+ } catch (error) {
936
+ await handle.close().catch(() => {
937
+ });
938
+ throw error;
939
+ }
940
+ await handle.close().catch(failed);
941
+ return result;
942
+ }
943
+ var init_ioErrors = __esm({
944
+ "src/util/ioErrors.js"() {
945
+ init_QvdErrors();
946
+ __name(isSystemError, "isSystemError");
947
+ __name(asIoError, "asIoError");
948
+ __name(rethrowAsIoError, "rethrowAsIoError");
949
+ __name(closeAfter, "closeAfter");
950
+ }
951
+ });
952
+ function changedAfterCheck(checked, change) {
953
+ return new QvdSecurityError(`Path traversal detected: ${DESCRIPTIONS[change]}`, {
954
+ path: checked.path,
955
+ resolvedPath: checked.target,
956
+ allowedDir: checked.base,
957
+ reason: "changed_after_check",
958
+ change
959
+ });
960
+ }
961
+ async function openChecked(checked, purpose, failed, { nofollow = NOFOLLOW } = {}) {
962
+ assert3(purpose === "read" || checked.stats !== null, "A rewrite in place is of a file that exists.");
963
+ const noFollow = checked.onDisk ? nofollow : 0;
964
+ const flags = (purpose === "rewrite" ? O_WRONLY : O_RDONLY) | noFollow;
965
+ let handle;
966
+ try {
967
+ handle = await fs2.promises.open(checked.target, flags);
968
+ } catch (error) {
969
+ const { code } = (
970
+ /** @type {{code?: string}} */
971
+ error ?? {}
972
+ );
973
+ if (checked.onDisk && noFollow !== 0 && code !== void 0 && BECAME_A_LINK.has(code)) {
974
+ throw changedAfterCheck(checked, "became_a_symlink");
975
+ }
976
+ return failed(error);
977
+ }
978
+ const stats = checked.stats;
979
+ if (stats === null) {
980
+ if (checked.onDisk) {
981
+ await handle.close().catch(() => {
982
+ });
983
+ throw changedAfterCheck(checked, "appeared");
984
+ }
985
+ return handle;
986
+ }
987
+ try {
988
+ const opened = await handle.stat({ bigint: true }).catch(failed);
989
+ if (opened.dev !== stats.dev || opened.ino !== stats.ino) {
990
+ throw changedAfterCheck(checked, "replaced");
991
+ }
992
+ if (purpose === "rewrite" && stats.isFile()) {
993
+ await handle.truncate(0).catch(failed);
994
+ }
995
+ } catch (error) {
996
+ await handle.close().catch(() => {
997
+ });
998
+ throw error;
999
+ }
1000
+ return handle;
1001
+ }
1002
+ var NOFOLLOW, O_RDONLY, O_WRONLY, BECAME_A_LINK, DESCRIPTIONS;
1003
+ var init_openChecked = __esm({
1004
+ "src/util/openChecked.js"() {
1005
+ init_QvdErrors();
1006
+ NOFOLLOW = fs2.constants.O_NOFOLLOW ?? 0;
1007
+ ({ O_RDONLY, O_WRONLY } = fs2.constants);
1008
+ BECAME_A_LINK = /* @__PURE__ */ new Set(["ELOOP", "EMLINK"]);
1009
+ DESCRIPTIONS = {
1010
+ became_a_symlink: "the file became a symbolic link after it was checked",
1011
+ replaced: "the file was replaced by a different one after it was checked",
1012
+ appeared: "a file appeared at the path after it was checked"
1013
+ };
1014
+ __name(changedAfterCheck, "changedAfterCheck");
1015
+ __name(openChecked, "openChecked");
1016
+ }
1017
+ });
1018
+ function firstBytesOf(name, budget) {
1019
+ if (Buffer.byteLength(name) <= budget) {
1020
+ return name;
1021
+ }
1022
+ let kept = "";
1023
+ let bytes = 0;
1024
+ for (const character of name) {
1025
+ const size = Buffer.byteLength(character);
1026
+ if (bytes + size > budget) {
1027
+ break;
1028
+ }
1029
+ kept += character;
1030
+ bytes += size;
1031
+ }
1032
+ return kept;
1033
+ }
1034
+ function temporaryPathFor(target) {
1035
+ const marks = `.qvdjs-${crypto2.randomBytes(8).toString("hex")}.tmp`;
1036
+ const name = `${firstBytesOf(path2.basename(target), MAX_NAME_BYTES - marks.length)}${marks}`;
1037
+ return path2.join(path2.dirname(target), name);
1038
+ }
1039
+ async function retrying(call, { platform = process.platform, delays = RETRY_DELAYS } = {}) {
1040
+ for (let attempt = 0; ; attempt++) {
1041
+ try {
1042
+ return await call();
1043
+ } catch (error) {
1044
+ const { code } = (
1045
+ /** @type {{code?: string}} */
1046
+ error ?? {}
1047
+ );
1048
+ if (platform !== "win32" || attempt >= delays.length || code === void 0 || !IN_USE.has(code)) {
1049
+ throw error;
1050
+ }
1051
+ await setTimeout(delays[attempt]);
1052
+ }
1053
+ }
1054
+ }
1055
+ function renameOver(from, to, options) {
1056
+ return retrying(() => fs2.promises.rename(from, to), options);
1057
+ }
1058
+ async function removeTemporary(file, options) {
1059
+ try {
1060
+ await retrying(() => fs2.promises.rm(file, { force: true }), options);
1061
+ return true;
1062
+ } catch {
1063
+ return false;
1064
+ }
1065
+ }
1066
+ var MAX_NAME_BYTES, IN_USE, RETRY_DELAYS;
1067
+ var init_replaceFile = __esm({
1068
+ "src/util/replaceFile.js"() {
1069
+ MAX_NAME_BYTES = 255;
1070
+ __name(firstBytesOf, "firstBytesOf");
1071
+ __name(temporaryPathFor, "temporaryPathFor");
1072
+ IN_USE = /* @__PURE__ */ new Set(["EPERM", "EACCES", "EBUSY"]);
1073
+ RETRY_DELAYS = [10, 20, 40, 80, 160, 320];
1074
+ __name(retrying, "retrying");
1075
+ __name(renameOver, "renameOver");
1076
+ __name(removeTemporary, "removeTemporary");
808
1077
  }
809
1078
  });
810
1079
 
@@ -1134,11 +1403,15 @@ function resetContradictedNumberFormat(numberFormat, facts) {
1134
1403
  }
1135
1404
  return numberFormat;
1136
1405
  }
1137
- var OBJECT_MEMO_BASE, NUMERIC_TAGS, TEXT_TAGS, WHOLE_NUMBER_TAGS, NUMERIC_FORMATS, UNKNOWN_NUMBER_FORMAT, QvdFileWriter;
1406
+ var OBJECT_MEMO_BASE, NUMERIC_TAGS, TEXT_TAGS, WHOLE_NUMBER_TAGS, NUMERIC_FORMATS, UNKNOWN_NUMBER_FORMAT, WRITE_CHUNK_SIZE, QvdFileWriter;
1138
1407
  var init_QvdFileWriter = __esm({
1139
1408
  "src/QvdFileWriter.js"() {
1140
1409
  init_QvdErrors();
1141
1410
  init_validatePath();
1411
+ init_ioErrors();
1412
+ init_openChecked();
1413
+ init_replaceFile();
1414
+ init_optionTypes();
1142
1415
  init_bitUtils();
1143
1416
  init_cellRules();
1144
1417
  init_symbolBytes();
@@ -1165,6 +1438,7 @@ var init_QvdFileWriter = __esm({
1165
1438
  UNKNOWN_NUMBER_FORMAT = Object.freeze({ Type: "UNKNOWN", nDec: "0", UseThou: "0", Fmt: "", Dec: "", Thou: "" });
1166
1439
  __name(pruneContradictedTags, "pruneContradictedTags");
1167
1440
  __name(resetContradictedNumberFormat, "resetContradictedNumberFormat");
1441
+ WRITE_CHUNK_SIZE = 512 * 1024 * 1024;
1168
1442
  QvdFileWriter = class {
1169
1443
  static {
1170
1444
  __name(this, "QvdFileWriter");
@@ -1181,10 +1455,30 @@ var init_QvdFileWriter = __esm({
1181
1455
  * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
1182
1456
  * empty value falls back to the working directory rather than removing the restriction.
1183
1457
  * @param {Function} [options.onProgress] Optional progress callback function.
1458
+ * @param {boolean} [options.atomic=true] Whether to replace the destination rather than rewrite it:
1459
+ * the file is built beside it under a temporary name and renamed over it, so a failure leaves the
1460
+ * previous file exactly as it was and nothing ever reads a part-written one. False rewrites the
1461
+ * destination in place, which is what every version before 2.0.0 did: that keeps the file's
1462
+ * identity - hard links, its owner, permissions set on the file itself - needs no room for two
1463
+ * copies at once and no permission to create files in the directory, and destroys the previous
1464
+ * file the moment the write begins. A file that does not exist yet is renamed into place either
1465
+ * way, since there is nothing to rewrite - see `_destination`.
1466
+ * @param {boolean} [options.fsync=true] Whether to wait for the file's contents to reach the disk
1467
+ * before the write is finished. It is what carries an atomic write's promise through a power
1468
+ * loss - the contents are on the disk before anything is renamed, so a crash leaves the previous
1469
+ * file or the new one and never a damaged one - and it is the only thing that reports a failing
1470
+ * disk's deferred error rather than losing it. The rename itself is not flushed, so a crash just
1471
+ * after the call can still lose the replacement, or a file that did not exist before. False
1472
+ * resolves as soon as the operating system has accepted the bytes, which is faster and is what
1473
+ * every version before 2.0.0 did.
1184
1474
  */
1185
1475
  constructor(filePath, df, options = {}) {
1186
- const { allowedDir, onProgress } = options;
1187
- this._path = validatePath(filePath, allowedDir);
1476
+ const { allowedDir, onProgress, atomic, fsync } = options;
1477
+ const checked = checkPath(filePath, allowedDir);
1478
+ this._path = checked.path;
1479
+ this._allowedDir = checked.base;
1480
+ this._atomic = booleanOption(atomic, { option: "atomic", file: this._path, whenUnset: true });
1481
+ this._fsync = booleanOption(fsync, { option: "fsync", file: this._path, whenUnset: true });
1188
1482
  this._df = df;
1189
1483
  this._onProgress = onProgress;
1190
1484
  this._header = null;
@@ -1220,22 +1514,168 @@ var init_QvdFileWriter = __esm({
1220
1514
  * Writes the data to the QVD file.
1221
1515
  */
1222
1516
  async _writeData() {
1223
- assert2(this._header, "The QVD file header has not been parsed.");
1224
- assert2(this._symbolBuffer, "The QVD file symbol table has not been parsed.");
1225
- assert2(this._indexBuffer, "The QVD file index table has not been parsed.");
1517
+ assert3(this._header, "The QVD file header has not been parsed.");
1518
+ assert3(this._symbolBuffer, "The QVD file symbol table has not been parsed.");
1519
+ assert3(this._indexBuffer, "The QVD file index table has not been parsed.");
1226
1520
  this._emitProgress("write", 0, 1);
1227
1521
  const headerBuffer = Buffer.concat([Buffer.from(this._header, "utf-8"), Buffer.from([0])]);
1228
- let fd;
1522
+ const failed = rethrowAsIoError(this._path, "write");
1523
+ const destination = this._destination();
1524
+ if (destination.replace) {
1525
+ await this._replaceFile(destination, headerBuffer, failed);
1526
+ } else {
1527
+ await this._writeInPlace(destination, headerBuffer, failed);
1528
+ }
1529
+ this._emitProgress("write", 1, 1);
1530
+ }
1531
+ /**
1532
+ * What is at the destination, and therefore how it is to be written.
1533
+ *
1534
+ * Decided by one containment check, made now - immediately before anything is opened - and by
1535
+ * nothing else: the file that check approved is the file written, in either mode, and what the
1536
+ * check found there is what decides how.
1537
+ *
1538
+ * It used to stat and resolve the path again for itself, after the check. That second resolution
1539
+ * was #247 in the atomic write: a destination swapped for a symlink after the check was followed by
1540
+ * it, so the temporary file was built beside a file outside allowedDir and renamed over it. A check
1541
+ * reports where it went, so nothing here has to go there again.
1542
+ *
1543
+ * @return {{checked: import('./util/validatePath.js').CheckedPath, path: string,
1544
+ * existing: import('fs').BigIntStats|null, replace: boolean, sync: boolean}} The check, the file it
1545
+ * approved, what was there, whether to replace it rather than rewrite it, and whether to flush it.
1546
+ * @private
1547
+ */
1548
+ _destination() {
1549
+ const checked = checkPath(this._path, this._allowedDir);
1550
+ const existing = checked.stats;
1551
+ const special = existing !== null && !existing.isFile();
1552
+ const replace = (this._atomic || existing === null) && !special;
1553
+ return {
1554
+ checked,
1555
+ path: checked.target,
1556
+ existing,
1557
+ replace,
1558
+ sync: this._fsync && !special
1559
+ };
1560
+ }
1561
+ /**
1562
+ * Rewrites the destination where it stands - the way every version before 2.0.0 wrote.
1563
+ *
1564
+ * The file is emptied as the write begins, so from there until the last byte is written there is no
1565
+ * previous version left: a failure part-way through leaves a stub that no read of its rows survives,
1566
+ * and anything reading the path meanwhile sees however much of the new file has arrived.
1567
+ *
1568
+ * It is emptied by `openChecked` rather than by opening with `'w'`, and later than `'w'` would: once
1569
+ * the descriptor is known to be the file the check approved. `'w'` empties whatever the open reaches,
1570
+ * which is what let #247 truncate a file outside allowedDir through a symlink swapped in after the
1571
+ * check. The file that results is the same.
1572
+ *
1573
+ * @param {{checked: import('./util/validatePath.js').CheckedPath, sync: boolean}} destination Where
1574
+ * to write, from `_destination`.
1575
+ * @param {Buffer} headerBuffer The header and its terminator.
1576
+ * @param {(error: unknown) => never} failed The write's `rethrowAsIoError` handler.
1577
+ * @private
1578
+ */
1579
+ async _writeInPlace(destination, headerBuffer, failed) {
1580
+ const fd = await openChecked(destination.checked, "rewrite", failed);
1581
+ await closeAfter(fd, failed, async () => {
1582
+ await this._writeParts(fd, headerBuffer, failed);
1583
+ if (destination.sync) {
1584
+ await fd.sync().catch(failed);
1585
+ }
1586
+ });
1587
+ }
1588
+ /**
1589
+ * Builds the file beside the destination and renames it over it.
1590
+ *
1591
+ * Nothing touches the destination until the rename, which either replaces it or leaves it as it was,
1592
+ * so a write that fails at any step - a full disk, a process killed, an error from the disk itself -
1593
+ * costs the temporary file and nothing else. A reader of the path gets the previous file or the new
1594
+ * one, never a part of either, which is the other half of what the old behaviour could not promise.
1595
+ *
1596
+ * @param {{path: string, existing: import('fs').BigIntStats|null, sync: boolean}} destination Where to
1597
+ * write, from `_destination`.
1598
+ * @param {Buffer} headerBuffer The header and its terminator.
1599
+ * @param {(error: unknown) => never} failed The write's `rethrowAsIoError` handler.
1600
+ * @private
1601
+ */
1602
+ async _replaceFile(destination, headerBuffer, failed) {
1603
+ const temporary = temporaryPathFor(destination.path);
1604
+ const mode = destination.existing === null ? void 0 : 384;
1605
+ const fd = await fs2.promises.open(temporary, "wx", mode).catch(failed);
1229
1606
  try {
1230
- fd = await fs.promises.open(this._path, "w");
1231
- await fd.write(headerBuffer, 0, headerBuffer.length, 0);
1232
- await fd.write(this._symbolBuffer, 0, this._symbolBuffer.length, headerBuffer.length);
1233
- await fd.write(this._indexBuffer, 0, this._indexBuffer.length, headerBuffer.length + this._symbolBuffer.length);
1234
- this._emitProgress("write", 1, 1);
1235
- } finally {
1236
- if (fd) {
1237
- await fd.close();
1607
+ await closeAfter(fd, failed, async () => {
1608
+ await this._writeParts(fd, headerBuffer, failed);
1609
+ if (destination.existing !== null && process.platform !== "win32") {
1610
+ await fd.chmod(Number(destination.existing.mode) & 511).catch(failed);
1611
+ }
1612
+ if (destination.sync) {
1613
+ await fd.sync().catch(failed);
1614
+ }
1615
+ });
1616
+ await renameOver(temporary, destination.path).catch(failed);
1617
+ } catch (error) {
1618
+ const removed = await removeTemporary(temporary);
1619
+ const context = (
1620
+ /** @type {{context?: Record<string, unknown>}} */
1621
+ error?.context
1622
+ );
1623
+ if (!removed && context !== null && typeof context === "object" && Object.isExtensible(context)) {
1624
+ context.temporaryFile = temporary;
1238
1625
  }
1626
+ throw error;
1627
+ }
1628
+ }
1629
+ /**
1630
+ * Writes the three parts of a QVD, in their order, into an open file.
1631
+ *
1632
+ * @param {import('fs/promises').FileHandle} fd The open file.
1633
+ * @param {Buffer} headerBuffer The header and its terminator.
1634
+ * @param {(error: unknown) => never} failed The write's `rethrowAsIoError` handler.
1635
+ * @private
1636
+ */
1637
+ async _writeParts(fd, headerBuffer, failed) {
1638
+ await this._writeRange(fd, headerBuffer, 0, failed);
1639
+ await this._writeRange(fd, this._symbolBuffer, headerBuffer.length, failed);
1640
+ await this._writeRange(fd, this._indexBuffer, headerBuffer.length + this._symbolBuffer.length, failed);
1641
+ }
1642
+ /**
1643
+ * Writes the whole of one buffer into the file, starting at `filePosition`.
1644
+ *
1645
+ * A write can resolve having written less than it was given, and that is all a disk that fills
1646
+ * part-way through one says. libuv retries a short write(2) itself, and when the retry fails it
1647
+ * returns the bytes that did land instead of the error - `uv__fs_write_all` in its `src/unix/fs.c`,
1648
+ * and `fs__write` on Windows does the same - so Node resolves with a short `bytesWritten` and never
1649
+ * rejects. Taking that for the whole write is how `toQvd()` resolved on a full disk and left a
1650
+ * truncated file: the index table, the last of the three writes, stopped part-way, and no later call
1651
+ * asked the disk again.
1652
+ *
1653
+ * So the rest is written until there is none, and it is the next call that reports the failure: the
1654
+ * disk refuses it outright, and that rejection is a QvdIOError with the system's code, ENOSPC, like
1655
+ * any other refused write. A write that stores nothing and reports nothing would repeat forever, so
1656
+ * it is a failure too, the one QvdIOError with no system code to carry.
1657
+ *
1658
+ * In bounded chunks as well, because Node refuses a single write of 2 GiB or more.
1659
+ *
1660
+ * @param {import('fs/promises').FileHandle} fd The open file.
1661
+ * @param {Buffer} buffer What to write.
1662
+ * @param {number} filePosition Where in the file it starts.
1663
+ * @param {(error: unknown) => never} failed The write's `rethrowAsIoError` handler, which every call
1664
+ * goes through.
1665
+ * @private
1666
+ */
1667
+ async _writeRange(fd, buffer, filePosition, failed) {
1668
+ let done = 0;
1669
+ while (done < buffer.length) {
1670
+ const length = Math.min(WRITE_CHUNK_SIZE, buffer.length - done);
1671
+ const { bytesWritten } = await fd.write(buffer, done, length, filePosition + done).catch(failed);
1672
+ if (!(bytesWritten > 0)) {
1673
+ throw new QvdIOError(
1674
+ `Could not write the QVD file: nothing was written at byte ${filePosition + done}, and the operating system reported no error.`,
1675
+ { file: this._path, operation: "write", filePosition: filePosition + done }
1676
+ );
1677
+ }
1678
+ done += bytesWritten;
1239
1679
  }
1240
1680
  }
1241
1681
  /**
@@ -1247,13 +1687,13 @@ var init_QvdFileWriter = __esm({
1247
1687
  const existingMetadata = this._df.metadata;
1248
1688
  const baseMetadata = existingMetadata ? {
1249
1689
  QvBuildNo: existingMetadata.QvBuildNo || 50667,
1250
- CreatorDoc: existingMetadata.CreatorDoc || crypto.randomUUID(),
1690
+ CreatorDoc: existingMetadata.CreatorDoc || crypto2.randomUUID(),
1251
1691
  CreateUtcTime: existingMetadata.CreateUtcTime || creationDate,
1252
1692
  SourceCreateUtcTime: existingMetadata.SourceCreateUtcTime || "",
1253
1693
  SourceFileUtcTime: existingMetadata.SourceFileUtcTime || "",
1254
1694
  SourceFileSize: existingMetadata.SourceFileSize || -1,
1255
1695
  StaleUtcTime: existingMetadata.StaleUtcTime || "",
1256
- TableName: existingMetadata.TableName || path.basename(this._path, path.extname(this._path)),
1696
+ TableName: existingMetadata.TableName || path2.basename(this._path, path2.extname(this._path)),
1257
1697
  Compression: existingMetadata.Compression || "",
1258
1698
  Comment: existingMetadata.Comment || "",
1259
1699
  EncryptionInfo: existingMetadata.EncryptionInfo || "",
@@ -1267,13 +1707,13 @@ var init_QvdFileWriter = __esm({
1267
1707
  }
1268
1708
  } : {
1269
1709
  QvBuildNo: 50667,
1270
- CreatorDoc: crypto.randomUUID(),
1710
+ CreatorDoc: crypto2.randomUUID(),
1271
1711
  CreateUtcTime: creationDate,
1272
1712
  SourceCreateUtcTime: "",
1273
1713
  SourceFileUtcTime: "",
1274
1714
  SourceFileSize: -1,
1275
1715
  StaleUtcTime: "",
1276
- TableName: path.basename(this._path, path.extname(this._path)),
1716
+ TableName: path2.basename(this._path, path2.extname(this._path)),
1277
1717
  Compression: "",
1278
1718
  Comment: "",
1279
1719
  EncryptionInfo: "",
@@ -1524,7 +1964,7 @@ var init_QvdFileWriter = __esm({
1524
1964
  const key = keys[slot];
1525
1965
  offset = typeof key === "number" ? writeSymbol(columnBuffer, offset, kinds[slot], key, texts[slot]) : writeSymbol(columnBuffer, offset, kinds[slot], null, key);
1526
1966
  }
1527
- assert2(offset === byteLength, "A column was encoded into a different number of bytes than it was sized for.");
1967
+ assert3(offset === byteLength, "A column was encoded into a different number of bytes than it was sized for.");
1528
1968
  columnBuffers.push(columnBuffer);
1529
1969
  this._symbolTableMetadata?.push([symbolsOffset, byteLength, containsNull[column]]);
1530
1970
  this._symbolCounts?.push(keys.length);
@@ -1563,9 +2003,9 @@ var init_QvdFileWriter = __esm({
1563
2003
  * @private
1564
2004
  */
1565
2005
  _buildIndexTable() {
1566
- assert2(this._symbolCounts, "The QVD file symbol table has not been built.");
1567
- assert2(this._symbolTableMetadata, "The QVD file symbol table metadata has not been built.");
1568
- assert2(this._symbolIndexByValue, "The QVD file symbol index has not been built.");
2006
+ assert3(this._symbolCounts, "The QVD file symbol table has not been built.");
2007
+ assert3(this._symbolTableMetadata, "The QVD file symbol table metadata has not been built.");
2008
+ assert3(this._symbolIndexByValue, "The QVD file symbol index has not been built.");
1569
2009
  this._indexTableMetadata = [];
1570
2010
  const columns = this._df.columns;
1571
2011
  const data = this._df.data;
@@ -2684,9 +3124,9 @@ var init_QvdColumnTable = __esm({
2684
3124
  * read switches to two-pass filtering.
2685
3125
  * @return {Promise<QvdColumnTable>} The file, as columns.
2686
3126
  */
2687
- static async fromQvd(path3, options = {}) {
3127
+ static async fromQvd(path5, options = {}) {
2688
3128
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
2689
- const reader = new QvdFileReader2(path3, {
3129
+ const reader = new QvdFileReader2(path5, {
2690
3130
  ...readerOptionsFrom(options),
2691
3131
  // This read builds no rows, so the memory guard must not charge it for them. A columnar
2692
3132
  // read of the 38MB taxi fixture completes in a 15MB heap; charged the row cost it was
@@ -2754,15 +3194,17 @@ var QvdFileReader_exports = {};
2754
3194
  __export(QvdFileReader_exports, {
2755
3195
  QvdFileReader: () => QvdFileReader
2756
3196
  });
2757
- function closeReadStream(stream) {
2758
- if (stream.closed) {
2759
- return Promise.resolve();
2760
- }
2761
- return new Promise((resolve) => {
2762
- stream.once("close", () => resolve());
2763
- stream.once("error", () => resolve());
2764
- stream.destroy();
2765
- });
3197
+ async function* chunksFrom(handle, chunkSize, failed) {
3198
+ let position = 0;
3199
+ for (; ; ) {
3200
+ const buffer = Buffer.alloc(chunkSize);
3201
+ const { bytesRead } = await handle.read(buffer, 0, chunkSize, position).catch(failed);
3202
+ if (bytesRead === 0) {
3203
+ return;
3204
+ }
3205
+ yield bytesRead === chunkSize ? buffer : Buffer.from(buffer.subarray(0, bytesRead));
3206
+ position += bytesRead;
3207
+ }
2766
3208
  }
2767
3209
  var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS, QvdFileReader;
2768
3210
  var init_QvdFileReader = __esm({
@@ -2770,6 +3212,8 @@ var init_QvdFileReader = __esm({
2770
3212
  init_QvdDataFrame();
2771
3213
  init_QvdErrors();
2772
3214
  init_validatePath();
3215
+ init_openChecked();
3216
+ init_ioErrors();
2773
3217
  init_bitUtils();
2774
3218
  init_memoryUtils();
2775
3219
  init_validationUtils();
@@ -2780,7 +3224,7 @@ var init_QvdFileReader = __esm({
2780
3224
  MAX_HEADER_SIZE = 16 * 1024 * 1024;
2781
3225
  READ_CHUNK_SIZE = 512 * 1024 * 1024;
2782
3226
  ANALYSIS_SLICE_ROWS = 65536;
2783
- __name(closeReadStream, "closeReadStream");
3227
+ __name(chunksFrom, "chunksFrom");
2784
3228
  QvdFileReader = class {
2785
3229
  static {
2786
3230
  __name(this, "QvdFileReader");
@@ -2841,7 +3285,9 @@ var init_QvdFileReader = __esm({
2841
3285
  signal
2842
3286
  } = options;
2843
3287
  this._materialisesRows = materialisesRows;
2844
- this._path = validatePath(filePath, allowedDir);
3288
+ const checked = checkPath(filePath, allowedDir);
3289
+ this._path = checked.path;
3290
+ this._allowedDir = checked.base;
2845
3291
  this._duals = normaliseDuals(duals, this._path);
2846
3292
  this._coerceNumericStrings = normaliseCoerceNumericStrings(coerceNumericStrings, this._path);
2847
3293
  this._memorySafetyFactor = memorySafetyFactor;
@@ -2926,18 +3372,21 @@ var init_QvdFileReader = __esm({
2926
3372
  * two-pass path exist for.
2927
3373
  *
2928
3374
  * Algorithm for a windowed read:
2929
- * 1. Stream-read the file until XML header delimiter is found
3375
+ * 1. Read the file a chunk at a time until the XML header delimiter is found
2930
3376
  * 2. Parse header to determine symbol table and index table locations
2931
3377
  * 3. Calculate bytes needed: header + full symbol table + partial index table
2932
- * 4. Read only those calculated bytes using fs.open/read
3378
+ * 4. Read only those calculated bytes, by position
2933
3379
  * 5. Rest of parsing proceeds normally with limited data
2934
3380
  *
2935
3381
  * WHY THIS APPROACH:
2936
3382
  * - Symbol table must be fully loaded (contains all unique values)
2937
3383
  * - Index table can be partially loaded (only rows we need)
2938
- * - Streaming for header finding is efficient for unknown header sizes
3384
+ * - Reading chunks to find the header is efficient for unknown header sizes
2939
3385
  * - Direct byte-range reading for remaining data is fastest
2940
3386
  *
3387
+ * All of it goes through one handle, opened once, on the file the containment check approved. See
3388
+ * `chunksFrom` and `openChecked` for why a read no longer opens the path more than once.
3389
+ *
2941
3390
  * A window with a non-zero `offset` reads two ranges rather than one: the header and symbol
2942
3391
  * table from the front of the file, and the window's records from wherever they sit. The bytes
2943
3392
  * between are never read, which is what makes `{offset: 1_700_000, limit: 100}` on the taxi
@@ -2955,49 +3404,49 @@ var init_QvdFileReader = __esm({
2955
3404
  async _readData(window = { offset: 0, limit: null }, headerOnly = false, liveRows = null) {
2956
3405
  this._throwIfAborted();
2957
3406
  this._emitProgress("read", 0, 1);
3407
+ const failed = rethrowAsIoError(this._path, "read");
3408
+ const handle = await openChecked(checkPath(this._path, this._allowedDir), "read", failed);
3409
+ await closeAfter(handle, failed, () => this._readFrom(handle, window, headerOnly, liveRows, failed));
3410
+ }
3411
+ /**
3412
+ * Reads what `_readData` was asked for, through the handle it opened.
3413
+ *
3414
+ * @param {import('fs/promises').FileHandle} handle The open file.
3415
+ * @param {QvdRowWindow} window The rows to read.
3416
+ * @param {boolean} headerOnly Stop once the XML header has been read.
3417
+ * @param {{rows: number, perChunk: number}|null} liveRows Rows held at one instant - see `_prepare`.
3418
+ * @param {(error: unknown) => never} failed The read's `rethrowAsIoError` handler.
3419
+ * @private
3420
+ */
3421
+ async _readFrom(handle, window, headerOnly, liveRows, failed) {
2958
3422
  const HEADER_DELIMITER = "\r\n\0";
2959
3423
  const CHUNK_SIZE = 64 * 1024;
2960
- const stream = fs.createReadStream(this._path, {
2961
- highWaterMark: CHUNK_SIZE
2962
- });
2963
3424
  const headerChunks = [];
2964
3425
  let headerBytes = 0;
2965
3426
  let tail = Buffer.alloc(0);
2966
3427
  let headerDelimiterIndex = -1;
2967
- try {
2968
- for await (const chunk of stream) {
2969
- const chunkStart = headerBytes;
2970
- const searchBuffer = tail.length > 0 ? Buffer.concat([tail, chunk]) : chunk;
2971
- const foundInSearch = searchBuffer.indexOf(HEADER_DELIMITER);
2972
- headerChunks.push(chunk);
2973
- headerBytes += chunk.length;
2974
- if (foundInSearch !== -1) {
2975
- headerDelimiterIndex = chunkStart - tail.length + foundInSearch;
2976
- stream.destroy();
2977
- break;
2978
- }
2979
- tail = Buffer.from(searchBuffer.subarray(-(HEADER_DELIMITER.length - 1)));
2980
- if (headerBytes > MAX_HEADER_SIZE) {
2981
- stream.destroy();
2982
- throw new QvdCorruptedError(
2983
- `The XML header delimiter was not found within the first ${MAX_HEADER_SIZE / (1024 * 1024)}MB of the file.`,
2984
- {
2985
- file: this._path,
2986
- bytesSearched: headerBytes,
2987
- maxHeaderSize: MAX_HEADER_SIZE,
2988
- stage: "readData"
2989
- }
2990
- );
2991
- }
3428
+ for await (const chunk of chunksFrom(handle, CHUNK_SIZE, failed)) {
3429
+ const chunkStart = headerBytes;
3430
+ const searchBuffer = tail.length > 0 ? Buffer.concat([tail, chunk]) : chunk;
3431
+ const foundInSearch = searchBuffer.indexOf(HEADER_DELIMITER);
3432
+ headerChunks.push(chunk);
3433
+ headerBytes += chunk.length;
3434
+ if (foundInSearch !== -1) {
3435
+ headerDelimiterIndex = chunkStart - tail.length + foundInSearch;
3436
+ break;
2992
3437
  }
2993
- } catch (error) {
2994
- const isExpectedEarlyClose = headerDelimiterIndex !== -1 && error !== null && typeof error === "object" && /** @type {{code?: unknown}} */
2995
- error.code === "ERR_STREAM_PREMATURE_CLOSE";
2996
- if (!isExpectedEarlyClose) {
2997
- throw error;
3438
+ tail = Buffer.from(searchBuffer.subarray(-(HEADER_DELIMITER.length - 1)));
3439
+ if (headerBytes > MAX_HEADER_SIZE) {
3440
+ throw new QvdCorruptedError(
3441
+ `The XML header delimiter was not found within the first ${MAX_HEADER_SIZE / (1024 * 1024)}MB of the file.`,
3442
+ {
3443
+ file: this._path,
3444
+ bytesSearched: headerBytes,
3445
+ maxHeaderSize: MAX_HEADER_SIZE,
3446
+ stage: "readData"
3447
+ }
3448
+ );
2998
3449
  }
2999
- } finally {
3000
- await closeReadStream(stream);
3001
3450
  }
3002
3451
  if (headerDelimiterIndex === -1) {
3003
3452
  throw new QvdCorruptedError(
@@ -3038,9 +3487,9 @@ var init_QvdFileReader = __esm({
3038
3487
  (value) => Number.isSafeInteger(value) && value >= 0
3039
3488
  );
3040
3489
  if (headerNumbersUsable) {
3041
- const { size: fileSize } = await fs.promises.stat(this._path);
3042
- this._fileSize = fileSize;
3043
- this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
3490
+ const { size: fileSize2 } = await handle.stat().catch(failed);
3491
+ this._fileSize = fileSize2;
3492
+ this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize2;
3044
3493
  }
3045
3494
  const resolved = headerNumbersUsable ? resolveWindow(window, totalRows) : { offset: 0, limit: 0 };
3046
3495
  const windowRows = resolved.limit;
@@ -3057,7 +3506,7 @@ var init_QvdFileReader = __esm({
3057
3506
  );
3058
3507
  }
3059
3508
  if (window.offset === 0 && window.limit === null) {
3060
- this._buffer = await fs.promises.readFile(this._path);
3509
+ this._buffer = await handle.readFile().catch(failed);
3061
3510
  this._fileSize = this._buffer.length;
3062
3511
  this._bufferFirstRow = 0;
3063
3512
  this._emitProgress("read", 1, 1);
@@ -3083,34 +3532,29 @@ var init_QvdFileReader = __esm({
3083
3532
  const indexTableBytesToRead = rowsToLoad * recordSize;
3084
3533
  const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
3085
3534
  const fileBytesRequired = indexTableOffset + skippedIndexBytes + indexTableBytesToRead;
3086
- const fd = await fs.promises.open(this._path, "r");
3087
- try {
3088
- const { size: fileSize } = await fd.stat();
3089
- this._fileSize = fileSize;
3090
- if (fileBytesRequired > fileSize) {
3091
- throw new QvdCorruptedError("The file is shorter than its header claims.", {
3092
- file: this._path,
3093
- fileSize,
3094
- requiredBytes: fileBytesRequired,
3095
- stage: "readData"
3096
- });
3097
- }
3098
- this._buffer = Buffer.alloc(totalBytesToRead);
3099
- await this._readRange(fd, 0, indexTableOffset, 0, fileSize, totalBytesToRead);
3100
- if (indexTableBytesToRead > 0) {
3101
- await this._readRange(
3102
- fd,
3103
- indexTableOffset,
3104
- indexTableBytesToRead,
3105
- indexTableOffset + skippedIndexBytes,
3106
- fileSize,
3107
- fileBytesRequired
3108
- );
3109
- }
3110
- this._bufferFirstRow = resolved.offset;
3111
- } finally {
3112
- await fd.close();
3535
+ const { size: fileSize } = await handle.stat().catch(failed);
3536
+ this._fileSize = fileSize;
3537
+ if (fileBytesRequired > fileSize) {
3538
+ throw new QvdCorruptedError("The file is shorter than its header claims.", {
3539
+ file: this._path,
3540
+ fileSize,
3541
+ requiredBytes: fileBytesRequired,
3542
+ stage: "readData"
3543
+ });
3544
+ }
3545
+ this._buffer = Buffer.alloc(totalBytesToRead);
3546
+ await this._readRange(handle, 0, indexTableOffset, 0, fileSize, totalBytesToRead);
3547
+ if (indexTableBytesToRead > 0) {
3548
+ await this._readRange(
3549
+ handle,
3550
+ indexTableOffset,
3551
+ indexTableBytesToRead,
3552
+ indexTableOffset + skippedIndexBytes,
3553
+ fileSize,
3554
+ fileBytesRequired
3555
+ );
3113
3556
  }
3557
+ this._bufferFirstRow = resolved.offset;
3114
3558
  this._emitProgress("read", 1, 1);
3115
3559
  }
3116
3560
  /**
@@ -3129,11 +3573,12 @@ var init_QvdFileReader = __esm({
3129
3573
  * @private
3130
3574
  */
3131
3575
  async _readRange(fd, bufferOffset, byteCount, filePosition, fileSize, requiredBytes) {
3132
- assert2(this._buffer, "The read buffer has not been allocated.");
3576
+ assert3(this._buffer, "The read buffer has not been allocated.");
3577
+ const failed = rethrowAsIoError(this._path, "read");
3133
3578
  let done = 0;
3134
3579
  while (done < byteCount) {
3135
3580
  const length = Math.min(READ_CHUNK_SIZE, byteCount - done);
3136
- const { bytesRead } = await fd.read(this._buffer, bufferOffset + done, length, filePosition + done);
3581
+ const { bytesRead } = await fd.read(this._buffer, bufferOffset + done, length, filePosition + done).catch(failed);
3137
3582
  if (bytesRead === 0) {
3138
3583
  throw new QvdCorruptedError("Unexpected end of file while reading QVD data.", {
3139
3584
  file: this._path,
@@ -3268,7 +3713,7 @@ var init_QvdFileReader = __esm({
3268
3713
  }
3269
3714
  this._fieldBitMetadataValidated = true;
3270
3715
  }
3271
- assert2(
3716
+ assert3(
3272
3717
  rowsToLoad === 0 || recordSize === 0 || Math.floor(indexBuffer.length / recordSize) >= rowsToLoad,
3273
3718
  `The index table holds ${Math.floor(indexBuffer.length / (recordSize || 1))} whole records but ${rowsToLoad} were validated as present.`
3274
3719
  );
@@ -3445,7 +3890,7 @@ var init_QvdFileReader = __esm({
3445
3890
  await this._parseHeader();
3446
3891
  this._emitProgress("header", 1, 1);
3447
3892
  this._throwIfAborted();
3448
- assert2(this._header, "The QVD file header has not been parsed.");
3893
+ assert3(this._header, "The QVD file header has not been parsed.");
3449
3894
  const header = this._header["QvdTableHeader"];
3450
3895
  let fields = header["Fields"]?.["QvdFieldHeader"] ?? [];
3451
3896
  if (!Array.isArray(fields)) {
@@ -3509,9 +3954,10 @@ var init_QvdFileReader = __esm({
3509
3954
  * Shares every step with `load()` up to the point where rows would be built - see `_prepare`.
3510
3955
  * What it keeps instead is what the decoder already produced: one `Int32Array` of stored
3511
3956
  * indices per field, and one resolved value per distinct symbol. On the 1.7M x 20 taxi
3512
- * fixture that is 38.6 MiB against the 352.8 MiB `data` retains, because a column costs four
3957
+ * fixture that is 133 MiB against the 352 MiB `data` retains, because a column costs four
3513
3958
  * bytes per row rather than a boxed value per cell, and the symbols are a few thousand
3514
- * entries shared across every row that uses them.
3959
+ * entries shared across every row that uses them. The codes' storage lives outside the V8
3960
+ * heap: 3 MiB of the 133 is on it.
3515
3961
  *
3516
3962
  * @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} [window]
3517
3963
  * The rows to decode, in the same spellings `load()` accepts.
@@ -3522,7 +3968,7 @@ var init_QvdFileReader = __esm({
3522
3968
  const prepared = await this._prepare(rows, null, true);
3523
3969
  await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
3524
3970
  const { QvdColumnTable: QvdColumnTable2 } = await Promise.resolve().then(() => (init_QvdColumnTable(), QvdColumnTable_exports));
3525
- assert2(this._indexColumns, "The QVD file index table has not been parsed.");
3971
+ assert3(this._indexColumns, "The QVD file index table has not been parsed.");
3526
3972
  return new QvdColumnTable2({
3527
3973
  columns: prepared.columns,
3528
3974
  codesByField: this._indexColumns,
@@ -3619,7 +4065,7 @@ var init_QvdFileReader = __esm({
3619
4065
  await this._parseHeader();
3620
4066
  this._emitProgress("header", 1, 1);
3621
4067
  this._throwIfAborted();
3622
- assert2(this._header, "The QVD file header has not been parsed.");
4068
+ assert3(this._header, "The QVD file header has not been parsed.");
3623
4069
  const totalRows = parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10);
3624
4070
  const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
3625
4071
  const resolved = resolveWindow(window, totalRows);
@@ -3633,9 +4079,9 @@ var init_QvdFileReader = __esm({
3633
4079
  }
3634
4080
  }
3635
4081
  await this._parseSymbolTable(symbolsToKeep, rowsAvailable, liveRows);
3636
- assert2(this._symbolTable, "The QVD file symbol table has not been parsed.");
4082
+ assert3(this._symbolTable, "The QVD file symbol table has not been parsed.");
3637
4083
  this._throwIfAborted();
3638
- assert2(this._selectedFields, "The QVD file fields have not been resolved.");
4084
+ assert3(this._selectedFields, "The QVD file fields have not been resolved.");
3639
4085
  const resolvedByField = [];
3640
4086
  const halvesByField = [];
3641
4087
  const entries = [];
@@ -3696,7 +4142,7 @@ var init_QvdFileReader = __esm({
3696
4142
  * @private
3697
4143
  */
3698
4144
  _buildRows(resolvedByField, progressBase, progressTotal) {
3699
- assert2(this._indexColumns, "The QVD file index table has not been parsed.");
4145
+ assert3(this._indexColumns, "The QVD file index table has not been parsed.");
3700
4146
  const indexColumns = this._indexColumns;
3701
4147
  const fieldCount = indexColumns.length;
3702
4148
  const rowCount = this._rowsDecoded;
@@ -4301,14 +4747,31 @@ var init_QvdDataFrame = __esm({
4301
4747
  * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
4302
4748
  * back to the working directory rather than removing the restriction.
4303
4749
  * @param {Function} [options.onProgress] Optional progress callback function that receives progress updates during write operations.
4750
+ * @param {boolean} [options.atomic=true] Whether to replace the file rather than rewrite it. The QVD
4751
+ * is built beside it under a temporary name and renamed over it, so a write that fails leaves the
4752
+ * previous file exactly as it was, and a reader of the path - a Qlik reload, say - sees the old
4753
+ * file or the new one and never a part of either. False rewrites the file where it stands, as
4754
+ * every version before 2.0.0 did: that keeps its identity, including hard links, its owner and
4755
+ * permissions set on the file itself, and needs neither room for two copies nor permission to
4756
+ * create files in the directory. It also destroys the previous file as the write begins. A file
4757
+ * that does not exist yet is renamed into place either way, since there is nothing to rewrite.
4758
+ * @param {boolean} [options.fsync=true] Whether to wait for the contents to reach the disk before
4759
+ * resolving. It is what carries an atomic write's promise through a power loss - the contents are
4760
+ * on the disk before anything is renamed, so a crash leaves the previous file or the new one and
4761
+ * never a damaged one - and what reports a failing disk's deferred error instead of losing it. The
4762
+ * rename itself is not flushed, so a crash just after the call can still lose the replacement, or
4763
+ * a file that did not exist before. False resolves once the operating system has accepted the
4764
+ * bytes, which is faster and is what every version before 2.0.0 did.
4304
4765
  */
4305
- async toQvd(path3, options = {}) {
4766
+ async toQvd(path5, options = {}) {
4306
4767
  const { QvdFileWriter: QvdFileWriter2 } = await Promise.resolve().then(() => (init_QvdFileWriter(), QvdFileWriter_exports));
4307
4768
  const writerOptions = {
4308
4769
  allowedDir: options.allowedDir,
4309
- onProgress: options.onProgress
4770
+ onProgress: options.onProgress,
4771
+ atomic: options.atomic,
4772
+ fsync: options.fsync
4310
4773
  };
4311
- await new QvdFileWriter2(path3, this, writerOptions).save();
4774
+ await new QvdFileWriter2(path5, this, writerOptions).save();
4312
4775
  }
4313
4776
  /**
4314
4777
  * Loads a QVD file and returns its data frame.
@@ -4361,9 +4824,9 @@ var init_QvdDataFrame = __esm({
4361
4824
  * is not one of its modes, or if `coerceNumericStrings` is not a boolean.
4362
4825
  * @return {Promise<QvdDataFrame>} The data frame of the QVD file.
4363
4826
  */
4364
- static async fromQvd(path3, options = {}) {
4827
+ static async fromQvd(path5, options = {}) {
4365
4828
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
4366
- return await new QvdFileReader2(path3, readerOptionsFrom(options)).load(windowFrom(options));
4829
+ return await new QvdFileReader2(path5, readerOptionsFrom(options)).load(windowFrom(options));
4367
4830
  }
4368
4831
  /**
4369
4832
  * Reads a QVD file in chunks, as an async generator of data frames.
@@ -4410,9 +4873,9 @@ var init_QvdDataFrame = __esm({
4410
4873
  * windowed read switches to two-pass filtering.
4411
4874
  * @return {AsyncGenerator<QvdDataFrame>} The chunks, in file order.
4412
4875
  */
4413
- static async *iterate(path3, options = {}) {
4876
+ static async *iterate(path5, options = {}) {
4414
4877
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
4415
- const reader = new QvdFileReader2(path3, readerOptionsFrom(options));
4878
+ const reader = new QvdFileReader2(path5, readerOptionsFrom(options));
4416
4879
  yield* reader.iterateRows(windowFrom(options), options.chunkSize === void 0 ? 1e5 : options.chunkSize);
4417
4880
  }
4418
4881
  /**
@@ -4443,9 +4906,9 @@ var init_QvdDataFrame = __esm({
4443
4906
  * @param {AbortSignal} [options.signal] Cancels the read, rejecting with `signal.reason`.
4444
4907
  * @return {Promise<QvdFileMetadata>} The file's schema and header metadata.
4445
4908
  */
4446
- static async readMetadata(path3, options = {}) {
4909
+ static async readMetadata(path5, options = {}) {
4447
4910
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
4448
- return await new QvdFileReader2(path3, metadataOptionsFrom(options)).loadMetadata();
4911
+ return await new QvdFileReader2(path5, metadataOptionsFrom(options)).loadMetadata();
4449
4912
  }
4450
4913
  /**
4451
4914
  * Constructs a data frame from a dictionary.