@blamejs/core 0.7.18 → 0.7.20
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 +427 -423
- package/README.md +150 -150
- package/bin/blamejs.js +0 -0
- package/index.js +310 -308
- package/lib/api-key.js +660 -660
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +337 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +628 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +350 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -275
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -420
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -293
- package/lib/framework-schema.js +717 -717
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -369
- package/lib/mail.js +981 -981
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +1170 -1170
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +399 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/fetch-metadata.js +129 -0
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +85 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +121 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -111
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +535 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +7 -5
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -340
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/backup/bundle.js
CHANGED
|
@@ -1,217 +1,217 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* backup-bundle — produce an encrypted backup bundle on disk.
|
|
4
|
-
*
|
|
5
|
-
* Given a dataDir + file include list + passphrase, walks each file,
|
|
6
|
-
* encrypts its bytes via backup-crypto, computes a sha3-512 checksum
|
|
7
|
-
* of the plaintext, and emits a bundle directory:
|
|
8
|
-
*
|
|
9
|
-
* <outDir>/manifest.json — backup-manifest schema
|
|
10
|
-
* <outDir>/files/<path>.enc — per-file encrypted blob
|
|
11
|
-
*
|
|
12
|
-
* Where <path> mirrors the file's relativePath under dataDir (subdirs
|
|
13
|
-
* preserved). The manifest is the only authoritative description of
|
|
14
|
-
* the bundle's contents — a restorer reads it first, then streams
|
|
15
|
-
* each blob into staging.
|
|
16
|
-
*
|
|
17
|
-
* await b.backupBundle.create({
|
|
18
|
-
* dataDir: "./data",
|
|
19
|
-
* outDir: "./backups/2026-04-27.bundle", // must NOT exist
|
|
20
|
-
* passphrase: Buffer.from("operator passphrase"),
|
|
21
|
-
* vaultKeyJson: "<vault.key contents>", // string; encrypted into manifest
|
|
22
|
-
* files: [
|
|
23
|
-
* { relativePath: "db.enc", kind: "raw", required: true },
|
|
24
|
-
* { relativePath: "db.key.enc", kind: "raw", required: true },
|
|
25
|
-
* { relativePath: "vault.key", kind: "raw", required: false },
|
|
26
|
-
* { relativePath: "ca.key.sealed", kind: "vault-sealed", required: false },
|
|
27
|
-
* ],
|
|
28
|
-
* metadata: { reason: "scheduled-daily" },
|
|
29
|
-
* progressCallback: function (event) { ... },
|
|
30
|
-
* });
|
|
31
|
-
* // → { manifest, manifestPath, outDir, bundleSize, fileCount, durationMs }
|
|
32
|
-
*
|
|
33
|
-
* vaultKeyJson is encrypted with the operator passphrase + a fresh
|
|
34
|
-
* salt and stored in the manifest's vaultKeyEnc. With only the
|
|
35
|
-
* passphrase, a restorer on a different machine can recover the
|
|
36
|
-
* framework's vault keypair and unseal the bundle's vault-sealed
|
|
37
|
-
* files post-restore. Without the passphrase, the bundle is opaque.
|
|
38
|
-
*
|
|
39
|
-
* Per-file salts: each file gets its own fresh salt. Argon2id is
|
|
40
|
-
* memory-hard but per-file fresh-salt means an attacker who recovers
|
|
41
|
-
* one file's key from the passphrase has no leverage on other files
|
|
42
|
-
* — the salt rotation forces the full Argon2 computation per file.
|
|
43
|
-
*
|
|
44
|
-
* The bundler does NOT compress files. Operators with large datasets
|
|
45
|
-
* who want compression run their backup pipeline through their own
|
|
46
|
-
* compressor (gzip, zstd) downstream of the framework primitive.
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
var fs = require("fs");
|
|
50
|
-
var path = require("path");
|
|
51
|
-
var atomicFile = require("../atomic-file");
|
|
52
|
-
var backupCrypto = require("./crypto");
|
|
53
|
-
var backupManifest = require("./manifest");
|
|
54
|
-
var validateOpts = require("../validate-opts");
|
|
55
|
-
var { defineClass } = require("../framework-error");
|
|
56
|
-
|
|
57
|
-
var BackupBundleError = defineClass("BackupBundleError", { alwaysPermanent: true });
|
|
58
|
-
|
|
59
|
-
function _emit(cb, ev) {
|
|
60
|
-
if (typeof cb === "function") {
|
|
61
|
-
try { cb(ev); } catch (_e) { /* progress-callback errors are non-fatal */ }
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Map relativePath → encryptedPath inside the bundle. Mirrors the
|
|
66
|
-
// directory structure under files/ and appends .enc so every blob
|
|
67
|
-
// has a clear stride and isn't confused with the source file.
|
|
68
|
-
function _encryptedPathFor(relativePath) {
|
|
69
|
-
// POSIX-normalize separators in the bundle so manifests written on
|
|
70
|
-
// Windows and Linux look the same on disk.
|
|
71
|
-
var posix = relativePath.split(path.sep).join("/");
|
|
72
|
-
return "files/" + posix + ".enc";
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function create(opts) {
|
|
76
|
-
var t0 = Date.now();
|
|
77
|
-
opts = opts || {};
|
|
78
|
-
if (typeof opts.dataDir !== "string" || !fs.existsSync(opts.dataDir)) {
|
|
79
|
-
throw new BackupBundleError("backup-bundle/no-datadir",
|
|
80
|
-
"create: opts.dataDir is required and must exist");
|
|
81
|
-
}
|
|
82
|
-
validateOpts.requireNonEmptyString(opts.outDir, "create: opts.outDir", BackupBundleError, "backup-bundle/no-outdir");
|
|
83
|
-
if (fs.existsSync(opts.outDir)) {
|
|
84
|
-
throw new BackupBundleError("backup-bundle/outdir-exists",
|
|
85
|
-
"create: outDir already exists: " + opts.outDir +
|
|
86
|
-
" (refusing to overwrite — pick a fresh path)");
|
|
87
|
-
}
|
|
88
|
-
if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
|
|
89
|
-
throw new BackupBundleError("backup-bundle/no-passphrase",
|
|
90
|
-
"create: opts.passphrase is required (Buffer or string)");
|
|
91
|
-
}
|
|
92
|
-
if (typeof opts.vaultKeyJson !== "string" || opts.vaultKeyJson.length === 0) {
|
|
93
|
-
throw new BackupBundleError("backup-bundle/no-vault-key-json",
|
|
94
|
-
"create: opts.vaultKeyJson is required (the in-memory vault keypair JSON; " +
|
|
95
|
-
"use vault.getKeysJson() or read vault.key from disk)");
|
|
96
|
-
}
|
|
97
|
-
if (!Array.isArray(opts.files) || opts.files.length === 0) {
|
|
98
|
-
throw new BackupBundleError("backup-bundle/no-files",
|
|
99
|
-
"create: opts.files must be a non-empty array of include entries");
|
|
100
|
-
}
|
|
101
|
-
var passphrase = opts.passphrase;
|
|
102
|
-
var dataDir = opts.dataDir;
|
|
103
|
-
var outDir = opts.outDir;
|
|
104
|
-
var progress = opts.progressCallback;
|
|
105
|
-
|
|
106
|
-
atomicFile.ensureDir(outDir);
|
|
107
|
-
atomicFile.ensureDir(path.join(outDir, "files"));
|
|
108
|
-
|
|
109
|
-
// 1. Encrypt the vault key JSON
|
|
110
|
-
_emit(progress, { phase: "wrap_vault_key" });
|
|
111
|
-
var wrappedVk = await backupCrypto.encryptWithFreshSalt(opts.vaultKeyJson, passphrase);
|
|
112
|
-
|
|
113
|
-
// 2. Walk each include entry, encrypt the bytes, emit a blob
|
|
114
|
-
var fileEntries = [];
|
|
115
|
-
var totalBytes = 0;
|
|
116
|
-
|
|
117
|
-
for (var i = 0; i < opts.files.length; i++) {
|
|
118
|
-
var entry = opts.files[i];
|
|
119
|
-
if (!entry || typeof entry.relativePath !== "string" || entry.relativePath.length === 0) {
|
|
120
|
-
throw new BackupBundleError("backup-bundle/bad-include",
|
|
121
|
-
"create: files[" + i + "] requires { relativePath: string }");
|
|
122
|
-
}
|
|
123
|
-
if (entry.relativePath.indexOf("..") !== -1 || /^[/\\]/.test(entry.relativePath)) {
|
|
124
|
-
throw new BackupBundleError("backup-bundle/bad-include",
|
|
125
|
-
"create: files[" + i + "].relativePath must be a relative path (got '" + entry.relativePath + "')");
|
|
126
|
-
}
|
|
127
|
-
var srcPath = path.join(dataDir, entry.relativePath);
|
|
128
|
-
if (!fs.existsSync(srcPath)) {
|
|
129
|
-
if (entry.required) {
|
|
130
|
-
throw new BackupBundleError("backup-bundle/missing-required",
|
|
131
|
-
"create: required file missing: " + entry.relativePath);
|
|
132
|
-
}
|
|
133
|
-
_emit(progress, { phase: "skip_missing", relativePath: entry.relativePath });
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
var stat = fs.statSync(srcPath);
|
|
137
|
-
if (!stat.isFile()) {
|
|
138
|
-
// Directories aren't supported in this slice — the bundler
|
|
139
|
-
// operates on a flat list of files. Operator wanting a recursive
|
|
140
|
-
// sweep walks the dir themselves and passes the resulting list.
|
|
141
|
-
throw new BackupBundleError("backup-bundle/not-a-file",
|
|
142
|
-
"create: '" + entry.relativePath + "' is not a regular file");
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
_emit(progress, { phase: "read", relativePath: entry.relativePath, size: stat.size });
|
|
146
|
-
var plain = fs.readFileSync(srcPath);
|
|
147
|
-
var checksum = backupCrypto.checksum(plain);
|
|
148
|
-
var encResult = await backupCrypto.encryptWithFreshSalt(plain, passphrase);
|
|
149
|
-
var encPath = _encryptedPathFor(entry.relativePath);
|
|
150
|
-
var destFull = path.join(outDir, encPath);
|
|
151
|
-
atomicFile.ensureDir(path.dirname(destFull));
|
|
152
|
-
atomicFile.writeSync(destFull, encResult.encrypted, { fileMode: 0o600 });
|
|
153
|
-
|
|
154
|
-
var kind = entry.kind || "raw";
|
|
155
|
-
if (!backupManifest.VALID_KINDS[kind]) {
|
|
156
|
-
throw new BackupBundleError("backup-bundle/bad-kind",
|
|
157
|
-
"create: files[" + i + "].kind must be one of raw, vault-sealed, plaintext (got '" + kind + "')");
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
fileEntries.push({
|
|
161
|
-
relativePath: entry.relativePath,
|
|
162
|
-
encryptedPath: encPath,
|
|
163
|
-
size: plain.length,
|
|
164
|
-
encryptedSize: encResult.encrypted.length,
|
|
165
|
-
checksum: checksum,
|
|
166
|
-
salt: encResult.salt,
|
|
167
|
-
kind: kind,
|
|
168
|
-
});
|
|
169
|
-
totalBytes += encResult.encrypted.length;
|
|
170
|
-
_emit(progress, {
|
|
171
|
-
phase: "encrypted",
|
|
172
|
-
relativePath: entry.relativePath,
|
|
173
|
-
encryptedSize: encResult.encrypted.length,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (fileEntries.length === 0) {
|
|
178
|
-
// Nothing to write; refuse to emit an empty manifest. Operators
|
|
179
|
-
// who genuinely want an "empty backup" need to revisit their
|
|
180
|
-
// include list.
|
|
181
|
-
throw new BackupBundleError("backup-bundle/empty",
|
|
182
|
-
"create: no files included in bundle (every entry was missing or skipped)");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// 3. Build the manifest and write it last (so a half-written bundle
|
|
186
|
-
// can be detected by absence of manifest.json — an integrity tell)
|
|
187
|
-
_emit(progress, { phase: "write_manifest" });
|
|
188
|
-
var manifest = backupManifest.create({
|
|
189
|
-
vaultKeySalt: wrappedVk.salt,
|
|
190
|
-
vaultKeyEnc: wrappedVk.encrypted.toString("base64"),
|
|
191
|
-
files: fileEntries,
|
|
192
|
-
metadata: opts.metadata || undefined,
|
|
193
|
-
});
|
|
194
|
-
var manifestPath = path.join(outDir, "manifest.json");
|
|
195
|
-
atomicFile.writeSync(manifestPath, backupManifest.serialize(manifest), { fileMode: 0o600 });
|
|
196
|
-
|
|
197
|
-
var durationMs = Date.now() - t0;
|
|
198
|
-
_emit(progress, {
|
|
199
|
-
phase: "done",
|
|
200
|
-
fileCount: fileEntries.length,
|
|
201
|
-
bundleSize: totalBytes,
|
|
202
|
-
durationMs: durationMs,
|
|
203
|
-
});
|
|
204
|
-
return {
|
|
205
|
-
manifest: manifest,
|
|
206
|
-
manifestPath: manifestPath,
|
|
207
|
-
outDir: outDir,
|
|
208
|
-
bundleSize: totalBytes,
|
|
209
|
-
fileCount: fileEntries.length,
|
|
210
|
-
durationMs: durationMs,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
module.exports = {
|
|
215
|
-
create: create,
|
|
216
|
-
BackupBundleError: BackupBundleError,
|
|
217
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* backup-bundle — produce an encrypted backup bundle on disk.
|
|
4
|
+
*
|
|
5
|
+
* Given a dataDir + file include list + passphrase, walks each file,
|
|
6
|
+
* encrypts its bytes via backup-crypto, computes a sha3-512 checksum
|
|
7
|
+
* of the plaintext, and emits a bundle directory:
|
|
8
|
+
*
|
|
9
|
+
* <outDir>/manifest.json — backup-manifest schema
|
|
10
|
+
* <outDir>/files/<path>.enc — per-file encrypted blob
|
|
11
|
+
*
|
|
12
|
+
* Where <path> mirrors the file's relativePath under dataDir (subdirs
|
|
13
|
+
* preserved). The manifest is the only authoritative description of
|
|
14
|
+
* the bundle's contents — a restorer reads it first, then streams
|
|
15
|
+
* each blob into staging.
|
|
16
|
+
*
|
|
17
|
+
* await b.backupBundle.create({
|
|
18
|
+
* dataDir: "./data",
|
|
19
|
+
* outDir: "./backups/2026-04-27.bundle", // must NOT exist
|
|
20
|
+
* passphrase: Buffer.from("operator passphrase"),
|
|
21
|
+
* vaultKeyJson: "<vault.key contents>", // string; encrypted into manifest
|
|
22
|
+
* files: [
|
|
23
|
+
* { relativePath: "db.enc", kind: "raw", required: true },
|
|
24
|
+
* { relativePath: "db.key.enc", kind: "raw", required: true },
|
|
25
|
+
* { relativePath: "vault.key", kind: "raw", required: false },
|
|
26
|
+
* { relativePath: "ca.key.sealed", kind: "vault-sealed", required: false },
|
|
27
|
+
* ],
|
|
28
|
+
* metadata: { reason: "scheduled-daily" },
|
|
29
|
+
* progressCallback: function (event) { ... },
|
|
30
|
+
* });
|
|
31
|
+
* // → { manifest, manifestPath, outDir, bundleSize, fileCount, durationMs }
|
|
32
|
+
*
|
|
33
|
+
* vaultKeyJson is encrypted with the operator passphrase + a fresh
|
|
34
|
+
* salt and stored in the manifest's vaultKeyEnc. With only the
|
|
35
|
+
* passphrase, a restorer on a different machine can recover the
|
|
36
|
+
* framework's vault keypair and unseal the bundle's vault-sealed
|
|
37
|
+
* files post-restore. Without the passphrase, the bundle is opaque.
|
|
38
|
+
*
|
|
39
|
+
* Per-file salts: each file gets its own fresh salt. Argon2id is
|
|
40
|
+
* memory-hard but per-file fresh-salt means an attacker who recovers
|
|
41
|
+
* one file's key from the passphrase has no leverage on other files
|
|
42
|
+
* — the salt rotation forces the full Argon2 computation per file.
|
|
43
|
+
*
|
|
44
|
+
* The bundler does NOT compress files. Operators with large datasets
|
|
45
|
+
* who want compression run their backup pipeline through their own
|
|
46
|
+
* compressor (gzip, zstd) downstream of the framework primitive.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
var fs = require("fs");
|
|
50
|
+
var path = require("path");
|
|
51
|
+
var atomicFile = require("../atomic-file");
|
|
52
|
+
var backupCrypto = require("./crypto");
|
|
53
|
+
var backupManifest = require("./manifest");
|
|
54
|
+
var validateOpts = require("../validate-opts");
|
|
55
|
+
var { defineClass } = require("../framework-error");
|
|
56
|
+
|
|
57
|
+
var BackupBundleError = defineClass("BackupBundleError", { alwaysPermanent: true });
|
|
58
|
+
|
|
59
|
+
function _emit(cb, ev) {
|
|
60
|
+
if (typeof cb === "function") {
|
|
61
|
+
try { cb(ev); } catch (_e) { /* progress-callback errors are non-fatal */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Map relativePath → encryptedPath inside the bundle. Mirrors the
|
|
66
|
+
// directory structure under files/ and appends .enc so every blob
|
|
67
|
+
// has a clear stride and isn't confused with the source file.
|
|
68
|
+
function _encryptedPathFor(relativePath) {
|
|
69
|
+
// POSIX-normalize separators in the bundle so manifests written on
|
|
70
|
+
// Windows and Linux look the same on disk.
|
|
71
|
+
var posix = relativePath.split(path.sep).join("/");
|
|
72
|
+
return "files/" + posix + ".enc";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function create(opts) {
|
|
76
|
+
var t0 = Date.now();
|
|
77
|
+
opts = opts || {};
|
|
78
|
+
if (typeof opts.dataDir !== "string" || !fs.existsSync(opts.dataDir)) {
|
|
79
|
+
throw new BackupBundleError("backup-bundle/no-datadir",
|
|
80
|
+
"create: opts.dataDir is required and must exist");
|
|
81
|
+
}
|
|
82
|
+
validateOpts.requireNonEmptyString(opts.outDir, "create: opts.outDir", BackupBundleError, "backup-bundle/no-outdir");
|
|
83
|
+
if (fs.existsSync(opts.outDir)) {
|
|
84
|
+
throw new BackupBundleError("backup-bundle/outdir-exists",
|
|
85
|
+
"create: outDir already exists: " + opts.outDir +
|
|
86
|
+
" (refusing to overwrite — pick a fresh path)");
|
|
87
|
+
}
|
|
88
|
+
if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
|
|
89
|
+
throw new BackupBundleError("backup-bundle/no-passphrase",
|
|
90
|
+
"create: opts.passphrase is required (Buffer or string)");
|
|
91
|
+
}
|
|
92
|
+
if (typeof opts.vaultKeyJson !== "string" || opts.vaultKeyJson.length === 0) {
|
|
93
|
+
throw new BackupBundleError("backup-bundle/no-vault-key-json",
|
|
94
|
+
"create: opts.vaultKeyJson is required (the in-memory vault keypair JSON; " +
|
|
95
|
+
"use vault.getKeysJson() or read vault.key from disk)");
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(opts.files) || opts.files.length === 0) {
|
|
98
|
+
throw new BackupBundleError("backup-bundle/no-files",
|
|
99
|
+
"create: opts.files must be a non-empty array of include entries");
|
|
100
|
+
}
|
|
101
|
+
var passphrase = opts.passphrase;
|
|
102
|
+
var dataDir = opts.dataDir;
|
|
103
|
+
var outDir = opts.outDir;
|
|
104
|
+
var progress = opts.progressCallback;
|
|
105
|
+
|
|
106
|
+
atomicFile.ensureDir(outDir);
|
|
107
|
+
atomicFile.ensureDir(path.join(outDir, "files"));
|
|
108
|
+
|
|
109
|
+
// 1. Encrypt the vault key JSON
|
|
110
|
+
_emit(progress, { phase: "wrap_vault_key" });
|
|
111
|
+
var wrappedVk = await backupCrypto.encryptWithFreshSalt(opts.vaultKeyJson, passphrase);
|
|
112
|
+
|
|
113
|
+
// 2. Walk each include entry, encrypt the bytes, emit a blob
|
|
114
|
+
var fileEntries = [];
|
|
115
|
+
var totalBytes = 0;
|
|
116
|
+
|
|
117
|
+
for (var i = 0; i < opts.files.length; i++) {
|
|
118
|
+
var entry = opts.files[i];
|
|
119
|
+
if (!entry || typeof entry.relativePath !== "string" || entry.relativePath.length === 0) {
|
|
120
|
+
throw new BackupBundleError("backup-bundle/bad-include",
|
|
121
|
+
"create: files[" + i + "] requires { relativePath: string }");
|
|
122
|
+
}
|
|
123
|
+
if (entry.relativePath.indexOf("..") !== -1 || /^[/\\]/.test(entry.relativePath)) {
|
|
124
|
+
throw new BackupBundleError("backup-bundle/bad-include",
|
|
125
|
+
"create: files[" + i + "].relativePath must be a relative path (got '" + entry.relativePath + "')");
|
|
126
|
+
}
|
|
127
|
+
var srcPath = path.join(dataDir, entry.relativePath);
|
|
128
|
+
if (!fs.existsSync(srcPath)) {
|
|
129
|
+
if (entry.required) {
|
|
130
|
+
throw new BackupBundleError("backup-bundle/missing-required",
|
|
131
|
+
"create: required file missing: " + entry.relativePath);
|
|
132
|
+
}
|
|
133
|
+
_emit(progress, { phase: "skip_missing", relativePath: entry.relativePath });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
var stat = fs.statSync(srcPath);
|
|
137
|
+
if (!stat.isFile()) {
|
|
138
|
+
// Directories aren't supported in this slice — the bundler
|
|
139
|
+
// operates on a flat list of files. Operator wanting a recursive
|
|
140
|
+
// sweep walks the dir themselves and passes the resulting list.
|
|
141
|
+
throw new BackupBundleError("backup-bundle/not-a-file",
|
|
142
|
+
"create: '" + entry.relativePath + "' is not a regular file");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_emit(progress, { phase: "read", relativePath: entry.relativePath, size: stat.size });
|
|
146
|
+
var plain = fs.readFileSync(srcPath);
|
|
147
|
+
var checksum = backupCrypto.checksum(plain);
|
|
148
|
+
var encResult = await backupCrypto.encryptWithFreshSalt(plain, passphrase);
|
|
149
|
+
var encPath = _encryptedPathFor(entry.relativePath);
|
|
150
|
+
var destFull = path.join(outDir, encPath);
|
|
151
|
+
atomicFile.ensureDir(path.dirname(destFull));
|
|
152
|
+
atomicFile.writeSync(destFull, encResult.encrypted, { fileMode: 0o600 });
|
|
153
|
+
|
|
154
|
+
var kind = entry.kind || "raw";
|
|
155
|
+
if (!backupManifest.VALID_KINDS[kind]) {
|
|
156
|
+
throw new BackupBundleError("backup-bundle/bad-kind",
|
|
157
|
+
"create: files[" + i + "].kind must be one of raw, vault-sealed, plaintext (got '" + kind + "')");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fileEntries.push({
|
|
161
|
+
relativePath: entry.relativePath,
|
|
162
|
+
encryptedPath: encPath,
|
|
163
|
+
size: plain.length,
|
|
164
|
+
encryptedSize: encResult.encrypted.length,
|
|
165
|
+
checksum: checksum,
|
|
166
|
+
salt: encResult.salt,
|
|
167
|
+
kind: kind,
|
|
168
|
+
});
|
|
169
|
+
totalBytes += encResult.encrypted.length;
|
|
170
|
+
_emit(progress, {
|
|
171
|
+
phase: "encrypted",
|
|
172
|
+
relativePath: entry.relativePath,
|
|
173
|
+
encryptedSize: encResult.encrypted.length,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (fileEntries.length === 0) {
|
|
178
|
+
// Nothing to write; refuse to emit an empty manifest. Operators
|
|
179
|
+
// who genuinely want an "empty backup" need to revisit their
|
|
180
|
+
// include list.
|
|
181
|
+
throw new BackupBundleError("backup-bundle/empty",
|
|
182
|
+
"create: no files included in bundle (every entry was missing or skipped)");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 3. Build the manifest and write it last (so a half-written bundle
|
|
186
|
+
// can be detected by absence of manifest.json — an integrity tell)
|
|
187
|
+
_emit(progress, { phase: "write_manifest" });
|
|
188
|
+
var manifest = backupManifest.create({
|
|
189
|
+
vaultKeySalt: wrappedVk.salt,
|
|
190
|
+
vaultKeyEnc: wrappedVk.encrypted.toString("base64"),
|
|
191
|
+
files: fileEntries,
|
|
192
|
+
metadata: opts.metadata || undefined,
|
|
193
|
+
});
|
|
194
|
+
var manifestPath = path.join(outDir, "manifest.json");
|
|
195
|
+
atomicFile.writeSync(manifestPath, backupManifest.serialize(manifest), { fileMode: 0o600 });
|
|
196
|
+
|
|
197
|
+
var durationMs = Date.now() - t0;
|
|
198
|
+
_emit(progress, {
|
|
199
|
+
phase: "done",
|
|
200
|
+
fileCount: fileEntries.length,
|
|
201
|
+
bundleSize: totalBytes,
|
|
202
|
+
durationMs: durationMs,
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
manifest: manifest,
|
|
206
|
+
manifestPath: manifestPath,
|
|
207
|
+
outDir: outDir,
|
|
208
|
+
bundleSize: totalBytes,
|
|
209
|
+
fileCount: fileEntries.length,
|
|
210
|
+
durationMs: durationMs,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
create: create,
|
|
216
|
+
BackupBundleError: BackupBundleError,
|
|
217
|
+
};
|