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.cjs CHANGED
@@ -1,21 +1,22 @@
1
1
  'use strict';
2
2
 
3
- var fs = require('fs');
4
- var path = require('path');
5
- var crypto = require('crypto');
3
+ var fs2 = require('fs');
4
+ var path2 = require('path');
5
+ var assert3 = require('assert');
6
+ var crypto2 = require('crypto');
7
+ var promises = require('timers/promises');
6
8
  var xml2 = require('xml2js');
7
- var assert2 = require('assert');
8
9
  var os = require('os');
9
10
  var v8 = require('v8');
10
11
  var util = require('util');
11
12
 
12
13
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
14
 
14
- var fs__default = /*#__PURE__*/_interopDefault(fs);
15
- var path__default = /*#__PURE__*/_interopDefault(path);
16
- var crypto__default = /*#__PURE__*/_interopDefault(crypto);
15
+ var fs2__default = /*#__PURE__*/_interopDefault(fs2);
16
+ var path2__default = /*#__PURE__*/_interopDefault(path2);
17
+ var assert3__default = /*#__PURE__*/_interopDefault(assert3);
18
+ var crypto2__default = /*#__PURE__*/_interopDefault(crypto2);
17
19
  var xml2__default = /*#__PURE__*/_interopDefault(xml2);
18
- var assert2__default = /*#__PURE__*/_interopDefault(assert2);
19
20
  var os__default = /*#__PURE__*/_interopDefault(os);
20
21
  var v8__default = /*#__PURE__*/_interopDefault(v8);
21
22
 
@@ -45,9 +46,11 @@ var init_QvdErrors = __esm({
45
46
  * @param {string} message The error message.
46
47
  * @param {string} code The error code.
47
48
  * @param {Object} [context={}] Additional context about the error.
49
+ * @param {{cause?: unknown}} [options] Passed on to `Error`, so a `cause` becomes `error.cause`: the
50
+ * error this one reports, such as the operating system's refusal behind a `QvdIOError`.
48
51
  */
49
- constructor(message, code, context = {}) {
50
- super(message);
52
+ constructor(message, code, context = {}, options = void 0) {
53
+ super(message, options);
51
54
  this.name = ERROR_NAMES.get(new.target) ?? new.target.name;
52
55
  this.code = code;
53
56
  this.context = context;
@@ -63,9 +66,10 @@ var init_QvdErrors = __esm({
63
66
  *
64
67
  * @param {string} message The error message.
65
68
  * @param {Object} [context={}] Additional context about the error.
69
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
66
70
  */
67
- constructor(message, context = {}) {
68
- super(message, "QVD_PARSE_ERROR", context);
71
+ constructor(message, context = {}, options = void 0) {
72
+ super(message, "QVD_PARSE_ERROR", context, options);
69
73
  }
70
74
  };
71
75
  exports.QvdValidationError = class extends exports.QvdError {
@@ -77,9 +81,10 @@ var init_QvdErrors = __esm({
77
81
  *
78
82
  * @param {string} message The error message.
79
83
  * @param {Object} [context={}] Additional context about the error.
84
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
80
85
  */
81
- constructor(message, context = {}) {
82
- super(message, "QVD_VALIDATION_ERROR", context);
86
+ constructor(message, context = {}, options = void 0) {
87
+ super(message, "QVD_VALIDATION_ERROR", context, options);
83
88
  }
84
89
  };
85
90
  exports.QvdIOError = class extends exports.QvdError {
@@ -91,9 +96,10 @@ var init_QvdErrors = __esm({
91
96
  *
92
97
  * @param {string} message The error message.
93
98
  * @param {Object} [context={}] Additional context about the error.
99
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
94
100
  */
95
- constructor(message, context = {}) {
96
- super(message, "QVD_IO_ERROR", context);
101
+ constructor(message, context = {}, options = void 0) {
102
+ super(message, "QVD_IO_ERROR", context, options);
97
103
  }
98
104
  };
99
105
  exports.QvdCorruptedError = class extends exports.QvdError {
@@ -105,9 +111,10 @@ var init_QvdErrors = __esm({
105
111
  *
106
112
  * @param {string} message The error message.
107
113
  * @param {Object} [context={}] Additional context about the error.
114
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
108
115
  */
109
- constructor(message, context = {}) {
110
- super(message, "QVD_CORRUPTED_ERROR", context);
116
+ constructor(message, context = {}, options = void 0) {
117
+ super(message, "QVD_CORRUPTED_ERROR", context, options);
111
118
  }
112
119
  };
113
120
  exports.QvdSecurityError = class extends exports.QvdError {
@@ -119,9 +126,10 @@ var init_QvdErrors = __esm({
119
126
  *
120
127
  * @param {string} message The error message.
121
128
  * @param {Object} [context={}] Additional context about the error.
129
+ * @param {{cause?: unknown}} [options] Passed on to `Error`; a `cause` becomes `error.cause`.
122
130
  */
123
- constructor(message, context = {}) {
124
- super(message, "QVD_SECURITY_ERROR", context);
131
+ constructor(message, context = {}, options = void 0) {
132
+ super(message, "QVD_SECURITY_ERROR", context, options);
125
133
  }
126
134
  };
127
135
  ERROR_NAMES.set(exports.QvdError, "QvdError");
@@ -419,6 +427,28 @@ var init_QvdDual = __esm({
419
427
  }
420
428
  });
421
429
 
430
+ // src/util/optionTypes.js
431
+ function booleanOption(value, { option, file, whenUnset }) {
432
+ if (value === void 0 || value === null) {
433
+ return whenUnset;
434
+ }
435
+ if (typeof value !== "boolean") {
436
+ throw new exports.QvdValidationError(`${option} must be true or false`, {
437
+ option,
438
+ provided: value,
439
+ type: typeof value,
440
+ file
441
+ });
442
+ }
443
+ return value;
444
+ }
445
+ var init_optionTypes = __esm({
446
+ "src/util/optionTypes.js"() {
447
+ init_QvdErrors();
448
+ __name(booleanOption, "booleanOption");
449
+ }
450
+ });
451
+
422
452
  // src/util/readOptions.js
423
453
  function requireRowCount(value, name, filePath) {
424
454
  if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
@@ -529,18 +559,7 @@ function normaliseDuals(value, filePath) {
529
559
  return value;
530
560
  }
531
561
  function normaliseCoerceNumericStrings(value, filePath) {
532
- if (value === void 0 || value === null) {
533
- return false;
534
- }
535
- if (typeof value !== "boolean") {
536
- throw new exports.QvdValidationError("coerceNumericStrings must be true or false", {
537
- option: "coerceNumericStrings",
538
- provided: value,
539
- type: typeof value,
540
- file: filePath
541
- });
542
- }
543
- return value;
562
+ return booleanOption(value, { option: "coerceNumericStrings", file: filePath, whenUnset: false });
544
563
  }
545
564
  function readerOptionsFrom(options) {
546
565
  return {
@@ -568,6 +587,7 @@ var DUAL_MODES;
568
587
  var init_readOptions = __esm({
569
588
  "src/util/readOptions.js"() {
570
589
  init_QvdErrors();
590
+ init_optionTypes();
571
591
  __name(requireRowCount, "requireRowCount");
572
592
  __name(normaliseWindow, "normaliseWindow");
573
593
  __name(resolveWindow, "resolveWindow");
@@ -711,24 +731,64 @@ var init_storedSymbols = __esm({
711
731
  __name(sameValueZero, "sameValueZero");
712
732
  }
713
733
  });
734
+ function readDanglingLink(target) {
735
+ let link;
736
+ try {
737
+ link = fs2__default.default.lstatSync(target);
738
+ } catch {
739
+ return NOT_A_LINK;
740
+ }
741
+ if (!link.isSymbolicLink()) {
742
+ return NOT_A_LINK;
743
+ }
744
+ let destination;
745
+ try {
746
+ destination = fs2__default.default.readlinkSync(target);
747
+ } catch {
748
+ return UNREADABLE_LINK;
749
+ }
750
+ if (path2__default.default.isAbsolute(destination)) {
751
+ return destination;
752
+ }
753
+ try {
754
+ return path2__default.default.resolve(fs2__default.default.realpathSync(path2__default.default.dirname(target)), destination);
755
+ } catch {
756
+ return UNREADABLE_LINK;
757
+ }
758
+ }
759
+ var MAX_LINK_HOPS, NOT_A_LINK, UNREADABLE_LINK;
760
+ var init_linkTarget = __esm({
761
+ "src/util/linkTarget.js"() {
762
+ MAX_LINK_HOPS = 40;
763
+ NOT_A_LINK = /* @__PURE__ */ Symbol("not a link");
764
+ UNREADABLE_LINK = /* @__PURE__ */ Symbol("unreadable link");
765
+ __name(readDanglingLink, "readDanglingLink");
766
+ }
767
+ });
714
768
  function isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) {
715
769
  const isCaseInsensitiveFS = process.platform === "win32";
716
770
  const base = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
717
771
  const target = isCaseInsensitiveFS ? resolvedPath.toLowerCase() : resolvedPath;
718
- const relative = path__default.default.relative(base, target);
772
+ const relative = path2__default.default.relative(base, target);
719
773
  if (relative === "") {
720
774
  return true;
721
775
  }
722
- if (path__default.default.isAbsolute(relative)) {
776
+ if (path2__default.default.isAbsolute(relative)) {
723
777
  return false;
724
778
  }
725
- return relative !== ".." && !relative.startsWith(`..${path__default.default.sep}`);
779
+ return relative !== ".." && !relative.startsWith(`..${path2__default.default.sep}`);
726
780
  }
727
781
  function resolveDeepestExisting(target) {
728
782
  let current = target;
783
+ let farEnd = target;
784
+ let walkedUp = false;
785
+ let followedWhileClimbing = false;
786
+ let hops = 0;
729
787
  for (; ; ) {
730
788
  try {
731
- return fs__default.default.realpathSync(current);
789
+ const deepest = fs2__default.default.realpathSync(current);
790
+ const canonicalFarEnd = walkedUp && !followedWhileClimbing ? path2__default.default.join(deepest, path2__default.default.relative(current, farEnd)) : farEnd;
791
+ return { deepest, exists: !walkedUp, farEnd: canonicalFarEnd };
732
792
  } catch (error) {
733
793
  const code = (
734
794
  /** @type {{code?: string}} */
@@ -737,10 +797,27 @@ function resolveDeepestExisting(target) {
737
797
  if (code !== "ENOENT" && code !== "ENOTDIR") {
738
798
  return null;
739
799
  }
740
- const parent = path__default.default.dirname(current);
800
+ const followed = readDanglingLink(current);
801
+ if (followed === UNREADABLE_LINK) {
802
+ return REFUSED;
803
+ }
804
+ if (followed !== NOT_A_LINK) {
805
+ if (++hops > MAX_LINK_HOPS) {
806
+ return REFUSED;
807
+ }
808
+ current = followed;
809
+ if (walkedUp) {
810
+ followedWhileClimbing = true;
811
+ } else {
812
+ farEnd = followed;
813
+ }
814
+ continue;
815
+ }
816
+ const parent = path2__default.default.dirname(current);
741
817
  if (parent === current) {
742
818
  return null;
743
819
  }
820
+ walkedUp = true;
744
821
  current = parent;
745
822
  }
746
823
  }
@@ -748,32 +825,42 @@ function resolveDeepestExisting(target) {
748
825
  function isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath) {
749
826
  let baseStat;
750
827
  try {
751
- baseStat = fs__default.default.statSync(fs__default.default.realpathSync(resolvedBaseDir));
828
+ baseStat = fs2__default.default.statSync(fs2__default.default.realpathSync(resolvedBaseDir), { bigint: true });
752
829
  } catch {
753
830
  return null;
754
831
  }
755
- let current = resolveDeepestExisting(resolvedPath);
756
- if (current === null) {
832
+ const found = resolveDeepestExisting(resolvedPath);
833
+ if (found === REFUSED) {
834
+ return { contained: false, target: resolvedPath, stats: null };
835
+ }
836
+ if (found === null) {
757
837
  return null;
758
838
  }
759
- for (; ; ) {
839
+ const target = found.exists ? found.deepest : found.farEnd;
840
+ let stats = null;
841
+ let current = found.deepest;
842
+ for (let first = true; ; first = false) {
843
+ const ownIdentity = first && found.exists;
760
844
  let stat;
761
845
  try {
762
- stat = fs__default.default.statSync(current);
846
+ stat = ownIdentity ? fs2__default.default.lstatSync(current, { bigint: true }) : fs2__default.default.statSync(current, { bigint: true });
763
847
  } catch {
764
848
  return null;
765
849
  }
850
+ if (ownIdentity) {
851
+ stats = stat;
852
+ }
766
853
  if (stat.dev === baseStat.dev && stat.ino === baseStat.ino) {
767
- return true;
854
+ return { contained: true, target, stats };
768
855
  }
769
- const parent = path__default.default.dirname(current);
856
+ const parent = path2__default.default.dirname(current);
770
857
  if (parent === current) {
771
- return false;
858
+ return { contained: false, target, stats };
772
859
  }
773
860
  current = parent;
774
861
  }
775
862
  }
776
- function validatePath(filePath, allowedDir) {
863
+ function checkPath(filePath, allowedDir) {
777
864
  if (typeof filePath !== "string" || filePath.length === 0) {
778
865
  throw new exports.QvdValidationError("filePath must be a non-empty string", {
779
866
  provided: filePath,
@@ -792,11 +879,11 @@ function validatePath(filePath, allowedDir) {
792
879
  reason: "null_byte"
793
880
  });
794
881
  }
795
- const resolvedPath = path__default.default.resolve(filePath);
882
+ const resolvedPath = path2__default.default.resolve(filePath);
796
883
  const baseDir = allowedDir || process.cwd();
797
- const resolvedBaseDir = path__default.default.resolve(baseDir);
884
+ const resolvedBaseDir = path2__default.default.resolve(baseDir);
798
885
  const onDisk = isWithinDirectoryOnDisk(resolvedBaseDir, resolvedPath);
799
- const contained = onDisk === null ? isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) : onDisk;
886
+ const contained = onDisk === null ? isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) : onDisk.contained;
800
887
  if (!contained) {
801
888
  throw new exports.QvdSecurityError("Path traversal detected: Access denied", {
802
889
  path: filePath,
@@ -808,15 +895,197 @@ function validatePath(filePath, allowedDir) {
808
895
  check: onDisk === null ? "lexical" : "filesystem"
809
896
  });
810
897
  }
811
- return resolvedPath;
898
+ if (onDisk === null) {
899
+ return { path: resolvedPath, base: resolvedBaseDir, target: resolvedPath, stats: null, onDisk: false };
900
+ }
901
+ return { path: resolvedPath, base: resolvedBaseDir, target: onDisk.target, stats: onDisk.stats, onDisk: true };
812
902
  }
903
+ var REFUSED;
813
904
  var init_validatePath = __esm({
814
905
  "src/util/validatePath.js"() {
815
906
  init_QvdErrors();
907
+ init_linkTarget();
816
908
  __name(isWithinDirectoryLexically, "isWithinDirectoryLexically");
909
+ REFUSED = /* @__PURE__ */ Symbol("refused");
817
910
  __name(resolveDeepestExisting, "resolveDeepestExisting");
818
911
  __name(isWithinDirectoryOnDisk, "isWithinDirectoryOnDisk");
819
- __name(validatePath, "validatePath");
912
+ __name(checkPath, "checkPath");
913
+ }
914
+ });
915
+
916
+ // src/util/ioErrors.js
917
+ function isSystemError(error) {
918
+ const candidate = (
919
+ /** @type {{code?: unknown, syscall?: unknown}|null} */
920
+ error
921
+ );
922
+ return candidate !== null && typeof candidate === "object" && typeof candidate.syscall === "string" && typeof candidate.code === "string";
923
+ }
924
+ function asIoError(error, file, direction) {
925
+ if (!isSystemError(error)) {
926
+ return error;
927
+ }
928
+ const { message, syscall, code } = (
929
+ /** @type {{message: string, syscall: string, code: string}} */
930
+ error
931
+ );
932
+ return new exports.QvdIOError(
933
+ `Could not ${direction} the QVD file: ${message}`,
934
+ { file, operation: syscall, code },
935
+ { cause: error }
936
+ );
937
+ }
938
+ function rethrowAsIoError(file, direction) {
939
+ return (error) => {
940
+ throw asIoError(error, file, direction);
941
+ };
942
+ }
943
+ async function closeAfter(handle, failed, use) {
944
+ let result;
945
+ try {
946
+ result = await use();
947
+ } catch (error) {
948
+ await handle.close().catch(() => {
949
+ });
950
+ throw error;
951
+ }
952
+ await handle.close().catch(failed);
953
+ return result;
954
+ }
955
+ var init_ioErrors = __esm({
956
+ "src/util/ioErrors.js"() {
957
+ init_QvdErrors();
958
+ __name(isSystemError, "isSystemError");
959
+ __name(asIoError, "asIoError");
960
+ __name(rethrowAsIoError, "rethrowAsIoError");
961
+ __name(closeAfter, "closeAfter");
962
+ }
963
+ });
964
+ function changedAfterCheck(checked, change) {
965
+ return new exports.QvdSecurityError(`Path traversal detected: ${DESCRIPTIONS[change]}`, {
966
+ path: checked.path,
967
+ resolvedPath: checked.target,
968
+ allowedDir: checked.base,
969
+ reason: "changed_after_check",
970
+ change
971
+ });
972
+ }
973
+ async function openChecked(checked, purpose, failed, { nofollow = NOFOLLOW } = {}) {
974
+ assert3__default.default(purpose === "read" || checked.stats !== null, "A rewrite in place is of a file that exists.");
975
+ const noFollow = checked.onDisk ? nofollow : 0;
976
+ const flags = (purpose === "rewrite" ? O_WRONLY : O_RDONLY) | noFollow;
977
+ let handle;
978
+ try {
979
+ handle = await fs2__default.default.promises.open(checked.target, flags);
980
+ } catch (error) {
981
+ const { code } = (
982
+ /** @type {{code?: string}} */
983
+ error ?? {}
984
+ );
985
+ if (checked.onDisk && noFollow !== 0 && code !== void 0 && BECAME_A_LINK.has(code)) {
986
+ throw changedAfterCheck(checked, "became_a_symlink");
987
+ }
988
+ return failed(error);
989
+ }
990
+ const stats = checked.stats;
991
+ if (stats === null) {
992
+ if (checked.onDisk) {
993
+ await handle.close().catch(() => {
994
+ });
995
+ throw changedAfterCheck(checked, "appeared");
996
+ }
997
+ return handle;
998
+ }
999
+ try {
1000
+ const opened = await handle.stat({ bigint: true }).catch(failed);
1001
+ if (opened.dev !== stats.dev || opened.ino !== stats.ino) {
1002
+ throw changedAfterCheck(checked, "replaced");
1003
+ }
1004
+ if (purpose === "rewrite" && stats.isFile()) {
1005
+ await handle.truncate(0).catch(failed);
1006
+ }
1007
+ } catch (error) {
1008
+ await handle.close().catch(() => {
1009
+ });
1010
+ throw error;
1011
+ }
1012
+ return handle;
1013
+ }
1014
+ var NOFOLLOW, O_RDONLY, O_WRONLY, BECAME_A_LINK, DESCRIPTIONS;
1015
+ var init_openChecked = __esm({
1016
+ "src/util/openChecked.js"() {
1017
+ init_QvdErrors();
1018
+ NOFOLLOW = fs2__default.default.constants.O_NOFOLLOW ?? 0;
1019
+ ({ O_RDONLY, O_WRONLY } = fs2__default.default.constants);
1020
+ BECAME_A_LINK = /* @__PURE__ */ new Set(["ELOOP", "EMLINK"]);
1021
+ DESCRIPTIONS = {
1022
+ became_a_symlink: "the file became a symbolic link after it was checked",
1023
+ replaced: "the file was replaced by a different one after it was checked",
1024
+ appeared: "a file appeared at the path after it was checked"
1025
+ };
1026
+ __name(changedAfterCheck, "changedAfterCheck");
1027
+ __name(openChecked, "openChecked");
1028
+ }
1029
+ });
1030
+ function firstBytesOf(name, budget) {
1031
+ if (Buffer.byteLength(name) <= budget) {
1032
+ return name;
1033
+ }
1034
+ let kept = "";
1035
+ let bytes = 0;
1036
+ for (const character of name) {
1037
+ const size = Buffer.byteLength(character);
1038
+ if (bytes + size > budget) {
1039
+ break;
1040
+ }
1041
+ kept += character;
1042
+ bytes += size;
1043
+ }
1044
+ return kept;
1045
+ }
1046
+ function temporaryPathFor(target) {
1047
+ const marks = `.qvdjs-${crypto2__default.default.randomBytes(8).toString("hex")}.tmp`;
1048
+ const name = `${firstBytesOf(path2__default.default.basename(target), MAX_NAME_BYTES - marks.length)}${marks}`;
1049
+ return path2__default.default.join(path2__default.default.dirname(target), name);
1050
+ }
1051
+ async function retrying(call, { platform = process.platform, delays = RETRY_DELAYS } = {}) {
1052
+ for (let attempt = 0; ; attempt++) {
1053
+ try {
1054
+ return await call();
1055
+ } catch (error) {
1056
+ const { code } = (
1057
+ /** @type {{code?: string}} */
1058
+ error ?? {}
1059
+ );
1060
+ if (platform !== "win32" || attempt >= delays.length || code === void 0 || !IN_USE.has(code)) {
1061
+ throw error;
1062
+ }
1063
+ await promises.setTimeout(delays[attempt]);
1064
+ }
1065
+ }
1066
+ }
1067
+ function renameOver(from, to, options) {
1068
+ return retrying(() => fs2__default.default.promises.rename(from, to), options);
1069
+ }
1070
+ async function removeTemporary(file, options) {
1071
+ try {
1072
+ await retrying(() => fs2__default.default.promises.rm(file, { force: true }), options);
1073
+ return true;
1074
+ } catch {
1075
+ return false;
1076
+ }
1077
+ }
1078
+ var MAX_NAME_BYTES, IN_USE, RETRY_DELAYS;
1079
+ var init_replaceFile = __esm({
1080
+ "src/util/replaceFile.js"() {
1081
+ MAX_NAME_BYTES = 255;
1082
+ __name(firstBytesOf, "firstBytesOf");
1083
+ __name(temporaryPathFor, "temporaryPathFor");
1084
+ IN_USE = /* @__PURE__ */ new Set(["EPERM", "EACCES", "EBUSY"]);
1085
+ RETRY_DELAYS = [10, 20, 40, 80, 160, 320];
1086
+ __name(retrying, "retrying");
1087
+ __name(renameOver, "renameOver");
1088
+ __name(removeTemporary, "removeTemporary");
820
1089
  }
821
1090
  });
822
1091
 
@@ -1146,11 +1415,15 @@ function resetContradictedNumberFormat(numberFormat, facts) {
1146
1415
  }
1147
1416
  return numberFormat;
1148
1417
  }
1149
- var OBJECT_MEMO_BASE, NUMERIC_TAGS, TEXT_TAGS, WHOLE_NUMBER_TAGS, NUMERIC_FORMATS, UNKNOWN_NUMBER_FORMAT; exports.QvdFileWriter = void 0;
1418
+ var OBJECT_MEMO_BASE, NUMERIC_TAGS, TEXT_TAGS, WHOLE_NUMBER_TAGS, NUMERIC_FORMATS, UNKNOWN_NUMBER_FORMAT, WRITE_CHUNK_SIZE; exports.QvdFileWriter = void 0;
1150
1419
  var init_QvdFileWriter = __esm({
1151
1420
  "src/QvdFileWriter.js"() {
1152
1421
  init_QvdErrors();
1153
1422
  init_validatePath();
1423
+ init_ioErrors();
1424
+ init_openChecked();
1425
+ init_replaceFile();
1426
+ init_optionTypes();
1154
1427
  init_bitUtils();
1155
1428
  init_cellRules();
1156
1429
  init_symbolBytes();
@@ -1177,6 +1450,7 @@ var init_QvdFileWriter = __esm({
1177
1450
  UNKNOWN_NUMBER_FORMAT = Object.freeze({ Type: "UNKNOWN", nDec: "0", UseThou: "0", Fmt: "", Dec: "", Thou: "" });
1178
1451
  __name(pruneContradictedTags, "pruneContradictedTags");
1179
1452
  __name(resetContradictedNumberFormat, "resetContradictedNumberFormat");
1453
+ WRITE_CHUNK_SIZE = 512 * 1024 * 1024;
1180
1454
  exports.QvdFileWriter = class {
1181
1455
  static {
1182
1456
  __name(this, "QvdFileWriter");
@@ -1193,10 +1467,30 @@ var init_QvdFileWriter = __esm({
1193
1467
  * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
1194
1468
  * empty value falls back to the working directory rather than removing the restriction.
1195
1469
  * @param {Function} [options.onProgress] Optional progress callback function.
1470
+ * @param {boolean} [options.atomic=true] Whether to replace the destination rather than rewrite it:
1471
+ * the file is built beside it under a temporary name and renamed over it, so a failure leaves the
1472
+ * previous file exactly as it was and nothing ever reads a part-written one. False rewrites the
1473
+ * destination in place, which is what every version before 2.0.0 did: that keeps the file's
1474
+ * identity - hard links, its owner, permissions set on the file itself - needs no room for two
1475
+ * copies at once and no permission to create files in the directory, and destroys the previous
1476
+ * file the moment the write begins. A file that does not exist yet is renamed into place either
1477
+ * way, since there is nothing to rewrite - see `_destination`.
1478
+ * @param {boolean} [options.fsync=true] Whether to wait for the file's contents to reach the disk
1479
+ * before the write is finished. It is what carries an atomic write's promise through a power
1480
+ * loss - the contents are on the disk before anything is renamed, so a crash leaves the previous
1481
+ * file or the new one and never a damaged one - and it is the only thing that reports a failing
1482
+ * disk's deferred error rather than losing it. The rename itself is not flushed, so a crash just
1483
+ * after the call can still lose the replacement, or a file that did not exist before. False
1484
+ * resolves as soon as the operating system has accepted the bytes, which is faster and is what
1485
+ * every version before 2.0.0 did.
1196
1486
  */
1197
1487
  constructor(filePath, df, options = {}) {
1198
- const { allowedDir, onProgress } = options;
1199
- this._path = validatePath(filePath, allowedDir);
1488
+ const { allowedDir, onProgress, atomic, fsync } = options;
1489
+ const checked = checkPath(filePath, allowedDir);
1490
+ this._path = checked.path;
1491
+ this._allowedDir = checked.base;
1492
+ this._atomic = booleanOption(atomic, { option: "atomic", file: this._path, whenUnset: true });
1493
+ this._fsync = booleanOption(fsync, { option: "fsync", file: this._path, whenUnset: true });
1200
1494
  this._df = df;
1201
1495
  this._onProgress = onProgress;
1202
1496
  this._header = null;
@@ -1232,22 +1526,168 @@ var init_QvdFileWriter = __esm({
1232
1526
  * Writes the data to the QVD file.
1233
1527
  */
1234
1528
  async _writeData() {
1235
- assert2__default.default(this._header, "The QVD file header has not been parsed.");
1236
- assert2__default.default(this._symbolBuffer, "The QVD file symbol table has not been parsed.");
1237
- assert2__default.default(this._indexBuffer, "The QVD file index table has not been parsed.");
1529
+ assert3__default.default(this._header, "The QVD file header has not been parsed.");
1530
+ assert3__default.default(this._symbolBuffer, "The QVD file symbol table has not been parsed.");
1531
+ assert3__default.default(this._indexBuffer, "The QVD file index table has not been parsed.");
1238
1532
  this._emitProgress("write", 0, 1);
1239
1533
  const headerBuffer = Buffer.concat([Buffer.from(this._header, "utf-8"), Buffer.from([0])]);
1240
- let fd;
1534
+ const failed = rethrowAsIoError(this._path, "write");
1535
+ const destination = this._destination();
1536
+ if (destination.replace) {
1537
+ await this._replaceFile(destination, headerBuffer, failed);
1538
+ } else {
1539
+ await this._writeInPlace(destination, headerBuffer, failed);
1540
+ }
1541
+ this._emitProgress("write", 1, 1);
1542
+ }
1543
+ /**
1544
+ * What is at the destination, and therefore how it is to be written.
1545
+ *
1546
+ * Decided by one containment check, made now - immediately before anything is opened - and by
1547
+ * nothing else: the file that check approved is the file written, in either mode, and what the
1548
+ * check found there is what decides how.
1549
+ *
1550
+ * It used to stat and resolve the path again for itself, after the check. That second resolution
1551
+ * was #247 in the atomic write: a destination swapped for a symlink after the check was followed by
1552
+ * it, so the temporary file was built beside a file outside allowedDir and renamed over it. A check
1553
+ * reports where it went, so nothing here has to go there again.
1554
+ *
1555
+ * @return {{checked: import('./util/validatePath.js').CheckedPath, path: string,
1556
+ * existing: import('fs').BigIntStats|null, replace: boolean, sync: boolean}} The check, the file it
1557
+ * approved, what was there, whether to replace it rather than rewrite it, and whether to flush it.
1558
+ * @private
1559
+ */
1560
+ _destination() {
1561
+ const checked = checkPath(this._path, this._allowedDir);
1562
+ const existing = checked.stats;
1563
+ const special = existing !== null && !existing.isFile();
1564
+ const replace = (this._atomic || existing === null) && !special;
1565
+ return {
1566
+ checked,
1567
+ path: checked.target,
1568
+ existing,
1569
+ replace,
1570
+ sync: this._fsync && !special
1571
+ };
1572
+ }
1573
+ /**
1574
+ * Rewrites the destination where it stands - the way every version before 2.0.0 wrote.
1575
+ *
1576
+ * The file is emptied as the write begins, so from there until the last byte is written there is no
1577
+ * previous version left: a failure part-way through leaves a stub that no read of its rows survives,
1578
+ * and anything reading the path meanwhile sees however much of the new file has arrived.
1579
+ *
1580
+ * It is emptied by `openChecked` rather than by opening with `'w'`, and later than `'w'` would: once
1581
+ * the descriptor is known to be the file the check approved. `'w'` empties whatever the open reaches,
1582
+ * which is what let #247 truncate a file outside allowedDir through a symlink swapped in after the
1583
+ * check. The file that results is the same.
1584
+ *
1585
+ * @param {{checked: import('./util/validatePath.js').CheckedPath, sync: boolean}} destination Where
1586
+ * to write, from `_destination`.
1587
+ * @param {Buffer} headerBuffer The header and its terminator.
1588
+ * @param {(error: unknown) => never} failed The write's `rethrowAsIoError` handler.
1589
+ * @private
1590
+ */
1591
+ async _writeInPlace(destination, headerBuffer, failed) {
1592
+ const fd = await openChecked(destination.checked, "rewrite", failed);
1593
+ await closeAfter(fd, failed, async () => {
1594
+ await this._writeParts(fd, headerBuffer, failed);
1595
+ if (destination.sync) {
1596
+ await fd.sync().catch(failed);
1597
+ }
1598
+ });
1599
+ }
1600
+ /**
1601
+ * Builds the file beside the destination and renames it over it.
1602
+ *
1603
+ * Nothing touches the destination until the rename, which either replaces it or leaves it as it was,
1604
+ * so a write that fails at any step - a full disk, a process killed, an error from the disk itself -
1605
+ * costs the temporary file and nothing else. A reader of the path gets the previous file or the new
1606
+ * one, never a part of either, which is the other half of what the old behaviour could not promise.
1607
+ *
1608
+ * @param {{path: string, existing: import('fs').BigIntStats|null, sync: boolean}} destination Where to
1609
+ * write, from `_destination`.
1610
+ * @param {Buffer} headerBuffer The header and its terminator.
1611
+ * @param {(error: unknown) => never} failed The write's `rethrowAsIoError` handler.
1612
+ * @private
1613
+ */
1614
+ async _replaceFile(destination, headerBuffer, failed) {
1615
+ const temporary = temporaryPathFor(destination.path);
1616
+ const mode = destination.existing === null ? void 0 : 384;
1617
+ const fd = await fs2__default.default.promises.open(temporary, "wx", mode).catch(failed);
1241
1618
  try {
1242
- fd = await fs__default.default.promises.open(this._path, "w");
1243
- await fd.write(headerBuffer, 0, headerBuffer.length, 0);
1244
- await fd.write(this._symbolBuffer, 0, this._symbolBuffer.length, headerBuffer.length);
1245
- await fd.write(this._indexBuffer, 0, this._indexBuffer.length, headerBuffer.length + this._symbolBuffer.length);
1246
- this._emitProgress("write", 1, 1);
1247
- } finally {
1248
- if (fd) {
1249
- await fd.close();
1619
+ await closeAfter(fd, failed, async () => {
1620
+ await this._writeParts(fd, headerBuffer, failed);
1621
+ if (destination.existing !== null && process.platform !== "win32") {
1622
+ await fd.chmod(Number(destination.existing.mode) & 511).catch(failed);
1623
+ }
1624
+ if (destination.sync) {
1625
+ await fd.sync().catch(failed);
1626
+ }
1627
+ });
1628
+ await renameOver(temporary, destination.path).catch(failed);
1629
+ } catch (error) {
1630
+ const removed = await removeTemporary(temporary);
1631
+ const context = (
1632
+ /** @type {{context?: Record<string, unknown>}} */
1633
+ error?.context
1634
+ );
1635
+ if (!removed && context !== null && typeof context === "object" && Object.isExtensible(context)) {
1636
+ context.temporaryFile = temporary;
1250
1637
  }
1638
+ throw error;
1639
+ }
1640
+ }
1641
+ /**
1642
+ * Writes the three parts of a QVD, in their order, into an open file.
1643
+ *
1644
+ * @param {import('fs/promises').FileHandle} fd The open file.
1645
+ * @param {Buffer} headerBuffer The header and its terminator.
1646
+ * @param {(error: unknown) => never} failed The write's `rethrowAsIoError` handler.
1647
+ * @private
1648
+ */
1649
+ async _writeParts(fd, headerBuffer, failed) {
1650
+ await this._writeRange(fd, headerBuffer, 0, failed);
1651
+ await this._writeRange(fd, this._symbolBuffer, headerBuffer.length, failed);
1652
+ await this._writeRange(fd, this._indexBuffer, headerBuffer.length + this._symbolBuffer.length, failed);
1653
+ }
1654
+ /**
1655
+ * Writes the whole of one buffer into the file, starting at `filePosition`.
1656
+ *
1657
+ * A write can resolve having written less than it was given, and that is all a disk that fills
1658
+ * part-way through one says. libuv retries a short write(2) itself, and when the retry fails it
1659
+ * returns the bytes that did land instead of the error - `uv__fs_write_all` in its `src/unix/fs.c`,
1660
+ * and `fs__write` on Windows does the same - so Node resolves with a short `bytesWritten` and never
1661
+ * rejects. Taking that for the whole write is how `toQvd()` resolved on a full disk and left a
1662
+ * truncated file: the index table, the last of the three writes, stopped part-way, and no later call
1663
+ * asked the disk again.
1664
+ *
1665
+ * So the rest is written until there is none, and it is the next call that reports the failure: the
1666
+ * disk refuses it outright, and that rejection is a QvdIOError with the system's code, ENOSPC, like
1667
+ * any other refused write. A write that stores nothing and reports nothing would repeat forever, so
1668
+ * it is a failure too, the one QvdIOError with no system code to carry.
1669
+ *
1670
+ * In bounded chunks as well, because Node refuses a single write of 2 GiB or more.
1671
+ *
1672
+ * @param {import('fs/promises').FileHandle} fd The open file.
1673
+ * @param {Buffer} buffer What to write.
1674
+ * @param {number} filePosition Where in the file it starts.
1675
+ * @param {(error: unknown) => never} failed The write's `rethrowAsIoError` handler, which every call
1676
+ * goes through.
1677
+ * @private
1678
+ */
1679
+ async _writeRange(fd, buffer, filePosition, failed) {
1680
+ let done = 0;
1681
+ while (done < buffer.length) {
1682
+ const length = Math.min(WRITE_CHUNK_SIZE, buffer.length - done);
1683
+ const { bytesWritten } = await fd.write(buffer, done, length, filePosition + done).catch(failed);
1684
+ if (!(bytesWritten > 0)) {
1685
+ throw new exports.QvdIOError(
1686
+ `Could not write the QVD file: nothing was written at byte ${filePosition + done}, and the operating system reported no error.`,
1687
+ { file: this._path, operation: "write", filePosition: filePosition + done }
1688
+ );
1689
+ }
1690
+ done += bytesWritten;
1251
1691
  }
1252
1692
  }
1253
1693
  /**
@@ -1259,13 +1699,13 @@ var init_QvdFileWriter = __esm({
1259
1699
  const existingMetadata = this._df.metadata;
1260
1700
  const baseMetadata = existingMetadata ? {
1261
1701
  QvBuildNo: existingMetadata.QvBuildNo || 50667,
1262
- CreatorDoc: existingMetadata.CreatorDoc || crypto__default.default.randomUUID(),
1702
+ CreatorDoc: existingMetadata.CreatorDoc || crypto2__default.default.randomUUID(),
1263
1703
  CreateUtcTime: existingMetadata.CreateUtcTime || creationDate,
1264
1704
  SourceCreateUtcTime: existingMetadata.SourceCreateUtcTime || "",
1265
1705
  SourceFileUtcTime: existingMetadata.SourceFileUtcTime || "",
1266
1706
  SourceFileSize: existingMetadata.SourceFileSize || -1,
1267
1707
  StaleUtcTime: existingMetadata.StaleUtcTime || "",
1268
- TableName: existingMetadata.TableName || path__default.default.basename(this._path, path__default.default.extname(this._path)),
1708
+ TableName: existingMetadata.TableName || path2__default.default.basename(this._path, path2__default.default.extname(this._path)),
1269
1709
  Compression: existingMetadata.Compression || "",
1270
1710
  Comment: existingMetadata.Comment || "",
1271
1711
  EncryptionInfo: existingMetadata.EncryptionInfo || "",
@@ -1279,13 +1719,13 @@ var init_QvdFileWriter = __esm({
1279
1719
  }
1280
1720
  } : {
1281
1721
  QvBuildNo: 50667,
1282
- CreatorDoc: crypto__default.default.randomUUID(),
1722
+ CreatorDoc: crypto2__default.default.randomUUID(),
1283
1723
  CreateUtcTime: creationDate,
1284
1724
  SourceCreateUtcTime: "",
1285
1725
  SourceFileUtcTime: "",
1286
1726
  SourceFileSize: -1,
1287
1727
  StaleUtcTime: "",
1288
- TableName: path__default.default.basename(this._path, path__default.default.extname(this._path)),
1728
+ TableName: path2__default.default.basename(this._path, path2__default.default.extname(this._path)),
1289
1729
  Compression: "",
1290
1730
  Comment: "",
1291
1731
  EncryptionInfo: "",
@@ -1536,7 +1976,7 @@ var init_QvdFileWriter = __esm({
1536
1976
  const key = keys[slot];
1537
1977
  offset = typeof key === "number" ? writeSymbol(columnBuffer, offset, kinds[slot], key, texts[slot]) : writeSymbol(columnBuffer, offset, kinds[slot], null, key);
1538
1978
  }
1539
- assert2__default.default(offset === byteLength, "A column was encoded into a different number of bytes than it was sized for.");
1979
+ assert3__default.default(offset === byteLength, "A column was encoded into a different number of bytes than it was sized for.");
1540
1980
  columnBuffers.push(columnBuffer);
1541
1981
  this._symbolTableMetadata?.push([symbolsOffset, byteLength, containsNull[column]]);
1542
1982
  this._symbolCounts?.push(keys.length);
@@ -1575,9 +2015,9 @@ var init_QvdFileWriter = __esm({
1575
2015
  * @private
1576
2016
  */
1577
2017
  _buildIndexTable() {
1578
- assert2__default.default(this._symbolCounts, "The QVD file symbol table has not been built.");
1579
- assert2__default.default(this._symbolTableMetadata, "The QVD file symbol table metadata has not been built.");
1580
- assert2__default.default(this._symbolIndexByValue, "The QVD file symbol index has not been built.");
2018
+ assert3__default.default(this._symbolCounts, "The QVD file symbol table has not been built.");
2019
+ assert3__default.default(this._symbolTableMetadata, "The QVD file symbol table metadata has not been built.");
2020
+ assert3__default.default(this._symbolIndexByValue, "The QVD file symbol index has not been built.");
1581
2021
  this._indexTableMetadata = [];
1582
2022
  const columns = this._df.columns;
1583
2023
  const data = this._df.data;
@@ -2696,9 +3136,9 @@ var init_QvdColumnTable = __esm({
2696
3136
  * read switches to two-pass filtering.
2697
3137
  * @return {Promise<QvdColumnTable>} The file, as columns.
2698
3138
  */
2699
- static async fromQvd(path3, options = {}) {
3139
+ static async fromQvd(path5, options = {}) {
2700
3140
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
2701
- const reader = new QvdFileReader2(path3, {
3141
+ const reader = new QvdFileReader2(path5, {
2702
3142
  ...readerOptionsFrom(options),
2703
3143
  // This read builds no rows, so the memory guard must not charge it for them. A columnar
2704
3144
  // read of the 38MB taxi fixture completes in a 15MB heap; charged the row cost it was
@@ -2766,15 +3206,17 @@ var QvdFileReader_exports = {};
2766
3206
  __export(QvdFileReader_exports, {
2767
3207
  QvdFileReader: () => exports.QvdFileReader
2768
3208
  });
2769
- function closeReadStream(stream) {
2770
- if (stream.closed) {
2771
- return Promise.resolve();
2772
- }
2773
- return new Promise((resolve) => {
2774
- stream.once("close", () => resolve());
2775
- stream.once("error", () => resolve());
2776
- stream.destroy();
2777
- });
3209
+ async function* chunksFrom(handle, chunkSize, failed) {
3210
+ let position = 0;
3211
+ for (; ; ) {
3212
+ const buffer = Buffer.alloc(chunkSize);
3213
+ const { bytesRead } = await handle.read(buffer, 0, chunkSize, position).catch(failed);
3214
+ if (bytesRead === 0) {
3215
+ return;
3216
+ }
3217
+ yield bytesRead === chunkSize ? buffer : Buffer.from(buffer.subarray(0, bytesRead));
3218
+ position += bytesRead;
3219
+ }
2778
3220
  }
2779
3221
  var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS; exports.QvdFileReader = void 0;
2780
3222
  var init_QvdFileReader = __esm({
@@ -2782,6 +3224,8 @@ var init_QvdFileReader = __esm({
2782
3224
  init_QvdDataFrame();
2783
3225
  init_QvdErrors();
2784
3226
  init_validatePath();
3227
+ init_openChecked();
3228
+ init_ioErrors();
2785
3229
  init_bitUtils();
2786
3230
  init_memoryUtils();
2787
3231
  init_validationUtils();
@@ -2792,7 +3236,7 @@ var init_QvdFileReader = __esm({
2792
3236
  MAX_HEADER_SIZE = 16 * 1024 * 1024;
2793
3237
  READ_CHUNK_SIZE = 512 * 1024 * 1024;
2794
3238
  ANALYSIS_SLICE_ROWS = 65536;
2795
- __name(closeReadStream, "closeReadStream");
3239
+ __name(chunksFrom, "chunksFrom");
2796
3240
  exports.QvdFileReader = class {
2797
3241
  static {
2798
3242
  __name(this, "QvdFileReader");
@@ -2853,7 +3297,9 @@ var init_QvdFileReader = __esm({
2853
3297
  signal
2854
3298
  } = options;
2855
3299
  this._materialisesRows = materialisesRows;
2856
- this._path = validatePath(filePath, allowedDir);
3300
+ const checked = checkPath(filePath, allowedDir);
3301
+ this._path = checked.path;
3302
+ this._allowedDir = checked.base;
2857
3303
  this._duals = normaliseDuals(duals, this._path);
2858
3304
  this._coerceNumericStrings = normaliseCoerceNumericStrings(coerceNumericStrings, this._path);
2859
3305
  this._memorySafetyFactor = memorySafetyFactor;
@@ -2938,18 +3384,21 @@ var init_QvdFileReader = __esm({
2938
3384
  * two-pass path exist for.
2939
3385
  *
2940
3386
  * Algorithm for a windowed read:
2941
- * 1. Stream-read the file until XML header delimiter is found
3387
+ * 1. Read the file a chunk at a time until the XML header delimiter is found
2942
3388
  * 2. Parse header to determine symbol table and index table locations
2943
3389
  * 3. Calculate bytes needed: header + full symbol table + partial index table
2944
- * 4. Read only those calculated bytes using fs.open/read
3390
+ * 4. Read only those calculated bytes, by position
2945
3391
  * 5. Rest of parsing proceeds normally with limited data
2946
3392
  *
2947
3393
  * WHY THIS APPROACH:
2948
3394
  * - Symbol table must be fully loaded (contains all unique values)
2949
3395
  * - Index table can be partially loaded (only rows we need)
2950
- * - Streaming for header finding is efficient for unknown header sizes
3396
+ * - Reading chunks to find the header is efficient for unknown header sizes
2951
3397
  * - Direct byte-range reading for remaining data is fastest
2952
3398
  *
3399
+ * All of it goes through one handle, opened once, on the file the containment check approved. See
3400
+ * `chunksFrom` and `openChecked` for why a read no longer opens the path more than once.
3401
+ *
2953
3402
  * A window with a non-zero `offset` reads two ranges rather than one: the header and symbol
2954
3403
  * table from the front of the file, and the window's records from wherever they sit. The bytes
2955
3404
  * between are never read, which is what makes `{offset: 1_700_000, limit: 100}` on the taxi
@@ -2967,49 +3416,49 @@ var init_QvdFileReader = __esm({
2967
3416
  async _readData(window = { offset: 0, limit: null }, headerOnly = false, liveRows = null) {
2968
3417
  this._throwIfAborted();
2969
3418
  this._emitProgress("read", 0, 1);
3419
+ const failed = rethrowAsIoError(this._path, "read");
3420
+ const handle = await openChecked(checkPath(this._path, this._allowedDir), "read", failed);
3421
+ await closeAfter(handle, failed, () => this._readFrom(handle, window, headerOnly, liveRows, failed));
3422
+ }
3423
+ /**
3424
+ * Reads what `_readData` was asked for, through the handle it opened.
3425
+ *
3426
+ * @param {import('fs/promises').FileHandle} handle The open file.
3427
+ * @param {QvdRowWindow} window The rows to read.
3428
+ * @param {boolean} headerOnly Stop once the XML header has been read.
3429
+ * @param {{rows: number, perChunk: number}|null} liveRows Rows held at one instant - see `_prepare`.
3430
+ * @param {(error: unknown) => never} failed The read's `rethrowAsIoError` handler.
3431
+ * @private
3432
+ */
3433
+ async _readFrom(handle, window, headerOnly, liveRows, failed) {
2970
3434
  const HEADER_DELIMITER = "\r\n\0";
2971
3435
  const CHUNK_SIZE = 64 * 1024;
2972
- const stream = fs__default.default.createReadStream(this._path, {
2973
- highWaterMark: CHUNK_SIZE
2974
- });
2975
3436
  const headerChunks = [];
2976
3437
  let headerBytes = 0;
2977
3438
  let tail = Buffer.alloc(0);
2978
3439
  let headerDelimiterIndex = -1;
2979
- try {
2980
- for await (const chunk of stream) {
2981
- const chunkStart = headerBytes;
2982
- const searchBuffer = tail.length > 0 ? Buffer.concat([tail, chunk]) : chunk;
2983
- const foundInSearch = searchBuffer.indexOf(HEADER_DELIMITER);
2984
- headerChunks.push(chunk);
2985
- headerBytes += chunk.length;
2986
- if (foundInSearch !== -1) {
2987
- headerDelimiterIndex = chunkStart - tail.length + foundInSearch;
2988
- stream.destroy();
2989
- break;
2990
- }
2991
- tail = Buffer.from(searchBuffer.subarray(-(HEADER_DELIMITER.length - 1)));
2992
- if (headerBytes > MAX_HEADER_SIZE) {
2993
- stream.destroy();
2994
- throw new exports.QvdCorruptedError(
2995
- `The XML header delimiter was not found within the first ${MAX_HEADER_SIZE / (1024 * 1024)}MB of the file.`,
2996
- {
2997
- file: this._path,
2998
- bytesSearched: headerBytes,
2999
- maxHeaderSize: MAX_HEADER_SIZE,
3000
- stage: "readData"
3001
- }
3002
- );
3003
- }
3440
+ for await (const chunk of chunksFrom(handle, CHUNK_SIZE, failed)) {
3441
+ const chunkStart = headerBytes;
3442
+ const searchBuffer = tail.length > 0 ? Buffer.concat([tail, chunk]) : chunk;
3443
+ const foundInSearch = searchBuffer.indexOf(HEADER_DELIMITER);
3444
+ headerChunks.push(chunk);
3445
+ headerBytes += chunk.length;
3446
+ if (foundInSearch !== -1) {
3447
+ headerDelimiterIndex = chunkStart - tail.length + foundInSearch;
3448
+ break;
3004
3449
  }
3005
- } catch (error) {
3006
- const isExpectedEarlyClose = headerDelimiterIndex !== -1 && error !== null && typeof error === "object" && /** @type {{code?: unknown}} */
3007
- error.code === "ERR_STREAM_PREMATURE_CLOSE";
3008
- if (!isExpectedEarlyClose) {
3009
- throw error;
3450
+ tail = Buffer.from(searchBuffer.subarray(-(HEADER_DELIMITER.length - 1)));
3451
+ if (headerBytes > MAX_HEADER_SIZE) {
3452
+ throw new exports.QvdCorruptedError(
3453
+ `The XML header delimiter was not found within the first ${MAX_HEADER_SIZE / (1024 * 1024)}MB of the file.`,
3454
+ {
3455
+ file: this._path,
3456
+ bytesSearched: headerBytes,
3457
+ maxHeaderSize: MAX_HEADER_SIZE,
3458
+ stage: "readData"
3459
+ }
3460
+ );
3010
3461
  }
3011
- } finally {
3012
- await closeReadStream(stream);
3013
3462
  }
3014
3463
  if (headerDelimiterIndex === -1) {
3015
3464
  throw new exports.QvdCorruptedError(
@@ -3050,9 +3499,9 @@ var init_QvdFileReader = __esm({
3050
3499
  (value) => Number.isSafeInteger(value) && value >= 0
3051
3500
  );
3052
3501
  if (headerNumbersUsable) {
3053
- const { size: fileSize } = await fs__default.default.promises.stat(this._path);
3054
- this._fileSize = fileSize;
3055
- this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
3502
+ const { size: fileSize2 } = await handle.stat().catch(failed);
3503
+ this._fileSize = fileSize2;
3504
+ this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize2;
3056
3505
  }
3057
3506
  const resolved = headerNumbersUsable ? resolveWindow(window, totalRows) : { offset: 0, limit: 0 };
3058
3507
  const windowRows = resolved.limit;
@@ -3069,7 +3518,7 @@ var init_QvdFileReader = __esm({
3069
3518
  );
3070
3519
  }
3071
3520
  if (window.offset === 0 && window.limit === null) {
3072
- this._buffer = await fs__default.default.promises.readFile(this._path);
3521
+ this._buffer = await handle.readFile().catch(failed);
3073
3522
  this._fileSize = this._buffer.length;
3074
3523
  this._bufferFirstRow = 0;
3075
3524
  this._emitProgress("read", 1, 1);
@@ -3095,34 +3544,29 @@ var init_QvdFileReader = __esm({
3095
3544
  const indexTableBytesToRead = rowsToLoad * recordSize;
3096
3545
  const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
3097
3546
  const fileBytesRequired = indexTableOffset + skippedIndexBytes + indexTableBytesToRead;
3098
- const fd = await fs__default.default.promises.open(this._path, "r");
3099
- try {
3100
- const { size: fileSize } = await fd.stat();
3101
- this._fileSize = fileSize;
3102
- if (fileBytesRequired > fileSize) {
3103
- throw new exports.QvdCorruptedError("The file is shorter than its header claims.", {
3104
- file: this._path,
3105
- fileSize,
3106
- requiredBytes: fileBytesRequired,
3107
- stage: "readData"
3108
- });
3109
- }
3110
- this._buffer = Buffer.alloc(totalBytesToRead);
3111
- await this._readRange(fd, 0, indexTableOffset, 0, fileSize, totalBytesToRead);
3112
- if (indexTableBytesToRead > 0) {
3113
- await this._readRange(
3114
- fd,
3115
- indexTableOffset,
3116
- indexTableBytesToRead,
3117
- indexTableOffset + skippedIndexBytes,
3118
- fileSize,
3119
- fileBytesRequired
3120
- );
3121
- }
3122
- this._bufferFirstRow = resolved.offset;
3123
- } finally {
3124
- await fd.close();
3547
+ const { size: fileSize } = await handle.stat().catch(failed);
3548
+ this._fileSize = fileSize;
3549
+ if (fileBytesRequired > fileSize) {
3550
+ throw new exports.QvdCorruptedError("The file is shorter than its header claims.", {
3551
+ file: this._path,
3552
+ fileSize,
3553
+ requiredBytes: fileBytesRequired,
3554
+ stage: "readData"
3555
+ });
3556
+ }
3557
+ this._buffer = Buffer.alloc(totalBytesToRead);
3558
+ await this._readRange(handle, 0, indexTableOffset, 0, fileSize, totalBytesToRead);
3559
+ if (indexTableBytesToRead > 0) {
3560
+ await this._readRange(
3561
+ handle,
3562
+ indexTableOffset,
3563
+ indexTableBytesToRead,
3564
+ indexTableOffset + skippedIndexBytes,
3565
+ fileSize,
3566
+ fileBytesRequired
3567
+ );
3125
3568
  }
3569
+ this._bufferFirstRow = resolved.offset;
3126
3570
  this._emitProgress("read", 1, 1);
3127
3571
  }
3128
3572
  /**
@@ -3141,11 +3585,12 @@ var init_QvdFileReader = __esm({
3141
3585
  * @private
3142
3586
  */
3143
3587
  async _readRange(fd, bufferOffset, byteCount, filePosition, fileSize, requiredBytes) {
3144
- assert2__default.default(this._buffer, "The read buffer has not been allocated.");
3588
+ assert3__default.default(this._buffer, "The read buffer has not been allocated.");
3589
+ const failed = rethrowAsIoError(this._path, "read");
3145
3590
  let done = 0;
3146
3591
  while (done < byteCount) {
3147
3592
  const length = Math.min(READ_CHUNK_SIZE, byteCount - done);
3148
- const { bytesRead } = await fd.read(this._buffer, bufferOffset + done, length, filePosition + done);
3593
+ const { bytesRead } = await fd.read(this._buffer, bufferOffset + done, length, filePosition + done).catch(failed);
3149
3594
  if (bytesRead === 0) {
3150
3595
  throw new exports.QvdCorruptedError("Unexpected end of file while reading QVD data.", {
3151
3596
  file: this._path,
@@ -3280,7 +3725,7 @@ var init_QvdFileReader = __esm({
3280
3725
  }
3281
3726
  this._fieldBitMetadataValidated = true;
3282
3727
  }
3283
- assert2__default.default(
3728
+ assert3__default.default(
3284
3729
  rowsToLoad === 0 || recordSize === 0 || Math.floor(indexBuffer.length / recordSize) >= rowsToLoad,
3285
3730
  `The index table holds ${Math.floor(indexBuffer.length / (recordSize || 1))} whole records but ${rowsToLoad} were validated as present.`
3286
3731
  );
@@ -3457,7 +3902,7 @@ var init_QvdFileReader = __esm({
3457
3902
  await this._parseHeader();
3458
3903
  this._emitProgress("header", 1, 1);
3459
3904
  this._throwIfAborted();
3460
- assert2__default.default(this._header, "The QVD file header has not been parsed.");
3905
+ assert3__default.default(this._header, "The QVD file header has not been parsed.");
3461
3906
  const header = this._header["QvdTableHeader"];
3462
3907
  let fields = header["Fields"]?.["QvdFieldHeader"] ?? [];
3463
3908
  if (!Array.isArray(fields)) {
@@ -3521,9 +3966,10 @@ var init_QvdFileReader = __esm({
3521
3966
  * Shares every step with `load()` up to the point where rows would be built - see `_prepare`.
3522
3967
  * What it keeps instead is what the decoder already produced: one `Int32Array` of stored
3523
3968
  * indices per field, and one resolved value per distinct symbol. On the 1.7M x 20 taxi
3524
- * fixture that is 38.6 MiB against the 352.8 MiB `data` retains, because a column costs four
3969
+ * fixture that is 133 MiB against the 352 MiB `data` retains, because a column costs four
3525
3970
  * bytes per row rather than a boxed value per cell, and the symbols are a few thousand
3526
- * entries shared across every row that uses them.
3971
+ * entries shared across every row that uses them. The codes' storage lives outside the V8
3972
+ * heap: 3 MiB of the 133 is on it.
3527
3973
  *
3528
3974
  * @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} [window]
3529
3975
  * The rows to decode, in the same spellings `load()` accepts.
@@ -3534,7 +3980,7 @@ var init_QvdFileReader = __esm({
3534
3980
  const prepared = await this._prepare(rows, null, true);
3535
3981
  await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
3536
3982
  const { QvdColumnTable: QvdColumnTable2 } = await Promise.resolve().then(() => (init_QvdColumnTable(), QvdColumnTable_exports));
3537
- assert2__default.default(this._indexColumns, "The QVD file index table has not been parsed.");
3983
+ assert3__default.default(this._indexColumns, "The QVD file index table has not been parsed.");
3538
3984
  return new QvdColumnTable2({
3539
3985
  columns: prepared.columns,
3540
3986
  codesByField: this._indexColumns,
@@ -3631,7 +4077,7 @@ var init_QvdFileReader = __esm({
3631
4077
  await this._parseHeader();
3632
4078
  this._emitProgress("header", 1, 1);
3633
4079
  this._throwIfAborted();
3634
- assert2__default.default(this._header, "The QVD file header has not been parsed.");
4080
+ assert3__default.default(this._header, "The QVD file header has not been parsed.");
3635
4081
  const totalRows = parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10);
3636
4082
  const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
3637
4083
  const resolved = resolveWindow(window, totalRows);
@@ -3645,9 +4091,9 @@ var init_QvdFileReader = __esm({
3645
4091
  }
3646
4092
  }
3647
4093
  await this._parseSymbolTable(symbolsToKeep, rowsAvailable, liveRows);
3648
- assert2__default.default(this._symbolTable, "The QVD file symbol table has not been parsed.");
4094
+ assert3__default.default(this._symbolTable, "The QVD file symbol table has not been parsed.");
3649
4095
  this._throwIfAborted();
3650
- assert2__default.default(this._selectedFields, "The QVD file fields have not been resolved.");
4096
+ assert3__default.default(this._selectedFields, "The QVD file fields have not been resolved.");
3651
4097
  const resolvedByField = [];
3652
4098
  const halvesByField = [];
3653
4099
  const entries = [];
@@ -3708,7 +4154,7 @@ var init_QvdFileReader = __esm({
3708
4154
  * @private
3709
4155
  */
3710
4156
  _buildRows(resolvedByField, progressBase, progressTotal) {
3711
- assert2__default.default(this._indexColumns, "The QVD file index table has not been parsed.");
4157
+ assert3__default.default(this._indexColumns, "The QVD file index table has not been parsed.");
3712
4158
  const indexColumns = this._indexColumns;
3713
4159
  const fieldCount = indexColumns.length;
3714
4160
  const rowCount = this._rowsDecoded;
@@ -4313,14 +4759,31 @@ var init_QvdDataFrame = __esm({
4313
4759
  * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
4314
4760
  * back to the working directory rather than removing the restriction.
4315
4761
  * @param {Function} [options.onProgress] Optional progress callback function that receives progress updates during write operations.
4762
+ * @param {boolean} [options.atomic=true] Whether to replace the file rather than rewrite it. The QVD
4763
+ * is built beside it under a temporary name and renamed over it, so a write that fails leaves the
4764
+ * previous file exactly as it was, and a reader of the path - a Qlik reload, say - sees the old
4765
+ * file or the new one and never a part of either. False rewrites the file where it stands, as
4766
+ * every version before 2.0.0 did: that keeps its identity, including hard links, its owner and
4767
+ * permissions set on the file itself, and needs neither room for two copies nor permission to
4768
+ * create files in the directory. It also destroys the previous file as the write begins. A file
4769
+ * that does not exist yet is renamed into place either way, since there is nothing to rewrite.
4770
+ * @param {boolean} [options.fsync=true] Whether to wait for the contents to reach the disk before
4771
+ * resolving. It is what carries an atomic write's promise through a power loss - the contents are
4772
+ * on the disk before anything is renamed, so a crash leaves the previous file or the new one and
4773
+ * never a damaged one - and what reports a failing disk's deferred error instead of losing it. The
4774
+ * rename itself is not flushed, so a crash just after the call can still lose the replacement, or
4775
+ * a file that did not exist before. False resolves once the operating system has accepted the
4776
+ * bytes, which is faster and is what every version before 2.0.0 did.
4316
4777
  */
4317
- async toQvd(path3, options = {}) {
4778
+ async toQvd(path5, options = {}) {
4318
4779
  const { QvdFileWriter: QvdFileWriter2 } = await Promise.resolve().then(() => (init_QvdFileWriter(), QvdFileWriter_exports));
4319
4780
  const writerOptions = {
4320
4781
  allowedDir: options.allowedDir,
4321
- onProgress: options.onProgress
4782
+ onProgress: options.onProgress,
4783
+ atomic: options.atomic,
4784
+ fsync: options.fsync
4322
4785
  };
4323
- await new QvdFileWriter2(path3, this, writerOptions).save();
4786
+ await new QvdFileWriter2(path5, this, writerOptions).save();
4324
4787
  }
4325
4788
  /**
4326
4789
  * Loads a QVD file and returns its data frame.
@@ -4373,9 +4836,9 @@ var init_QvdDataFrame = __esm({
4373
4836
  * is not one of its modes, or if `coerceNumericStrings` is not a boolean.
4374
4837
  * @return {Promise<QvdDataFrame>} The data frame of the QVD file.
4375
4838
  */
4376
- static async fromQvd(path3, options = {}) {
4839
+ static async fromQvd(path5, options = {}) {
4377
4840
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
4378
- return await new QvdFileReader2(path3, readerOptionsFrom(options)).load(windowFrom(options));
4841
+ return await new QvdFileReader2(path5, readerOptionsFrom(options)).load(windowFrom(options));
4379
4842
  }
4380
4843
  /**
4381
4844
  * Reads a QVD file in chunks, as an async generator of data frames.
@@ -4422,9 +4885,9 @@ var init_QvdDataFrame = __esm({
4422
4885
  * windowed read switches to two-pass filtering.
4423
4886
  * @return {AsyncGenerator<QvdDataFrame>} The chunks, in file order.
4424
4887
  */
4425
- static async *iterate(path3, options = {}) {
4888
+ static async *iterate(path5, options = {}) {
4426
4889
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
4427
- const reader = new QvdFileReader2(path3, readerOptionsFrom(options));
4890
+ const reader = new QvdFileReader2(path5, readerOptionsFrom(options));
4428
4891
  yield* reader.iterateRows(windowFrom(options), options.chunkSize === void 0 ? 1e5 : options.chunkSize);
4429
4892
  }
4430
4893
  /**
@@ -4455,9 +4918,9 @@ var init_QvdDataFrame = __esm({
4455
4918
  * @param {AbortSignal} [options.signal] Cancels the read, rejecting with `signal.reason`.
4456
4919
  * @return {Promise<QvdFileMetadata>} The file's schema and header metadata.
4457
4920
  */
4458
- static async readMetadata(path3, options = {}) {
4921
+ static async readMetadata(path5, options = {}) {
4459
4922
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
4460
- return await new QvdFileReader2(path3, metadataOptionsFrom(options)).loadMetadata();
4923
+ return await new QvdFileReader2(path5, metadataOptionsFrom(options)).loadMetadata();
4461
4924
  }
4462
4925
  /**
4463
4926
  * Constructs a data frame from a dictionary.