@blamejs/core 0.12.18 → 0.12.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 +4 -0
- package/lib/backup/index.js +191 -0
- package/lib/safe-archive.js +13 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.20 (2026-05-24) — **`bundleAdapterStorage.verifyAllBundles(opts)` — bounded-parallel batch integrity walk with stopOnFirstFailure short-circuit.** Batch wrapper over the v0.12.19 verifyBundle primitive. `storage.verifyAllBundles(opts?)` iterates `listBundles()` + walks each bundle with a bounded-parallel pool. Returns `{ total, ok, failed, results }` where `results` carries every per-bundle verifyBundle output (including bundleId, format, envelopeKind, entryCount, errors). `opts.concurrency` defaults to 4 (gentle on the storage backend); `opts.stopOnFirstFailure` short-circuits the walk when an unhealthy bundle is found (default off — operators want the full health report). `opts.recipient` / `opts.passphrase` forwarded to every per-bundle verify call. Operators wiring a periodic cron job over a backup repository now have a single primitive to call instead of hand-rolling the listBundles loop. **Added:** *`storage.verifyAllBundles(opts?)` — batch integrity walk* — Iterates `listBundles()` + calls `verifyBundle(bundleId, opts)` on each. Bounded-parallel fan-out (default 4 workers; `opts.concurrency` raises or lowers); each worker pulls from a shared queue + the warm-up keeps `concurrency` workers in flight until the queue drains. Returns the aggregate `{ total, ok, failed, results }` with `results` sorted by bundleId so the report is stable across runs regardless of completion order. `opts.stopOnFirstFailure` short-circuits the walk when the first unhealthy bundle is found — useful for fast-fail CI gates that don't need to enumerate every failure.
|
|
12
|
+
|
|
13
|
+
- v0.12.19 (2026-05-24) — **`bundleAdapterStorage.verifyBundle(bundleId)` — bundle integrity check without restore + `b.safeArchive.inspect` tar.gz support.** `storage.verifyBundle(bundleId, opts?)` walks a bundle without restoring it: confirms the payload exists, the envelope (if any) decrypts under the supplied key, and the inner tar / tar.gz walker enumerates every entry without writing to disk. Returns `{ ok, format, envelopeKind, entryCount, errors }` — operators wanting periodic health-check of a backup repository call verifyBundle across `listBundles()` and aggregate `ok === false` results. Composes the bundle's known format directly through the unwrap → gunzip → tar pipeline (skips safeArchive's auto-sniff because the bundle's format is already known from bundleInfo). `b.safeArchive.inspect` separately gains `format: "tar.gz"` dispatch — operators with a known tar.gz payload now get entry enumeration without extracting. **Added:** *`storage.verifyBundle(bundleId, opts?)` — integrity check without restore* — Composes bundleInfo for format/envelope detection + the unwrap → gunzip → tar walker chain directly. opts.recipient / opts.passphrase override the storage's configured keys (useful for verifying a bundle under a different key set than the storage was opened with — e.g. verifying that a key rotation candidate can still read a bundle). Wrong key / corrupted payload returns `ok: false` with a typed error code in the errors array rather than throwing. Directory format reports ok=true based on manifest existence + readability (the inspect walker doesn't apply). · *`b.safeArchive.inspect` accepts `format: "tar.gz"`* — Mirrors the v0.12.9 extract surface: operators handed a `.tar.gz` payload can now enumerate entries without extracting. Composes `b.archive.read.gz` + `.asTar()` internally so the same bomb caps apply (1 GiB output / 100× ratio default) to inspect calls.
|
|
14
|
+
|
|
11
15
|
- v0.12.18 (2026-05-24) — **`bundleAdapterStorage.listBundles({ withStats })` + `bundleInfo.createdAt` — opt-in mtime + size from `statKey`.** Two additions on `b.backup.bundleAdapterStorage`. `listBundles({ withStats: true })` fans out `statKey` per bundle and populates `createdAt` (ISO string from mtimeMs) + `size` (bytes) on every entry. Without the opt the default fast path stays a single listKeys call — operators rendering a bundle picker UI choose between O(1) listings and O(N) stat-enriched listings explicitly. `bundleInfo(bundleId)` gains the same `createdAt` field for parity. fsAdapter + objectStoreAdapter both expose `statKey`; legacy adapters without the capability leave the fields null. **Added:** *`storage.listBundles({ withStats: true })` — opt-in per-bundle stat fan-out* — When the adapter exposes `statKey`, populates `createdAt` (ISO string from mtimeMs) + `size` (bytes) per entry. Stat fan-out is O(N) round-trips so the opt is OFF by default — operators wanting cheap one-shot listings stay on `listBundles()`. Format precedence (tar.gz > tar > directory) carries through to which payload key gets stat'd. · *`storage.bundleInfo(bundleId)` returns `createdAt`* — The bundle introspection primitive now returns `{ bundleId, format, envelopeKind, sizeBytes, createdAt }`. `createdAt` is the ISO string derived from `statKey.mtimeMs` (when the adapter exposes it) — null otherwise. Matches the listBundles+withStats shape so operators can use bundleInfo for single-bundle drill-downs without re-mapping field names.
|
|
12
16
|
|
|
13
17
|
- v0.12.17 (2026-05-23) — **`bundleAdapterStorage.bundleInfo` + `listBundles.format` — per-bundle introspection for envelope kind + format without restore.** Two introspection additions on `b.backup.bundleAdapterStorage`. `listBundles()` now returns the inferred `format` (`"tar"` / `"tar.gz"` / `"directory"`) per bundle from the storage key suffix — no byte read. `storage.bundleInfo(bundleId)` returns `{ bundleId, format, envelopeKind, sizeBytes }` where `envelopeKind` is the result of a 5-byte magic probe (`"recipient"` / `"passphrase"` / `"none"`). Operators administering a multi-strategy backup repository can now filter bundles by encryption posture or by format without a full restore cycle. **Added:** *`listBundles()` carries inferred format per bundle* — Each entry now includes `format` alongside `bundleId` / `createdAt` / `size`. Inference is from the storage key suffix the writeBundle path produced — `<bid>/bundle.tar` → tar, `<bid>/bundle.tar.gz` → tar.gz, anything else → directory. Cheap: no byte read, no per-key stat call. Operators rendering a bundle picker UI now sort + filter by format from a single list call. · *`storage.bundleInfo(bundleId)` — per-bundle introspection* — Returns `{ bundleId, format, envelopeKind, sizeBytes }`. `format` from the storage layout (no byte read). `envelopeKind` from `b.archive.sniffEnvelope` over the bundle payload — `"recipient"` (BAWRP / v0.12.10 hybrid PQC), `"passphrase"` (BAWPP / v0.12.11 Argon2id), `"none"` (plaintext or directory format). `sizeBytes` is the payload byte count for tar / tar.gz; null for directory format (operator's per-file walk applies if exact size matters). Nonexistent bundles refused with `backup/bundle-not-found`.
|
package/lib/backup/index.js
CHANGED
|
@@ -1436,6 +1436,197 @@ function bundleAdapterStorage(opts) {
|
|
|
1436
1436
|
out.sort(function (a, b) { return a.bundleId < b.bundleId ? 1 : -1; });
|
|
1437
1437
|
return out;
|
|
1438
1438
|
},
|
|
1439
|
+
// verifyBundle(bundleId, opts?) — v0.12.19 integrity check.
|
|
1440
|
+
// Walks the bundle without restoring: confirms the payload
|
|
1441
|
+
// exists, the envelope (if any) decrypts under the supplied
|
|
1442
|
+
// key, and the inner archive structure is well-formed (every
|
|
1443
|
+
// entry enumerable). Returns `{ ok, format, envelopeKind,
|
|
1444
|
+
// entryCount, errors }`. opts.recipient / opts.passphrase
|
|
1445
|
+
// forwarded when the bundle is wrap-wrapped; omit them for
|
|
1446
|
+
// plaintext bundles. Composes safeArchive.inspect which gates
|
|
1447
|
+
// entries through bomb-policy + entry-type-policy walkers, so
|
|
1448
|
+
// a malformed archive surfaces with a typed error rather than
|
|
1449
|
+
// crashing the verify call.
|
|
1450
|
+
async verifyBundle(bundleId, vOpts) {
|
|
1451
|
+
vOpts = vOpts || {};
|
|
1452
|
+
_ensureBundleId(bundleId);
|
|
1453
|
+
var info;
|
|
1454
|
+
try { info = await this.bundleInfo(bundleId); }
|
|
1455
|
+
catch (e) {
|
|
1456
|
+
return {
|
|
1457
|
+
ok: false,
|
|
1458
|
+
format: null,
|
|
1459
|
+
envelopeKind: null,
|
|
1460
|
+
entryCount: 0,
|
|
1461
|
+
errors: [e.code || e.message || String(e)],
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
if (info.format === "directory") {
|
|
1465
|
+
// Directory format isn't archive-shaped — manifest.json
|
|
1466
|
+
// existence + readability via bundleInfo IS the
|
|
1467
|
+
// verification. No inspect walk applies.
|
|
1468
|
+
return {
|
|
1469
|
+
ok: true,
|
|
1470
|
+
format: info.format,
|
|
1471
|
+
envelopeKind: info.envelopeKind,
|
|
1472
|
+
entryCount: null,
|
|
1473
|
+
errors: [],
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
// Compose the reader chain directly so we don't depend on
|
|
1477
|
+
// safeArchive's auto-sniff dispatch (which is conservative
|
|
1478
|
+
// about inferring "tar.gz" from gzip-magic-only inner bytes).
|
|
1479
|
+
// We already know the bundle's format + envelopeKind from
|
|
1480
|
+
// bundleInfo — apply the layers in order: unwrap (if any)
|
|
1481
|
+
// → gunzip (if tar.gz) → tar walker.
|
|
1482
|
+
var keySuffix = info.format === "tar.gz" ? TAR_GZ_KEY_SUFFIX : TAR_KEY_SUFFIX;
|
|
1483
|
+
var payload = await adapter.readFile(bundleId + keySuffix);
|
|
1484
|
+
try {
|
|
1485
|
+
// Layer 1 — unwrap envelope if present.
|
|
1486
|
+
if (info.envelopeKind === "recipient") {
|
|
1487
|
+
var rcp = vOpts.recipient !== undefined ? vOpts.recipient : recipient;
|
|
1488
|
+
if (!rcp) {
|
|
1489
|
+
return {
|
|
1490
|
+
ok: false, format: info.format, envelopeKind: info.envelopeKind,
|
|
1491
|
+
entryCount: 0, errors: ["backup/no-recipient-for-verify"],
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
payload = archiveLazy().unwrap(payload, { recipient: rcp });
|
|
1495
|
+
} else if (info.envelopeKind === "passphrase") {
|
|
1496
|
+
var pp = vOpts.passphrase !== undefined ? vOpts.passphrase : passphrase;
|
|
1497
|
+
if (typeof pp !== "string" && !Buffer.isBuffer(pp)) {
|
|
1498
|
+
return {
|
|
1499
|
+
ok: false, format: info.format, envelopeKind: info.envelopeKind,
|
|
1500
|
+
entryCount: 0, errors: ["backup/no-passphrase-for-verify"],
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
payload = await archiveLazy().unwrapWithPassphrase(payload, { passphrase: pp });
|
|
1504
|
+
}
|
|
1505
|
+
// Layer 2 — gunzip if tar.gz.
|
|
1506
|
+
var tarReader;
|
|
1507
|
+
if (info.format === "tar.gz") {
|
|
1508
|
+
var gzReader = archiveLazy().read.gz(archiveAdaptersLazy().buffer(payload), {
|
|
1509
|
+
maxDecompressedBytes: maxBundleBytes,
|
|
1510
|
+
maxExpansionRatio: 0,
|
|
1511
|
+
});
|
|
1512
|
+
tarReader = gzReader.asTar();
|
|
1513
|
+
} else {
|
|
1514
|
+
tarReader = archiveLazy().read.tar(archiveAdaptersLazy().buffer(payload));
|
|
1515
|
+
}
|
|
1516
|
+
// Layer 3 — walk the tar entries (inspect doesn't extract).
|
|
1517
|
+
var entries = await tarReader.inspect();
|
|
1518
|
+
return {
|
|
1519
|
+
ok: true,
|
|
1520
|
+
format: info.format,
|
|
1521
|
+
envelopeKind: info.envelopeKind,
|
|
1522
|
+
entryCount: entries.length,
|
|
1523
|
+
errors: [],
|
|
1524
|
+
};
|
|
1525
|
+
} catch (e) {
|
|
1526
|
+
return {
|
|
1527
|
+
ok: false,
|
|
1528
|
+
format: info.format,
|
|
1529
|
+
envelopeKind: info.envelopeKind,
|
|
1530
|
+
entryCount: 0,
|
|
1531
|
+
errors: [e.code || e.message || String(e)],
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
},
|
|
1535
|
+
// verifyAllBundles(opts?) — v0.12.20 batch integrity check.
|
|
1536
|
+
// Iterates listBundles() + calls verifyBundle on each. Returns
|
|
1537
|
+
// `{ total, ok, failed, results }` where `results` is an array
|
|
1538
|
+
// of per-bundle verifyBundle outputs. opts.concurrency caps the
|
|
1539
|
+
// parallelism (default 4 — gentle on the storage backend);
|
|
1540
|
+
// opts.stopOnFirstFailure short-circuits the walk when an
|
|
1541
|
+
// unhealthy bundle is found (default false — operators want
|
|
1542
|
+
// the full report). opts.recipient / opts.passphrase forwarded
|
|
1543
|
+
// to verifyBundle for each bundle.
|
|
1544
|
+
async verifyAllBundles(vOpts) {
|
|
1545
|
+
vOpts = vOpts || {};
|
|
1546
|
+
// Codex P1 on v0.12.20 PR #171 — clamp fractional + zero
|
|
1547
|
+
// floors so a stray `0.5` doesn't spawn zero workers + return
|
|
1548
|
+
// a silent ok=0/failed=0 report on non-empty storage. Default
|
|
1549
|
+
// 4; minimum 1; non-finite / non-positive falls back to
|
|
1550
|
+
// default.
|
|
1551
|
+
var concurrency = 4; // allow:raw-byte-literal — default fan-out, not byte count
|
|
1552
|
+
if (typeof vOpts.concurrency === "number" && Number.isFinite(vOpts.concurrency) &&
|
|
1553
|
+
vOpts.concurrency > 0) {
|
|
1554
|
+
concurrency = Math.max(1, Math.floor(vOpts.concurrency));
|
|
1555
|
+
}
|
|
1556
|
+
var stopOnFirst = vOpts.stopOnFirstFailure === true;
|
|
1557
|
+
var list = await this.listBundles();
|
|
1558
|
+
var self = this;
|
|
1559
|
+
var results = [];
|
|
1560
|
+
var failed = 0;
|
|
1561
|
+
var ok = 0;
|
|
1562
|
+
var pending = list.slice();
|
|
1563
|
+
var inflight = [];
|
|
1564
|
+
var aborted = false;
|
|
1565
|
+
// Sequential bounded-parallel walk. Bring up `concurrency`
|
|
1566
|
+
// workers; each pulls the next bundleId until the queue is
|
|
1567
|
+
// empty or stopOnFirstFailure trips.
|
|
1568
|
+
function _spawn() {
|
|
1569
|
+
if (aborted) return null;
|
|
1570
|
+
if (pending.length === 0) return null;
|
|
1571
|
+
var entry = pending.shift();
|
|
1572
|
+
// Codex P2 on v0.12.20 PR #171 — wrap each worker so any
|
|
1573
|
+
// verifyBundle rejection becomes a failed-result entry
|
|
1574
|
+
// rather than rejecting the whole batch. Without this, a
|
|
1575
|
+
// mid-walk failure (payload disappeared between listBundles
|
|
1576
|
+
// + readFile, network blip on object-store, etc.) would
|
|
1577
|
+
// throw out of Promise.race and abort verifyAllBundles
|
|
1578
|
+
// without returning the promised aggregate report.
|
|
1579
|
+
var promise = self.verifyBundle(entry.bundleId, {
|
|
1580
|
+
recipient: vOpts.recipient,
|
|
1581
|
+
passphrase: vOpts.passphrase,
|
|
1582
|
+
}).then(function (r) {
|
|
1583
|
+
results.push(Object.assign({ bundleId: entry.bundleId }, r));
|
|
1584
|
+
if (r.ok) ok += 1;
|
|
1585
|
+
else {
|
|
1586
|
+
failed += 1;
|
|
1587
|
+
if (stopOnFirst) aborted = true;
|
|
1588
|
+
}
|
|
1589
|
+
}, function (err) {
|
|
1590
|
+
// Rejection path — convert to a failed-result entry so
|
|
1591
|
+
// the aggregate stays consistent.
|
|
1592
|
+
results.push({
|
|
1593
|
+
bundleId: entry.bundleId,
|
|
1594
|
+
ok: false,
|
|
1595
|
+
format: null,
|
|
1596
|
+
envelopeKind: null,
|
|
1597
|
+
entryCount: 0,
|
|
1598
|
+
errors: [err && err.code ? err.code : ((err && err.message) || String(err))],
|
|
1599
|
+
});
|
|
1600
|
+
failed += 1;
|
|
1601
|
+
if (stopOnFirst) aborted = true;
|
|
1602
|
+
});
|
|
1603
|
+
inflight.push(promise);
|
|
1604
|
+
promise.finally(function () {
|
|
1605
|
+
var idx = inflight.indexOf(promise);
|
|
1606
|
+
if (idx !== -1) inflight.splice(idx, 1);
|
|
1607
|
+
});
|
|
1608
|
+
return promise;
|
|
1609
|
+
}
|
|
1610
|
+
// Warm-up: spawn up to concurrency workers.
|
|
1611
|
+
var i;
|
|
1612
|
+
for (i = 0; i < concurrency; i += 1) _spawn();
|
|
1613
|
+
// Drain: as each worker resolves, spawn its replacement.
|
|
1614
|
+
while (inflight.length > 0) {
|
|
1615
|
+
await Promise.race(inflight.slice());
|
|
1616
|
+
if (!aborted && pending.length > 0 && inflight.length < concurrency) {
|
|
1617
|
+
while (inflight.length < concurrency && pending.length > 0) _spawn();
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
// Sort results back to listBundles order for operator-
|
|
1621
|
+
// friendly output (concurrency reorders inflight completion).
|
|
1622
|
+
results.sort(function (a, b) { return a.bundleId < b.bundleId ? 1 : -1; });
|
|
1623
|
+
return {
|
|
1624
|
+
total: list.length,
|
|
1625
|
+
ok: ok,
|
|
1626
|
+
failed: failed,
|
|
1627
|
+
results: results,
|
|
1628
|
+
};
|
|
1629
|
+
},
|
|
1439
1630
|
// bundleInfo(bundleId) — v0.12.17 per-bundle introspection.
|
|
1440
1631
|
// Returns `{ bundleId, format, envelopeKind, sizeBytes }`.
|
|
1441
1632
|
// `format` is one of `"tar"` / `"tar.gz"` / `"directory"`
|
package/lib/safe-archive.js
CHANGED
|
@@ -373,9 +373,21 @@ async function inspect(opts) {
|
|
|
373
373
|
bombPolicy: opts.bombPolicy,
|
|
374
374
|
audit: opts.audit,
|
|
375
375
|
});
|
|
376
|
+
} else if (format === "tar.gz") {
|
|
377
|
+
// v0.12.19 — inspect parity with extract for tar.gz format.
|
|
378
|
+
// gz envelope auto-decompresses + the inner tar walker
|
|
379
|
+
// enumerates entries without writing to disk.
|
|
380
|
+
reader = archiveGz().read.gz(source, {
|
|
381
|
+
maxDecompressedBytes: opts.maxDecompressedBytes,
|
|
382
|
+
maxExpansionRatio: opts.maxExpansionRatio,
|
|
383
|
+
audit: opts.audit,
|
|
384
|
+
}).asTar({
|
|
385
|
+
bombPolicy: opts.bombPolicy,
|
|
386
|
+
audit: opts.audit,
|
|
387
|
+
});
|
|
376
388
|
} else {
|
|
377
389
|
throw new SafeArchiveError("safe-archive/format-unsupported",
|
|
378
|
-
"inspect: format=" + JSON.stringify(format) + " — v0.12.
|
|
390
|
+
"inspect: format=" + JSON.stringify(format) + " — v0.12.19 ships ZIP + tar + tar.gz; auto-unwraps wrap envelopes");
|
|
379
391
|
}
|
|
380
392
|
var entries = await reader.inspect();
|
|
381
393
|
var totalCompressed = 0;
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:d7fb9c32-9b4a-4de6-a37c-b0a5326a3078",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-24T06:29:55.574Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.12.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.12.20",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.12.
|
|
25
|
+
"version": "0.12.20",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.12.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.12.20",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.12.
|
|
57
|
+
"ref": "@blamejs/core@0.12.20",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|