@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/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
- function resolveTmpDir(optsTmpDir) {
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 (nodeFs.existsSync("/dev/shm")) return "/dev/shm";
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
- if (b64) {
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
- // Force WAL checkpoint so the .db file holds all committed transactions.
951
- try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
952
- if (!nodeFs.existsSync(dbPath)) return;
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
- try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
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
- // Clean up stale plaintext DB files left by previously-crashed processes.
1013
- // Anything matching blamejs-*.db that isn't our current process's file is
1014
- // stale (no other process should write to /dev/shm with our prefix).
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
- * tmpDir: string, // override the encrypted-mode tmpfs path (default /dev/shm or BLAMEJS_TMPDIR)
1053
- * allowNonTmpfsTmpDir: boolean, // default false — encrypted mode THROWS when tmpDir is not a recognized tmpfs mount (plaintext-on-disk leak); pass true to downgrade to a warning when the mount is verified in-memory out-of-band
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 we verify the resolved path lands
1147
- // under a known in-memory mount (/dev/shm /run/shm /run/user, plus
1148
- // /tmp which is tmpfs on systemd-default + most container images).
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 resolves OUTSIDE those
1151
- // mounts THROWS db/tmpdir-not-tmpfs at boot rather than logging a
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.) The heuristic is Linux-only;
1160
- // other platforms can't be probed by path and emit the warning unchanged.
1161
- if (process.platform === "linux") {
1162
- var realTmp = "";
1163
- try { realTmp = nodeFs.realpathSync(tmpDir); } catch (_e) { /* stat best-effort */ }
1164
- if (realTmp.indexOf("/dev/shm") !== 0 && realTmp.indexOf("/run/shm") !== 0 &&
1165
- realTmp.indexOf("/run/user/") !== 0 && realTmp.indexOf("/tmp") !== 0) {
1166
- var tmpfsMsg = "db.init: tmpDir '" + tmpDir + "' (real: '" + realTmp +
1167
- "') does not resolve under /dev/shm /run/shm /run/user /tmp — it is not a " +
1168
- "recognized tmpfs mount. A persistent-disk tmpDir leaks the decrypted " +
1169
- "working copy into backup snapshots, replication, and forensic disk images.";
1170
- if (opts.allowNonTmpfsTmpDir === true) {
1171
- log.warn("WARNING: " + tmpfsMsg + " (allowNonTmpfsTmpDir:true verify the " +
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
- database = new DatabaseSync(dbPath, {
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
- runSql(database, "PRAGMA journal_mode=WAL");
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
- await audit.checkpoint({ skipIfUnchanged: true });
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
- encTimer = safeAsync.repeating(function () {
1630
- try { encryptToDisk(); } catch (e) {
1631
- log.error("periodic encrypt failed: " + e.message);
1632
- }
1633
- }, C.TIME.minutes(5), { name: "db-periodic-encrypt" });
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
- audit.checkpoint({ skipIfUnchanged: true }).catch(function (e) {
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,