@blamejs/core 0.18.54 → 0.18.55
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/CHANGELOG.md +110 -0
- package/NOTICE +1 -1
- package/README.md +3 -3
- package/lib/agent-audit.js +27 -2
- package/lib/audit-sign.js +24 -5
- package/lib/auth/passkey.js +4 -1
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-filename.js +8 -1
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +24 -3
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +6 -4
- package/lib/mail-auth.js +59 -2
- package/lib/mail-server-imap.js +65 -34
- package/lib/mail-server-managesieve.js +50 -37
- package/lib/mail-server-mx.js +115 -34
- package/lib/mail-server-net.js +155 -1
- package/lib/mail-server-pop3.js +16 -19
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +162 -33
- package/lib/mail-server-tls.js +48 -3
- package/lib/mcp.js +11 -3
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-tls.js +18 -0
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +396 -37
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/db.js
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
*/
|
|
46
46
|
var nodeFs = require("node:fs");
|
|
47
47
|
var nodePath = require("node:path");
|
|
48
|
+
var nodeUrl = require("node:url");
|
|
48
49
|
var { DatabaseSync } = require("node:sqlite");
|
|
49
50
|
var { Readable } = require("node:stream");
|
|
50
51
|
var atomicFile = require("./atomic-file");
|
|
@@ -74,6 +75,7 @@ var safeJson = require("./safe-json");
|
|
|
74
75
|
var safeSql = require("./safe-sql");
|
|
75
76
|
var sql = require("./sql");
|
|
76
77
|
var validateOpts = require("./validate-opts");
|
|
78
|
+
var pidProbe = require("./pid-probe");
|
|
77
79
|
var vault = require("./vault");
|
|
78
80
|
var vaultAad = require("./vault-aad");
|
|
79
81
|
|
|
@@ -85,6 +87,11 @@ var vaultAad = require("./vault-aad");
|
|
|
85
87
|
var _SQL_OPTS = { dialect: "sqlite", quoteName: true };
|
|
86
88
|
|
|
87
89
|
var DbError = defineClass("DbError", { alwaysPermanent: true });
|
|
90
|
+
|
|
91
|
+
// Sidecar naming the process that owns an encrypted-mode working copy, so the
|
|
92
|
+
// stale sweep can ask whether that process is still running instead of reading
|
|
93
|
+
// the filename as proof it is not.
|
|
94
|
+
var OWNER_SUFFIX = ".owner";
|
|
88
95
|
var WormViolationError = require("./framework-error").WormViolationError;
|
|
89
96
|
var _wormErr = WormViolationError.factory;
|
|
90
97
|
|
|
@@ -161,6 +168,15 @@ var statfsProbe = null; // free-space reader (fs.statfsSync; injectable f
|
|
|
161
168
|
// reload); the flag makes it idempotent. The handler reads live module
|
|
162
169
|
// state at exit time, so a later re-init is still covered.
|
|
163
170
|
var _exitHandlerRegistered = false;
|
|
171
|
+
// A read-only open never writes back. Two processes sharing one encrypted
|
|
172
|
+
// volume each decrypt their own working copy at open time, so whichever
|
|
173
|
+
// flushes last overwrites what the other wrote since. A reader that promises
|
|
174
|
+
// not to flush cannot take part in that: it reads the snapshot it decrypted
|
|
175
|
+
// and leaves db.enc exactly as it found it. Held as module state so the ONE
|
|
176
|
+
// place that writes db.enc can honour it, rather than each of the three
|
|
177
|
+
// callers remembering to.
|
|
178
|
+
var readOnly = false;
|
|
179
|
+
var immutableOpen = false;
|
|
164
180
|
var dataDir = null;
|
|
165
181
|
var initialized = false;
|
|
166
182
|
// Monotonic identity of the live `database` handle. Bumped on every (re)open,
|
|
@@ -717,14 +733,75 @@ var log = boot("db");
|
|
|
717
733
|
|
|
718
734
|
// ---- Tmpfs detection ----
|
|
719
735
|
|
|
720
|
-
|
|
736
|
+
// Where the decrypted working copy lives, and whether that place is in memory.
|
|
737
|
+
//
|
|
738
|
+
// Both questions take the platform and the filesystem reader as parameters. A
|
|
739
|
+
// rule about what Windows does is untestable from a Linux CI host while it is
|
|
740
|
+
// reached only by running on Windows, and these two rules are the difference
|
|
741
|
+
// between encrypted-at-rest holding and silently not holding.
|
|
742
|
+
|
|
743
|
+
// The /dev/shm probe is Linux-only, and the guard carries weight rather than
|
|
744
|
+
// tidiness: a leading-slash path on Windows is DRIVE-relative, so asking
|
|
745
|
+
// existsSync("/dev/shm") there asks about C:\dev\shm. That is ordinary
|
|
746
|
+
// persistent NTFS, it is trivially created, and answering yes puts the
|
|
747
|
+
// decrypted database on exactly the disk atRest: "encrypted" exists to keep it
|
|
748
|
+
// off — with the residency check below unable to fire, because it too can only
|
|
749
|
+
// read a mount table by path on Linux.
|
|
750
|
+
function _resolveTmpDirFrom(optsTmpDir, platform, exists) {
|
|
721
751
|
if (optsTmpDir) return optsTmpDir;
|
|
722
752
|
var envTmp = safeEnv.readVar("BLAMEJS_TMPDIR");
|
|
723
753
|
if (envTmp) return envTmp;
|
|
724
|
-
if (
|
|
754
|
+
if (platform !== "linux") return null;
|
|
755
|
+
if (exists("/dev/shm")) return "/dev/shm";
|
|
725
756
|
return null;
|
|
726
757
|
}
|
|
727
758
|
|
|
759
|
+
function resolveTmpDir(optsTmpDir) {
|
|
760
|
+
return _resolveTmpDirFrom(optsTmpDir, process.platform, nodeFs.existsSync);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Returns null when the path is a recognized in-memory mount, or { determined,
|
|
764
|
+
// message } describing why it is not. The caller decides what each costs.
|
|
765
|
+
//
|
|
766
|
+
// The two cases are not the same finding, and collapsing them makes the posture
|
|
767
|
+
// worse rather than stricter. On Linux the mount table can be compared against,
|
|
768
|
+
// so a path outside the known tmpfs mounts is a POSITIVE determination that the
|
|
769
|
+
// working copy would land on disk: determined, and refused. Off Linux nothing
|
|
770
|
+
// can be compared — the heuristic is a path match against mounts only Linux has
|
|
771
|
+
// — so the honest report is that we do not know, and an operator who named the
|
|
772
|
+
// path is told rather than refused.
|
|
773
|
+
//
|
|
774
|
+
// Refusing the undetermined case would mean every macOS and Windows operator
|
|
775
|
+
// setting allowNonTmpfsTmpDir: true to boot at all, and that flag travels: set
|
|
776
|
+
// once in shared config, it also switches off the Linux check, which is the one
|
|
777
|
+
// that works. A warning everyone sees beats a flag everyone sets.
|
|
778
|
+
function _tmpDirResidencyIssue(tmpDir, platform, realpath) {
|
|
779
|
+
if (platform !== "linux") {
|
|
780
|
+
return {
|
|
781
|
+
determined: false,
|
|
782
|
+
message: "db.init: tmpDir '" + tmpDir + "' cannot be shown to be an in-memory " +
|
|
783
|
+
"mount on " + platform + " — the tmpfs heuristic compares against /dev/shm " +
|
|
784
|
+
"/run/shm /run/user /tmp, which are Linux mounts. If it is disk-backed, the " +
|
|
785
|
+
"decrypted working copy reaches backup snapshots, replication, and forensic " +
|
|
786
|
+
"disk images; verify the mount is in-memory out-of-band, or pass " +
|
|
787
|
+
"atRest: 'plain' if encryption-at-rest is not required.",
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
var realTmp = "";
|
|
791
|
+
try { realTmp = realpath(tmpDir); } catch (_e) { /* stat best-effort */ }
|
|
792
|
+
if (realTmp.indexOf("/dev/shm") === 0 || realTmp.indexOf("/run/shm") === 0 ||
|
|
793
|
+
realTmp.indexOf("/run/user/") === 0 || realTmp.indexOf("/tmp") === 0) {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
determined: true,
|
|
798
|
+
message: "db.init: tmpDir '" + tmpDir + "' (real: '" + realTmp +
|
|
799
|
+
"') does not resolve under /dev/shm /run/shm /run/user /tmp — it is not a " +
|
|
800
|
+
"recognized tmpfs mount. A persistent-disk tmpDir leaks the decrypted " +
|
|
801
|
+
"working copy into backup snapshots, replication, and forensic disk images.",
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
728
805
|
// ---- DB encryption key management ----
|
|
729
806
|
|
|
730
807
|
// AAD binds the sealed DB encryption key to the deployment's dataDir
|
|
@@ -765,7 +842,14 @@ function loadOrCreateDbKey(dataDirPath, keyPathOverride) {
|
|
|
765
842
|
// the next boot is AAD-verified. Read-migration preserves the key
|
|
766
843
|
// bytes — no re-key, no operator action.
|
|
767
844
|
b64 = vault.unseal(sealed);
|
|
768
|
-
|
|
845
|
+
// The re-seal is a convenience, not part of reading the key: by this
|
|
846
|
+
// point the bytes are already in hand and the open would succeed without
|
|
847
|
+
// it. So a read-only handle unseals and moves on. Writing here would
|
|
848
|
+
// fail outright on a genuinely read-only mount, taking down an open that
|
|
849
|
+
// had no need to write, and on a writable one it would modify a volume
|
|
850
|
+
// this handle promised not to touch — to perform a rewrite the next
|
|
851
|
+
// writer performs anyway.
|
|
852
|
+
if (b64 && !readOnly) {
|
|
769
853
|
atomicFile.writeSync(keyPath, vaultAad.seal(b64, aad), { fileMode: 0o600 });
|
|
770
854
|
log("re-sealed DB encryption key with deployment-path binding at " + keyPath);
|
|
771
855
|
}
|
|
@@ -777,6 +861,17 @@ function loadOrCreateDbKey(dataDirPath, keyPathOverride) {
|
|
|
777
861
|
return Buffer.from(b64, "base64");
|
|
778
862
|
}
|
|
779
863
|
// First run — generate, AAD-seal, persist (atomic).
|
|
864
|
+
//
|
|
865
|
+
// A read-only open has no first run. There is no key, so there is no volume
|
|
866
|
+
// encrypted under one, so there is nothing for a reader to read. Generating
|
|
867
|
+
// one would write the file this handle promised not to write and then hand
|
|
868
|
+
// back an empty database indistinguishable from a successful read of an
|
|
869
|
+
// empty volume. Saying which of the two happened is the more useful answer.
|
|
870
|
+
if (readOnly) {
|
|
871
|
+
throw _dbErr("db/read-only-no-key",
|
|
872
|
+
"readOnly: no encryption key at " + keyPath + " — a read-only open reads an " +
|
|
873
|
+
"existing encrypted volume and will not create one");
|
|
874
|
+
}
|
|
780
875
|
var raw = generateBytes(C.BYTES.bytes(32));
|
|
781
876
|
var sealedKey = vaultAad.seal(raw.toString("base64"), aad);
|
|
782
877
|
atomicFile.writeSync(keyPath, sealedKey, { fileMode: 0o600 });
|
|
@@ -945,11 +1040,116 @@ function _installWriteGate() {
|
|
|
945
1040
|
};
|
|
946
1041
|
}
|
|
947
1042
|
|
|
1043
|
+
// Null when the main database file is known to carry every committed
|
|
1044
|
+
// transaction, or the reason that could not be established.
|
|
1045
|
+
//
|
|
1046
|
+
// Three places treat the main file as the whole database: the immutable open,
|
|
1047
|
+
// snapshot(), and the flush to db.enc. It is the whole database only once the
|
|
1048
|
+
// write-ahead log has been folded into it, and each of these would otherwise
|
|
1049
|
+
// produce a database that opens cleanly while missing its newest rows — worse
|
|
1050
|
+
// than failing outright, because nothing downstream can tell.
|
|
1051
|
+
//
|
|
1052
|
+
// The length of the log is not the question. A passive or automatic checkpoint
|
|
1053
|
+
// copies frames into the main file and leaves the log allocated behind it, so a
|
|
1054
|
+
// non-empty log does not by itself mean anything is missing, and refusing on
|
|
1055
|
+
// size alone would reject snapshots that are perfectly complete.
|
|
1056
|
+
//
|
|
1057
|
+
// Where the handle can write, the checkpoint itself reports what it moved, and
|
|
1058
|
+
// that report is the answer. Where it cannot, there is no way to ask without
|
|
1059
|
+
// writing. Both "cannot ask" and "cannot read the log" are unknown rather than
|
|
1060
|
+
// empty, and unknown fails closed: a log that is missing is a database with no
|
|
1061
|
+
// log, but a log that errors on stat is a log we know nothing about.
|
|
1062
|
+
// Stat and checkpoint are parameters for the same reason the platform is
|
|
1063
|
+
// elsewhere in this file: "an unreadable log must not read as an absent one"
|
|
1064
|
+
// and "an allocated log that is fully checkpointed is complete" are both rules
|
|
1065
|
+
// about states that are awkward to arrange for real and easy to describe.
|
|
1066
|
+
function _walNotDrainedFrom(forPath, canCheckpoint, stat, checkpoint) {
|
|
1067
|
+
var walStat = null;
|
|
1068
|
+
try { walStat = stat(forPath + "-wal"); }
|
|
1069
|
+
catch (e) {
|
|
1070
|
+
if (e && e.code === "ENOENT") return null;
|
|
1071
|
+
return "the write-ahead log '" + forPath + "-wal' could not be read (" +
|
|
1072
|
+
((e && e.code) || "unknown error") + "), so whether the database file holds " +
|
|
1073
|
+
"every committed transaction cannot be established";
|
|
1074
|
+
}
|
|
1075
|
+
if (walStat.size === 0) return null;
|
|
1076
|
+
|
|
1077
|
+
if (!canCheckpoint) {
|
|
1078
|
+
return "'" + forPath + "-wal' holds " + walStat.size + " byte(s) and this handle " +
|
|
1079
|
+
"cannot checkpoint, so whether those frames are already in the database file " +
|
|
1080
|
+
"cannot be established without writing";
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// PRAGMA wal_checkpoint reports (busy, log, checkpointed): total frames in
|
|
1084
|
+
// the log, and how many of them reached the database file.
|
|
1085
|
+
var row = null;
|
|
1086
|
+
try { row = checkpoint(); }
|
|
1087
|
+
catch (e) {
|
|
1088
|
+
return "the checkpoint that folds the write-ahead log into the database file " +
|
|
1089
|
+
"failed (" + ((e && e.message) || String(e)) + ")";
|
|
1090
|
+
}
|
|
1091
|
+
if (!row) return null;
|
|
1092
|
+
if (Number(row.busy) !== 0) {
|
|
1093
|
+
return "the checkpoint could not run to completion because the database was busy, " +
|
|
1094
|
+
"so '" + forPath + "-wal' may hold transactions the database file does not";
|
|
1095
|
+
}
|
|
1096
|
+
var frames = Number(row.log) || 0;
|
|
1097
|
+
var moved = Number(row.checkpointed) || 0;
|
|
1098
|
+
if (frames > moved) {
|
|
1099
|
+
return "the checkpoint left " + (frames - moved) + " of " + frames + " frame(s) in '" +
|
|
1100
|
+
forPath + "-wal', so the database file does not carry every committed transaction";
|
|
1101
|
+
}
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function _walNotDrained(forPath, canCheckpoint) {
|
|
1106
|
+
return _walNotDrainedFrom(forPath, canCheckpoint, nodeFs.statSync, function () {
|
|
1107
|
+
return database.prepare("PRAGMA wal_checkpoint(TRUNCATE)").all()[0];
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
|
|
948
1111
|
function encryptToDisk() {
|
|
949
1112
|
if (!encPath) return;
|
|
950
|
-
//
|
|
951
|
-
|
|
952
|
-
|
|
1113
|
+
// A read-only open promised not to write db.enc, and this is the one place
|
|
1114
|
+
// that would. The periodic timer, close() and the exit handler all arrive
|
|
1115
|
+
// here, so honouring the promise once covers all three.
|
|
1116
|
+
if (readOnly) return;
|
|
1117
|
+
// The checkpoint that folds committed transactions into the .db file runs
|
|
1118
|
+
// below, beside the read of the file it drains into, so its result is
|
|
1119
|
+
// available where the decision is made. It used to run here and discard that
|
|
1120
|
+
// result, which is how a checkpoint that quietly did nothing could send a
|
|
1121
|
+
// db.enc short of its newest commits and report the flush as successful.
|
|
1122
|
+
// A missing working copy used to return here as though the flush had
|
|
1123
|
+
// succeeded. It never can have: the source of the bytes is gone, so nothing
|
|
1124
|
+
// reaches db.enc, and the caller — the periodic flush, close(), the exit
|
|
1125
|
+
// handler — is told the data is safe. Every symptom of a peer having
|
|
1126
|
+
// unlinked this file was invisible for exactly that reason, and one error
|
|
1127
|
+
// line here would have surfaced all of them at the first flush.
|
|
1128
|
+
//
|
|
1129
|
+
// AFTER close() the same absence is expected and correct: close removes the
|
|
1130
|
+
// working copy on purpose, having flushed first. The handle being gone is
|
|
1131
|
+
// what separates "torn down" from "the file vanished underneath a running
|
|
1132
|
+
// database", and only the second is a loss.
|
|
1133
|
+
if (!database) return;
|
|
1134
|
+
if (!nodeFs.existsSync(dbPath)) {
|
|
1135
|
+
throw new DbError("db/working-copy-missing",
|
|
1136
|
+
"db flush: the encrypted-mode working copy " + dbPath + " no longer exists, so " +
|
|
1137
|
+
"nothing can be written to " + encPath + ". Writes made since the last successful " +
|
|
1138
|
+
"flush are still in this process's open file and are NOT on disk. Another process " +
|
|
1139
|
+
"sharing this tmpDir most likely removed it; give each process its own tmpDir, or " +
|
|
1140
|
+
"run one process per volume.");
|
|
1141
|
+
}
|
|
1142
|
+
// Same reasoning as the missing working copy above, one step later: bytes
|
|
1143
|
+
// that reached db.enc without the log folded in are a volume that decrypts
|
|
1144
|
+
// and opens while missing its newest commits, and the flush would have
|
|
1145
|
+
// reported success. Failing here names it at the first flush instead.
|
|
1146
|
+
var unflushedWal = _walNotDrained(dbPath, true);
|
|
1147
|
+
if (unflushedWal) {
|
|
1148
|
+
throw new DbError("db/flush-pending-wal",
|
|
1149
|
+
"db flush: " + unflushedWal + ". Writing the working copy to " + encPath +
|
|
1150
|
+
" would store a volume that opens cleanly while missing the most recent " +
|
|
1151
|
+
"writes; retry once the writer is idle.");
|
|
1152
|
+
}
|
|
953
1153
|
atomicFile.writeSync(encPath, encryptPacked(atomicFile.fdSafeReadSync(dbPath, { maxBytes: C.BYTES.gib(2) }), encKey, _dbEncAad(dataDir)));
|
|
954
1154
|
}
|
|
955
1155
|
|
|
@@ -984,7 +1184,22 @@ function snapshot() {
|
|
|
984
1184
|
// WAL checkpoint flushes committed transactions into the main DB file
|
|
985
1185
|
// so the snapshot reflects the current logical state, not just the
|
|
986
1186
|
// pre-WAL pages.
|
|
987
|
-
|
|
1187
|
+
//
|
|
1188
|
+
// A read-only handle cannot perform one. The attempt used to fail into a
|
|
1189
|
+
// silent catch and the copy went ahead anyway, producing a snapshot missing
|
|
1190
|
+
// exactly the rows the log still held — a backup that restores cleanly and
|
|
1191
|
+
// is short of its most recent writes. Whether the checkpoint ran is now
|
|
1192
|
+
// checked by its result rather than assumed from its absence of an error.
|
|
1193
|
+
var pendingWal = _walNotDrained(dbPath, !readOnly);
|
|
1194
|
+
if (pendingWal) {
|
|
1195
|
+
throw _dbErr("db/snapshot-pending-wal",
|
|
1196
|
+
"snapshot: " + pendingWal + ", so a copy of the database file would be missing " +
|
|
1197
|
+
"the most recent rows while still opening cleanly." +
|
|
1198
|
+
(readOnly
|
|
1199
|
+
? " Take the snapshot from a handle that can write, or checkpoint the volume " +
|
|
1200
|
+
"before opening it readOnly."
|
|
1201
|
+
: " Retry once the writer is idle."));
|
|
1202
|
+
}
|
|
988
1203
|
if (!nodeFs.existsSync(dbPath)) {
|
|
989
1204
|
throw _dbErr("db/snapshot-no-source",
|
|
990
1205
|
"snapshot: plaintext DB at " + dbPath + " is missing — did init complete?");
|
|
@@ -1007,11 +1222,118 @@ function removePlaintextFiles() {
|
|
|
1007
1222
|
try { nodeFs.unlinkSync(dbPath); } catch (_e) { /* cleanup */ }
|
|
1008
1223
|
try { nodeFs.unlinkSync(dbPath + "-wal"); } catch (_e) { /* cleanup */ }
|
|
1009
1224
|
try { nodeFs.unlinkSync(dbPath + "-shm"); } catch (_e) { /* cleanup */ }
|
|
1225
|
+
// The ownership record goes with the copy it describes. Leaving it would
|
|
1226
|
+
// accumulate one file per open/close cycle forever, and the sweep could
|
|
1227
|
+
// never reclaim them: it enumerates `.db` names, so a sidecar whose working
|
|
1228
|
+
// copy is gone is invisible to it.
|
|
1229
|
+
try { nodeFs.unlinkSync(dbPath + OWNER_SUFFIX); } catch (_e) { /* cleanup */ }
|
|
1010
1230
|
}
|
|
1011
1231
|
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1232
|
+
// Which PID namespace this process's ids belong to.
|
|
1233
|
+
//
|
|
1234
|
+
// A process id only means something inside the namespace that issued it. Two
|
|
1235
|
+
// containers sharing a tmpDir are each pid 1, and one container probing the
|
|
1236
|
+
// other's pid either finds nothing (and would call a running process dead) or
|
|
1237
|
+
// finds an unrelated local process (and would call a dead one alive). Recording
|
|
1238
|
+
// the namespace alongside the pid makes that comparability testable instead of
|
|
1239
|
+
// assumed.
|
|
1240
|
+
//
|
|
1241
|
+
// Linux exposes it as a symlink whose target names the namespace inode.
|
|
1242
|
+
//
|
|
1243
|
+
// The fallback is where this gets decided, and it is not one case but two. On a
|
|
1244
|
+
// platform with no PID namespaces at all there is no such file to read, every
|
|
1245
|
+
// id on the host is comparable, and one shared token is right. On Linux the
|
|
1246
|
+
// file is supposed to be there, so failing to read it means /proc is unmounted
|
|
1247
|
+
// or the entry is restricted — namespaces may well be in use and we simply
|
|
1248
|
+
// cannot see which one we are in. Answering "host" there would tell two
|
|
1249
|
+
// containers with a shared tmpDir that each other's pids are comparable, which
|
|
1250
|
+
// is precisely the cross-namespace unlink of a LIVE database this record was
|
|
1251
|
+
// added to prevent.
|
|
1252
|
+
//
|
|
1253
|
+
// So that case gets a token unique to this process: no stored record can ever
|
|
1254
|
+
// match it, the sweep reclaims nothing, and the cost of not knowing is a leaked
|
|
1255
|
+
// working copy rather than someone else's data.
|
|
1256
|
+
//
|
|
1257
|
+
// Split into a function taking the platform and the reader so that branch can
|
|
1258
|
+
// be driven from any host. A rule about what happens when /proc is missing is
|
|
1259
|
+
// untestable while reaching it requires unmounting /proc.
|
|
1260
|
+
var _opaqueNamespace = null;
|
|
1261
|
+
function _namespaceFrom(platform, readlink) {
|
|
1262
|
+
// Only Linux has /proc/self/ns/pid, and asking anywhere else is not a
|
|
1263
|
+
// harmless miss: the leading slash is drive-relative on Windows, so the read
|
|
1264
|
+
// lands on C:\proc\self\ns\pid, which an unprivileged local user can create.
|
|
1265
|
+
// Answering from a planted file would let whoever wrote it name this
|
|
1266
|
+
// process's namespace, and a namespace that matches is exactly what
|
|
1267
|
+
// authorizes unlinking a working copy another process is using. Off Linux
|
|
1268
|
+
// there is one namespace by definition, so the platform answers it.
|
|
1269
|
+
if (platform !== "linux") return "host";
|
|
1270
|
+
try { return String(readlink("/proc/self/ns/pid")); }
|
|
1271
|
+
catch (_e) { /* decided below — /proc is unmounted or restricted */ }
|
|
1272
|
+
if (!_opaqueNamespace) _opaqueNamespace = "unreadable-ns:" + generateToken(16);
|
|
1273
|
+
return _opaqueNamespace;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function _ownerNamespace() {
|
|
1277
|
+
return _namespaceFrom(process.platform, nodeFs.readlinkSync);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Anchor an audit checkpoint, unless this handle promised not to write.
|
|
1281
|
+
//
|
|
1282
|
+
// Two places anchor one: boot, so a chain that moved since the last checkpoint
|
|
1283
|
+
// gets an anchor, and close(), so the audit.tip sidecar names the final state.
|
|
1284
|
+
// They are far apart and shaped differently — one awaited, one fire-and-forget
|
|
1285
|
+
// behind a leader check — which is how gating the first left the second live.
|
|
1286
|
+
// Routing both through here puts the rule in one place, so a third anchor added
|
|
1287
|
+
// later inherits it instead of re-deciding it.
|
|
1288
|
+
//
|
|
1289
|
+
// The read-only case returns a resolved promise rather than throwing: not
|
|
1290
|
+
// anchoring is the correct outcome for that handle, not an error in it.
|
|
1291
|
+
function _anchorCheckpoint() {
|
|
1292
|
+
if (readOnly) return Promise.resolve(null);
|
|
1293
|
+
return audit.checkpoint({ skipIfUnchanged: true });
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Is the process that owns a working copy still running?
|
|
1297
|
+
//
|
|
1298
|
+
// true — running, including running but owned by another user.
|
|
1299
|
+
// false — no such process, in a namespace we can compare against.
|
|
1300
|
+
// null — no record, an unreadable one, or one from another namespace.
|
|
1301
|
+
//
|
|
1302
|
+
// The liveness question itself belongs to b.pidProbe, which owns the
|
|
1303
|
+
// EPERM-means-alive / ESRCH-means-dead classification. What this adds is the
|
|
1304
|
+
// question that has to be answered BEFORE liveness: whether the recorded id is
|
|
1305
|
+
// ours to interpret at all. Reading "no such process" from a foreign namespace
|
|
1306
|
+
// as "dead owner" would unlink a live database — the exact loss the record was
|
|
1307
|
+
// added to prevent.
|
|
1308
|
+
function _tmpDbOwnerAlive(workingCopyPath) {
|
|
1309
|
+
var raw;
|
|
1310
|
+
try {
|
|
1311
|
+
raw = atomicFile.fdSafeReadSync(workingCopyPath + OWNER_SUFFIX, {
|
|
1312
|
+
maxBytes: C.BYTES.kib(1), refuseSymlink: true, encoding: "utf8",
|
|
1313
|
+
});
|
|
1314
|
+
} catch (_e) { return null; } // no record; owner unknowable
|
|
1315
|
+
// "<namespace> <pid>". A record without both fields predates this format or
|
|
1316
|
+
// was truncated; either way it cannot be compared, so it is left alone.
|
|
1317
|
+
var parts = String(raw).trim().split(" ");
|
|
1318
|
+
if (parts.length !== 2) return null;
|
|
1319
|
+
if (parts[0] !== _ownerNamespace()) return null; // another namespace's pid
|
|
1320
|
+
var pid = parseInt(parts[1], 10);
|
|
1321
|
+
if (!isFinite(pid) || pid <= 0) return null;
|
|
1322
|
+
return pidProbe.isLivePid(pid);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Reclaim working copies left behind by processes that are GONE.
|
|
1326
|
+
//
|
|
1327
|
+
// This used to unlink every `blamejs-*.db` in the directory that was not our
|
|
1328
|
+
// own, inferring "stale" from the filename alone. On Linux the unlink succeeds
|
|
1329
|
+
// against a file another process holds open, so a peer that was still running
|
|
1330
|
+
// kept a working descriptor, saw nothing wrong, and had every later flush
|
|
1331
|
+
// silently write nothing — losing its data back to its last flush. Sharing a
|
|
1332
|
+
// temporary directory is the normal case for a container, where the documented
|
|
1333
|
+
// way to get a shell is to exec into the running image.
|
|
1334
|
+
//
|
|
1335
|
+
// A name is not evidence of ownership. The owning process id is, so each
|
|
1336
|
+
// working copy now carries one and this sweep asks the operating system.
|
|
1015
1337
|
function cleanStaleTmpDbs(tmpDir) {
|
|
1016
1338
|
var entries = atomicFile.listDir(tmpDir, {
|
|
1017
1339
|
filter: function (name) { return name.startsWith("blamejs-") && name.endsWith(".db"); },
|
|
@@ -1019,9 +1341,19 @@ function cleanStaleTmpDbs(tmpDir) {
|
|
|
1019
1341
|
for (var i = 0; i < entries.length; i++) {
|
|
1020
1342
|
var full = entries[i].fullPath;
|
|
1021
1343
|
if (full === dbPath) continue;
|
|
1344
|
+
var alive = _tmpDbOwnerAlive(full);
|
|
1345
|
+
if (alive !== false) {
|
|
1346
|
+
// Alive, or unprovable. A working copy written by a version that kept no
|
|
1347
|
+
// ownership record is in the second group, and so is one whose record we
|
|
1348
|
+
// cannot read. Leaving it costs a file; removing it cost a database.
|
|
1349
|
+
log("leaving working copy " + full +
|
|
1350
|
+
(alive === null ? " (owner unknown)" : " (owner still running)"));
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1022
1353
|
try { nodeFs.unlinkSync(full); } catch (_e) { /* concurrent cleanup */ }
|
|
1023
1354
|
try { nodeFs.unlinkSync(full + "-wal"); } catch (_e) { /* may not exist */ }
|
|
1024
1355
|
try { nodeFs.unlinkSync(full + "-shm"); } catch (_e) { /* may not exist */ }
|
|
1356
|
+
try { nodeFs.unlinkSync(full + OWNER_SUFFIX); } catch (_e) { /* may not exist */ }
|
|
1025
1357
|
}
|
|
1026
1358
|
}
|
|
1027
1359
|
|
|
@@ -1049,8 +1381,10 @@ function cleanStaleTmpDbs(tmpDir) {
|
|
|
1049
1381
|
* dataDir: string, // required — where db.enc + db.key.enc live
|
|
1050
1382
|
* schema: Array, // required — [{ name, columns, indexes, sealedFields, derivedHashes, foreignKeys, primaryKey, subjectField, personalDataCategories }, ...]
|
|
1051
1383
|
* atRest: "encrypted"|"plain", // default "encrypted"
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1384
|
+
* immutable: boolean, // default false — requires `readOnly` and `atRest: "plain"`. Declares that NOTHING writes this volume while it is open, so SQLite reads it without creating the `-wal` / `-shm` pair it otherwise needs for a WAL-mode database. That pair is what makes a plain read-only open fail on a read-only mount. Only the operator can make this claim: if a writer is in fact active, the reader's view is undefined rather than merely stale, which is why it is never inferred.
|
|
1385
|
+
* readOnly: boolean, // default false — open without ever writing back. Under `atRest: "encrypted"` two processes sharing one volume each decrypt their own working copy, so whichever flushes last overwrites the other's writes; a reader takes no part in that. SQLite refuses writes too, so a stray one fails where it is issued.
|
|
1386
|
+
* tmpDir: string, // the encrypted-mode tmpfs path. Defaults to `BLAMEJS_TMPDIR`, then to `/dev/shm` on Linux only — off Linux nothing is inferred and encrypted mode refuses with `db/no-tmpfs` until one of the two names a mount.
|
|
1387
|
+
* allowNonTmpfsTmpDir: boolean, // default false — on Linux, where the mount table can be compared against, encrypted mode THROWS when tmpDir resolves outside the recognized tmpfs mounts (plaintext-on-disk leak); pass true to downgrade that to a warning when the mount is verified in-memory out-of-band. Off Linux the mount cannot be classified, so an operator-named path is always taken with a warning and this option changes nothing.
|
|
1054
1388
|
* migrationDir: string, // optional — path to ./migrations/ (run-once each)
|
|
1055
1389
|
* streamLimit: number, // default 1_000_000 — db.stream row ceiling
|
|
1056
1390
|
* columnGate: "reject"|"warn"|"off", // default "reject" — refuse queries on columns not declared in the table schema
|
|
@@ -1102,6 +1436,41 @@ async function init(opts) {
|
|
|
1102
1436
|
throw new DbError("db/bad-at-rest",
|
|
1103
1437
|
"db.init: atRest must be 'encrypted' or 'plain', got: " + opts.atRest);
|
|
1104
1438
|
}
|
|
1439
|
+
// Read-only open. Throw at config-time on a typo rather than letting a
|
|
1440
|
+
// truthy string read as "true" — an operator who meant to open a reader and
|
|
1441
|
+
// instead opened a writer is back in the overwrite this option exists to
|
|
1442
|
+
// avoid.
|
|
1443
|
+
validateOpts.optionalBoolean(opts.readOnly, "db.init: readOnly",
|
|
1444
|
+
DbError, "db/bad-read-only");
|
|
1445
|
+
readOnly = opts.readOnly === true;
|
|
1446
|
+
// `immutable` is the operator declaring that NOTHING writes this volume for
|
|
1447
|
+
// the life of the open — not another process, not another host sharing the
|
|
1448
|
+
// mount. That is a claim only they can make, so it is never inferred.
|
|
1449
|
+
//
|
|
1450
|
+
// It exists because SQLite needs a `-shm` file to read a database whose
|
|
1451
|
+
// header says WAL, and creates one on open even for a reader. On a read-only
|
|
1452
|
+
// mount that fails, which left `readOnly` unusable in exactly the setting it
|
|
1453
|
+
// was built for. Declaring the volume immutable lets SQLite read it with no
|
|
1454
|
+
// sidecars at all.
|
|
1455
|
+
//
|
|
1456
|
+
// Getting the claim wrong is a correctness failure, not an availability one:
|
|
1457
|
+
// a concurrent writer makes the reader's view undefined rather than merely
|
|
1458
|
+
// stale. That asymmetry is why the default stays the safe, sidecar-creating
|
|
1459
|
+
// open, and the fast path is opt-in.
|
|
1460
|
+
validateOpts.optionalBoolean(opts.immutable, "db.init: immutable",
|
|
1461
|
+
DbError, "db/bad-immutable");
|
|
1462
|
+
immutableOpen = opts.immutable === true;
|
|
1463
|
+
if (immutableOpen && !readOnly) {
|
|
1464
|
+
throw new DbError("db/bad-immutable",
|
|
1465
|
+
"db.init: immutable requires readOnly: true — it declares the volume will " +
|
|
1466
|
+
"not change, which says nothing about a handle that may write to it");
|
|
1467
|
+
}
|
|
1468
|
+
if (immutableOpen && atRest !== "plain") {
|
|
1469
|
+
throw new DbError("db/bad-immutable",
|
|
1470
|
+
"db.init: immutable applies to atRest: 'plain', where the volume is the " +
|
|
1471
|
+
"file opened. Under 'encrypted' the handle opens a working copy this " +
|
|
1472
|
+
"process just decrypted, so there is nothing for the operator to declare");
|
|
1473
|
+
}
|
|
1105
1474
|
// Operator-tunable streamLimit ceiling. Throw at config-time
|
|
1106
1475
|
// on bad shape so a typo surfaces at boot rather than as an
|
|
1107
1476
|
// unbounded stream at first export.
|
|
@@ -1143,12 +1512,12 @@ async function init(opts) {
|
|
|
1143
1512
|
// If the resolved tmpDir is NOT actually tmpfs, the plaintext working
|
|
1144
1513
|
// copy lives on persistent storage and leaks into backup snapshots,
|
|
1145
1514
|
// replication, and forensic disk images — defeating the whole point of
|
|
1146
|
-
// encrypted-at-rest mode. On Linux
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1515
|
+
// encrypted-at-rest mode. On Linux the resolved path is checked against
|
|
1516
|
+
// the known in-memory mounts (/dev/shm /run/shm /run/user, plus /tmp
|
|
1517
|
+
// which is tmpfs on systemd-default + most container images).
|
|
1149
1518
|
//
|
|
1150
|
-
// Fail-closed default (v0.15.0): a tmpDir that
|
|
1151
|
-
//
|
|
1519
|
+
// Fail-closed default (v0.15.0): a tmpDir that cannot be shown to be one
|
|
1520
|
+
// of those THROWS db/tmpdir-not-tmpfs at boot rather than logging a
|
|
1152
1521
|
// warning the operator never reads — the prior warn-only path silently
|
|
1153
1522
|
// shipped plaintext to disk under the encrypted-mode default. The
|
|
1154
1523
|
// documented opt-out is opts.allowNonTmpfsTmpDir: true, for the operator
|
|
@@ -1156,27 +1525,24 @@ async function init(opts) {
|
|
|
1156
1525
|
// a tmpfs bind-mounted at a non-standard path the heuristic can't see)
|
|
1157
1526
|
// or who has accepted the disk-residency tradeoff. The opt-out downgrades
|
|
1158
1527
|
// to the prior warning. (Free-space headroom is enforced separately via
|
|
1159
|
-
// fs.statfsSync in the storage guard below.)
|
|
1160
|
-
//
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
"
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
"mount is in-memory out-of-band.)");
|
|
1173
|
-
} else {
|
|
1174
|
-
throw _dbErr("db/tmpdir-not-tmpfs", "FATAL: " + tmpfsMsg +
|
|
1175
|
-
" Mount a tmpfs at the path (or set BLAMEJS_TMPDIR / opts.tmpDir to one), " +
|
|
1176
|
-
"or pass opts.allowNonTmpfsTmpDir: true to accept the disk-residency tradeoff, " +
|
|
1177
|
-
"or pass atRest: 'plain' if encryption-at-rest is not required.");
|
|
1178
|
-
}
|
|
1528
|
+
// fs.statfsSync in the storage guard below.)
|
|
1529
|
+
//
|
|
1530
|
+
// Off Linux the mount cannot be classified at all, so the finding is that
|
|
1531
|
+
// it is unknown, and the operator who named the path is warned rather than
|
|
1532
|
+
// refused. Nothing was emitted there before, which read as approval — and
|
|
1533
|
+
// the comment above this block used to claim a warning was emitted.
|
|
1534
|
+
var residencyIssue = _tmpDirResidencyIssue(tmpDir, process.platform, nodeFs.realpathSync);
|
|
1535
|
+
if (residencyIssue) {
|
|
1536
|
+
if (residencyIssue.determined && opts.allowNonTmpfsTmpDir !== true) {
|
|
1537
|
+
throw _dbErr("db/tmpdir-not-tmpfs", "FATAL: " + residencyIssue.message +
|
|
1538
|
+
" Mount a tmpfs at the path (or set BLAMEJS_TMPDIR / opts.tmpDir to one), " +
|
|
1539
|
+
"or pass opts.allowNonTmpfsTmpDir: true to accept the disk-residency tradeoff, " +
|
|
1540
|
+
"or pass atRest: 'plain' if encryption-at-rest is not required.");
|
|
1179
1541
|
}
|
|
1542
|
+
log.warn("WARNING: " + residencyIssue.message +
|
|
1543
|
+
(residencyIssue.determined
|
|
1544
|
+
? " (allowNonTmpfsTmpDir:true — verify the mount is in-memory out-of-band.)"
|
|
1545
|
+
: ""));
|
|
1180
1546
|
}
|
|
1181
1547
|
|
|
1182
1548
|
// Operator overrides for the encrypted-DB on-disk nodePath. `opts.encryptedDbPath`
|
|
@@ -1204,6 +1570,11 @@ async function init(opts) {
|
|
|
1204
1570
|
: (typeof nodeFs.statfsSync === "function" ? nodeFs.statfsSync : null);
|
|
1205
1571
|
|
|
1206
1572
|
cleanStaleTmpDbs(tmpDir);
|
|
1573
|
+
// Claim this working copy BEFORE any bytes go into it, so a peer booting
|
|
1574
|
+
// concurrently sees an owner rather than an unattributed file. Written
|
|
1575
|
+
// with atomicFile so a peer never reads a half-written process id.
|
|
1576
|
+
atomicFile.writeSync(dbPath + OWNER_SUFFIX,
|
|
1577
|
+
Buffer.from(_ownerNamespace() + " " + process.pid.toString(10), "utf8"));
|
|
1207
1578
|
decryptToTmp();
|
|
1208
1579
|
} else {
|
|
1209
1580
|
// plain mode
|
|
@@ -1225,21 +1596,78 @@ async function init(opts) {
|
|
|
1225
1596
|
// Node 24.10+, comfortably under the engines floor. (SQLITE_LIMIT_ATTACHED is
|
|
1226
1597
|
// left at the SQLite default — the snapshot / backup path relies on the
|
|
1227
1598
|
// attach mechanism.)
|
|
1228
|
-
|
|
1599
|
+
// Being told the bytes cannot change, SQLite stops consulting the -wal
|
|
1600
|
+
// sidecar entirely. That is the point — it is what removes the need to create
|
|
1601
|
+
// one — but it means a volume carrying committed transactions ONLY in its WAL
|
|
1602
|
+
// would be read without them.
|
|
1603
|
+
//
|
|
1604
|
+
// A clean close removes the -wal. One left behind with bytes in it is a
|
|
1605
|
+
// writer that crashed, or a volume captured mid-write, and either way the
|
|
1606
|
+
// most recent commits live only there. Reading past it would not return a
|
|
1607
|
+
// stale database, which is the tradeoff `immutable` asks the operator to
|
|
1608
|
+
// accept; it would return a partial one that looks whole, and the rows that
|
|
1609
|
+
// went missing would be the newest.
|
|
1610
|
+
//
|
|
1611
|
+
// So refuse. Replaying the WAL requires a writable open, which is exactly
|
|
1612
|
+
// what the operator has said they cannot do here — and guessing on their
|
|
1613
|
+
// behalf is what this whole option exists to avoid.
|
|
1614
|
+
if (immutableOpen) {
|
|
1615
|
+
// No handle is open yet, and this one could not checkpoint if it were, so
|
|
1616
|
+
// the log can only be reported on rather than resolved.
|
|
1617
|
+
var walIssue = _walNotDrained(dbPath, false);
|
|
1618
|
+
if (walIssue) {
|
|
1619
|
+
throw _dbErr("db/immutable-pending-wal",
|
|
1620
|
+
"db.init: immutable was requested but " + walIssue + ". An immutable open does " +
|
|
1621
|
+
"not read the write-ahead log, so any transaction committed only there would be " +
|
|
1622
|
+
"silently missing. Open the volume once writable to checkpoint and remove the " +
|
|
1623
|
+
"log, or drop immutable and open it read-only on a mount that permits the " +
|
|
1624
|
+
"-wal / -shm pair.");
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// A declared-immutable volume is opened through a file: URI so SQLite is told
|
|
1629
|
+
// the bytes cannot change and reads it without a -wal / -shm pair. The URI is
|
|
1630
|
+
// built with pathToFileURL rather than by concatenation: a dataDir with a
|
|
1631
|
+
// space or a parenthesis in it is ordinary, and both need percent-encoding
|
|
1632
|
+
// before SQLite will parse the path back out.
|
|
1633
|
+
var openTarget = immutableOpen
|
|
1634
|
+
? nodeUrl.pathToFileURL(dbPath).href + "?immutable=1"
|
|
1635
|
+
: dbPath;
|
|
1636
|
+
database = new DatabaseSync(openTarget, {
|
|
1637
|
+
// Enforced by SQLite as well as by the flush skip, so a write under a
|
|
1638
|
+
// read-only open fails where it is issued rather than succeeding into a
|
|
1639
|
+
// working copy nobody will ever persist.
|
|
1640
|
+
readOnly: readOnly,
|
|
1229
1641
|
limits: {
|
|
1230
1642
|
sqlLength: C.BYTES.mib(1),
|
|
1231
1643
|
},
|
|
1232
1644
|
});
|
|
1233
1645
|
_dbGenerationCounter++; // a new live handle — see dbGeneration()
|
|
1234
1646
|
|
|
1235
|
-
// Performance pragmas
|
|
1236
|
-
|
|
1647
|
+
// Performance pragmas.
|
|
1648
|
+
//
|
|
1649
|
+
// Two of these change the DATABASE, not the connection: journal_mode rewrites
|
|
1650
|
+
// the file header and brings a `-wal` sidecar into existence beside it, and
|
|
1651
|
+
// auto_vacuum rewrites the header too. A read-only open must issue neither.
|
|
1652
|
+
// Under `atRest: "encrypted"` that was invisible, because the handle is
|
|
1653
|
+
// opened against a decrypted working copy in tmpfs, which is writable
|
|
1654
|
+
// whatever the volume is mounted as. In plain mode the file IS the volume, so
|
|
1655
|
+
// a reader pointed at a read-only mount failed on the first pragma — the mode
|
|
1656
|
+
// a reader exists for was the one it could not be used in.
|
|
1657
|
+
//
|
|
1658
|
+
// The rest are connection settings. They configure this handle and touch
|
|
1659
|
+
// nothing on disk, so a reader keeps them: cache and mmap sizing matter as
|
|
1660
|
+
// much for reading, and foreign_keys / secure_delete describe writes that a
|
|
1661
|
+
// read-only handle will not be making anyway.
|
|
1662
|
+
if (!readOnly) {
|
|
1663
|
+
runSql(database, "PRAGMA journal_mode=WAL");
|
|
1664
|
+
runSql(database, "PRAGMA auto_vacuum=INCREMENTAL");
|
|
1665
|
+
}
|
|
1237
1666
|
runSql(database, "PRAGMA synchronous=NORMAL");
|
|
1238
1667
|
runSql(database, "PRAGMA cache_size=-8000");
|
|
1239
1668
|
runSql(database, "PRAGMA temp_store=MEMORY");
|
|
1240
1669
|
runSql(database, "PRAGMA busy_timeout=5000");
|
|
1241
1670
|
runSql(database, "PRAGMA mmap_size=268435456");
|
|
1242
|
-
runSql(database, "PRAGMA auto_vacuum=INCREMENTAL");
|
|
1243
1671
|
// Foreign-key enforcement is OFF by default in SQLite. Turn it ON so
|
|
1244
1672
|
// structured `foreignKeys` declarations actually constrain writes.
|
|
1245
1673
|
runSql(database, "PRAGMA foreign_keys=ON");
|
|
@@ -1595,6 +2023,11 @@ async function init(opts) {
|
|
|
1595
2023
|
dataDir: dataDir,
|
|
1596
2024
|
mode: auditSigningMode,
|
|
1597
2025
|
algorithm: auditSigningAlg || undefined,
|
|
2026
|
+
// A reader verifies with the key the volume already has; it never
|
|
2027
|
+
// establishes one. Without this the signing bootstrap generated a
|
|
2028
|
+
// keypair, wrote it under dataDir and swept orphaned temp files, which
|
|
2029
|
+
// fails an open on a read-only mount and quietly modifies a writable one.
|
|
2030
|
+
readOnly: readOnly,
|
|
1598
2031
|
});
|
|
1599
2032
|
}
|
|
1600
2033
|
|
|
@@ -1616,7 +2049,15 @@ async function init(opts) {
|
|
|
1616
2049
|
|
|
1617
2050
|
// Anchor a fresh checkpoint at boot if there's any new audit
|
|
1618
2051
|
// activity since the last checkpoint (else no-op).
|
|
1619
|
-
|
|
2052
|
+
//
|
|
2053
|
+
// A read-only open anchors nothing. `skipIfUnchanged` skips the write only
|
|
2054
|
+
// when the chain has not moved, and the state a reader most wants to
|
|
2055
|
+
// inspect is the opposite one: a volume left by a crash, with audit rows
|
|
2056
|
+
// newer than its last checkpoint. There the insert is attempted and the
|
|
2057
|
+
// read-only handle refuses it, so the advertised reader could not open
|
|
2058
|
+
// precisely the snapshots it exists for. The signature verification above
|
|
2059
|
+
// still runs — checkpoints are VERIFIED here, only creating one is skipped.
|
|
2060
|
+
await _anchorCheckpoint();
|
|
1620
2061
|
}
|
|
1621
2062
|
|
|
1622
2063
|
// ---- NTP drift check ----
|
|
@@ -1624,13 +2065,18 @@ async function init(opts) {
|
|
|
1624
2065
|
// (unless BLAMEJS_NTP_STRICT=0 / BLAMEJS_SKIP_NTP_CHECK=1).
|
|
1625
2066
|
await _runNtpBootCheck(opts);
|
|
1626
2067
|
|
|
1627
|
-
// Start periodic encrypt timer (encrypted mode only)
|
|
2068
|
+
// Start periodic encrypt timer (encrypted mode only). A read-only open has
|
|
2069
|
+
// nothing to flush, so it never arms one — encryptToDisk would return
|
|
2070
|
+
// immediately, and a timer whose every firing is a no-op is a handle held
|
|
2071
|
+
// open for nothing.
|
|
1628
2072
|
if (atRest === "encrypted") {
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
2073
|
+
if (!readOnly) {
|
|
2074
|
+
encTimer = safeAsync.repeating(function () {
|
|
2075
|
+
try { encryptToDisk(); } catch (e) {
|
|
2076
|
+
log.error("periodic encrypt failed: " + e.message);
|
|
2077
|
+
}
|
|
2078
|
+
}, C.TIME.minutes(5), { name: "db-periodic-encrypt" });
|
|
2079
|
+
}
|
|
1634
2080
|
|
|
1635
2081
|
// Tmpfs free-space guard. Install the growth-write gate now (after all
|
|
1636
2082
|
// of init's own writes), then probe on a short interval so the
|
|
@@ -2403,7 +2849,7 @@ function close() {
|
|
|
2403
2849
|
// await it across the test/shutdown lifecycle. Operators who need
|
|
2404
2850
|
// a guaranteed-flushed checkpoint should call audit.checkpoint()
|
|
2405
2851
|
// explicitly before invoking close().
|
|
2406
|
-
|
|
2852
|
+
_anchorCheckpoint().catch(function (e) {
|
|
2407
2853
|
log.error("close: final checkpoint failed: " + e.message);
|
|
2408
2854
|
});
|
|
2409
2855
|
}
|
|
@@ -2952,6 +3398,8 @@ function _resetForTest() {
|
|
|
2952
3398
|
encPath = null;
|
|
2953
3399
|
encKey = null;
|
|
2954
3400
|
atRest = null;
|
|
3401
|
+
readOnly = false;
|
|
3402
|
+
immutableOpen = false;
|
|
2955
3403
|
dataDir = null;
|
|
2956
3404
|
minFreeBytes = 0;
|
|
2957
3405
|
statfsProbe = null;
|
|
@@ -3379,6 +3827,14 @@ function getActivePosture() { return _activePosture; }
|
|
|
3379
3827
|
|
|
3380
3828
|
module.exports = {
|
|
3381
3829
|
init: init,
|
|
3830
|
+
// Test hook: the namespace token this process stamps into an ownership
|
|
3831
|
+
// record, so a test writes the same format the sweep reads instead of
|
|
3832
|
+
// hard-coding one that can drift from it.
|
|
3833
|
+
_ownerNamespaceForTest: _ownerNamespace,
|
|
3834
|
+
_namespaceFromForTest: _namespaceFrom,
|
|
3835
|
+
_resolveTmpDirFromForTest: _resolveTmpDirFrom,
|
|
3836
|
+
_tmpDirResidencyIssueForTest: _tmpDirResidencyIssue,
|
|
3837
|
+
_walNotDrainedFromForTest: _walNotDrainedFrom,
|
|
3382
3838
|
_dbGeneration: dbGeneration,
|
|
3383
3839
|
applyPosture: applyPosture,
|
|
3384
3840
|
getActivePosture: getActivePosture,
|