@blamejs/core 0.17.11 → 0.17.13

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.
@@ -57,7 +57,6 @@ var nodeCrypto = require("node:crypto");
57
57
  var numericBounds = require("./numeric-bounds");
58
58
  var atomicFile = require("./atomic-file");
59
59
  var validateOpts = require("./validate-opts");
60
- var bCrypto = require("./crypto");
61
60
  var guardRegex = require("./guard-regex");
62
61
  var httpClient = require("./http-client");
63
62
  var safeJson = require("./safe-json");
@@ -301,6 +300,60 @@ function _matchAsset(name, pattern, fallback) {
301
300
  return fallback ? fallback.test(name) : false;
302
301
  }
303
302
 
303
+ // Detached-signature suffixes. A release's detached signature is conventionally
304
+ // the asset name plus one of these (asset.tar.gz.sig / .asc / .sig.bin).
305
+ var _SIG_SUFFIXES = [".sig", ".asc", ".sig.bin"];
306
+ var _SIG_SHAPE = /\.sig$|\.asc$|\.sig\.bin$/i;
307
+
308
+ function _assetObj(a) {
309
+ return {
310
+ name: a.name,
311
+ url: a.browser_download_url,
312
+ size: a.size || null,
313
+ digest: typeof a.digest === "string" ? a.digest : null,
314
+ };
315
+ }
316
+
317
+ function _findEntryByName(entries, name) {
318
+ for (var i = 0; i < entries.length; i++) {
319
+ if (entries[i].name === name) return entries[i];
320
+ }
321
+ return null;
322
+ }
323
+
324
+ // _selectSignatureFor — pick the detached signature OF `assetName`, not a
325
+ // first-match-wins signature that may sign a DIFFERENT sidecar. Selecting the
326
+ // asset first and DERIVING the expected signature name from it is the pairing
327
+ // contract: a returned { asset, signature } is guaranteed to be an asset and
328
+ // the signature over exactly that asset. Falls back to a lone signature-shaped
329
+ // asset only when the release ships exactly one (the common one-asset-one-sig
330
+ // case); anything ambiguous fails closed (null) rather than pairing a signature
331
+ // that may not sign the returned asset.
332
+ function _selectSignatureFor(assetName, entries, signaturePattern) {
333
+ // (a) Strong pairing: the asset name plus a signature suffix.
334
+ for (var s = 0; s < _SIG_SUFFIXES.length; s++) {
335
+ var hit = _findEntryByName(entries, assetName + _SIG_SUFFIXES[s]);
336
+ // When the operator constrained signaturePattern, the derived name must
337
+ // also satisfy it; otherwise the derived name is authoritative.
338
+ if (hit && (signaturePattern === undefined || _matchAsset(hit.name, signaturePattern, null))) {
339
+ return _assetObj(hit);
340
+ }
341
+ }
342
+ // (b) Operator signaturePattern, no derived hit: accept only a pattern match
343
+ // that ALSO references the asset stem, and only when unambiguous — else fail
344
+ // closed (never pair a pattern hit that may sign a different asset).
345
+ if (signaturePattern !== undefined) {
346
+ var stemMatches = entries.filter(function (e) {
347
+ return e.name.indexOf(assetName) === 0 && _matchAsset(e.name, signaturePattern, null);
348
+ });
349
+ return stemMatches.length === 1 ? _assetObj(stemMatches[0]) : null;
350
+ }
351
+ // (c) No operator pattern, no derived hit: accept a lone signature-shaped
352
+ // asset (single-sig release), else null.
353
+ var sigShaped = entries.filter(function (e) { return _SIG_SHAPE.test(e.name); });
354
+ return sigShaped.length === 1 ? _assetObj(sigShaped[0]) : null;
355
+ }
356
+
304
357
  /**
305
358
  * @primitive b.selfUpdate.poll
306
359
  * @signature b.selfUpdate.poll(opts)
@@ -474,21 +527,27 @@ async function poll(opts) {
474
527
  }
475
528
 
476
529
  var assets = Array.isArray(latest.assets) ? latest.assets : [];
477
- var assetMatch = null;
478
- var signatureMatch = null;
530
+ // Collect the well-formed asset entries once, preserving feed order.
531
+ var entries = [];
479
532
  for (var i = 0; i < assets.length; i++) {
480
533
  var a = assets[i] || {};
481
534
  if (typeof a.name !== "string" || typeof a.browser_download_url !== "string") continue;
482
- if (signatureMatch === null && _matchAsset(a.name, opts.signaturePattern, /\.sig$|\.asc$|\.sig\.bin$/i)) {
483
- signatureMatch = { name: a.name, url: a.browser_download_url, size: a.size || null,
484
- digest: typeof a.digest === "string" ? a.digest : null };
485
- continue;
486
- }
487
- if (assetMatch === null && _matchAsset(a.name, opts.assetPattern, /\.(tar\.gz|tgz|zip|node|exe|bin)$/i)) {
488
- assetMatch = { name: a.name, url: a.browser_download_url, size: a.size || null,
489
- digest: typeof a.digest === "string" ? a.digest : null };
535
+ entries.push(a);
536
+ }
537
+ // Select the runtime asset FIRST, then derive its detached signature — so the
538
+ // returned signature is the sig OF the returned asset, never a first-match-wins
539
+ // sig that may belong to a different sidecar (#497).
540
+ var assetMatch = null;
541
+ var signatureMatch = null;
542
+ for (var j = 0; j < entries.length; j++) {
543
+ if (_matchAsset(entries[j].name, opts.assetPattern, /\.(tar\.gz|tgz|zip|node|exe|bin)$/i)) {
544
+ assetMatch = _assetObj(entries[j]);
545
+ break;
490
546
  }
491
547
  }
548
+ if (assetMatch) {
549
+ signatureMatch = _selectSignatureFor(assetMatch.name, entries, opts.signaturePattern);
550
+ }
492
551
 
493
552
  _safeAuditEmit("selfupdate.poll.checked", "success", {
494
553
  releasesUrl: opts.releasesUrl,
@@ -539,13 +598,20 @@ function _validateVerifyOpts(opts) {
539
598
  * @since 0.6.0
540
599
  * @related b.selfUpdate.poll, b.selfUpdate.swap, b.crypto.verify
541
600
  *
542
- * Verify a detached signature over the asset bytes. The algorithm is
543
- * auto-detected from `opts.pubkeyPem` (ML-DSA-87 / Ed25519 / ECDSA
544
- * P-384) by `b.crypto.verify`. Reports the asset's hash alongside the
545
- * verified flag for SBOM / audit correlation; the supported digest
546
- * algorithms are sha3-512 (default), sha-256, sha-512, and shake256.
547
- * Throws SelfUpdateError on a missing file, a verify-time exception,
548
- * or a signature that does not verify.
601
+ * Verify a detached signature over the asset bytes. The signature
602
+ * algorithm is auto-detected from `opts.pubkeyPem` (ML-DSA-87 / Ed25519
603
+ * / ECDSA P-384). Verification routes through the framework's own
604
+ * `standaloneVerifier`, which streams the asset (no whole-file buffer),
605
+ * commits to a SHA3-512 digest, and dispatches the ECDSA signature
606
+ * encoding by structure (DER SEQUENCE vs raw IEEE-P1363) — so a release
607
+ * sidecar signed SHA3-512-then-sign with either encoding verifies, and
608
+ * the accept set is identical to `b.selfUpdate.standaloneVerifier.verify`
609
+ * (no verifier divergence between the install-pipeline and installed
610
+ * paths). Reports the asset's hash alongside the verified flag for SBOM /
611
+ * audit correlation; the supported digest algorithms are sha3-512
612
+ * (default), sha-256, sha-512, and shake256. Throws SelfUpdateError on a
613
+ * missing file, a verify-time exception, or a signature that does not
614
+ * verify.
549
615
  *
550
616
  * @opts
551
617
  * assetPath: string, // required — path to the downloaded asset
@@ -565,56 +631,67 @@ function _validateVerifyOpts(opts) {
565
631
  * e.code; // → "selfupdate/read-failed"
566
632
  * }
567
633
  */
634
+ // _mapStandaloneKind — translate a standaloneVerifier `.kind` into this
635
+ // module's typed selfupdate/* code. File-availability / size-cap failures are
636
+ // read-failed; a cryptographic non-verification is signature-mismatch; a
637
+ // structural / key / encoding problem is verify-failed. Keeping the mapping
638
+ // structural (off `.kind`, not English message text) means a message reword in
639
+ // the zero-dep verifier never silently reclassifies a failure here.
640
+ function _mapStandaloneKind(kind) {
641
+ switch (kind) {
642
+ case "asset-not-found":
643
+ case "sig-not-found":
644
+ case "sig-too-large":
645
+ case "asset-too-large":
646
+ case "size-race":
647
+ return "selfupdate/read-failed";
648
+ case "verify-failed":
649
+ return "selfupdate/signature-mismatch";
650
+ default: // bad-input / bad-pubkey / unsupported-key / sig-empty / bad-sig-encoding
651
+ return "selfupdate/verify-failed";
652
+ }
653
+ }
654
+
568
655
  async function verify(opts) {
569
656
  _validateVerifyOpts(opts);
570
657
  var alg = opts.hashAlgo || DEFAULT_HASH_ALG;
658
+ var maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : C.BYTES.gib(1);
659
+ // The default sha3-512 / sha-256 reported digests are already produced by the
660
+ // standalone verifier's single pass; only a non-default reported alg needs an
661
+ // extra digest folded into that same stream (no second read of the asset).
662
+ var extraDigests = (alg === "sha3-512" || alg === "sha-256") ? [] : [alg];
571
663
 
572
- var assetBytes;
573
- var sigBytes;
664
+ var result;
574
665
  try {
575
- assetBytes = await atomicFile.read(opts.assetPath, {
576
- maxBytes: typeof opts.maxBytes === "number" ? opts.maxBytes : C.BYTES.gib(1),
577
- });
578
- sigBytes = await atomicFile.read(opts.signaturePath, {
579
- maxBytes: C.BYTES.kib(64),
666
+ // Route the signature verification through the framework's own
667
+ // standaloneVerifier so the installed path and the copy-into-install-pipeline
668
+ // path share ONE verifier — SHA3-512 digest, DER/IEEE-P1363 structural
669
+ // dispatch, streamed (no whole-asset in-memory buffer). It fails closed on a
670
+ // wrong key, truncated / empty signature, or size-cap breach.
671
+ result = standaloneVerifier.verify(opts.assetPath, opts.signaturePath, opts.pubkeyPem, {
672
+ maxAssetBytes: maxBytes,
673
+ extraDigests: extraDigests,
580
674
  });
581
675
  } catch (e) {
676
+ var code = _mapStandaloneKind(e && e.kind);
582
677
  _safeAuditEmit("selfupdate.verify.failed", "denied", {
583
678
  assetPath: opts.assetPath, signaturePath: opts.signaturePath,
584
- reason: "read-failed", message: (e && e.message) || String(e),
585
- });
586
- throw new SelfUpdateError("selfupdate/read-failed",
587
- "selfUpdate.verify: read failed: " + ((e && e.message) || String(e)));
588
- }
589
-
590
- var ok = false;
591
- try { ok = bCrypto.verify(assetBytes, sigBytes, opts.pubkeyPem); }
592
- catch (e) {
593
- _safeAuditEmit("selfupdate.verify.failed", "denied", {
594
- assetPath: opts.assetPath, signaturePath: opts.signaturePath,
595
- reason: "verify-threw", message: (e && e.message) || String(e),
679
+ reason: (e && e.kind) || "verify-error", message: (e && e.message) || String(e),
596
680
  });
597
- throw new SelfUpdateError("selfupdate/verify-failed",
598
- "selfUpdate.verify: signature verify threw: " + ((e && e.message) || String(e)));
681
+ throw new SelfUpdateError(code,
682
+ "selfUpdate.verify: " + ((e && e.message) || String(e)));
599
683
  }
600
684
 
601
- var hashHex = nodeCrypto.createHash(alg).update(assetBytes).digest("hex");
602
-
603
- if (!ok) {
604
- _safeAuditEmit("selfupdate.verify.failed", "denied", {
605
- assetPath: opts.assetPath, signaturePath: opts.signaturePath,
606
- alg: alg, hash: hashHex, reason: "signature-mismatch",
607
- });
608
- throw new SelfUpdateError("selfupdate/signature-mismatch",
609
- "selfUpdate.verify: signature did not verify against the supplied public key");
610
- }
685
+ var hashHex = alg === "sha3-512" ? result.sha3_512
686
+ : alg === "sha-256" ? result.sha256
687
+ : result.digests[alg];
611
688
 
612
689
  _safeAuditEmit("selfupdate.verify.passed", "success", {
613
690
  assetPath: opts.assetPath, signaturePath: opts.signaturePath,
614
- alg: alg, hash: hashHex, bytes: assetBytes.length,
691
+ alg: alg, hash: hashHex, bytes: result.bytes,
615
692
  });
616
693
  log("selfUpdate.verify passed asset=" + opts.assetPath + " alg=" + alg);
617
- return { verified: true, hash: hashHex, alg: alg, bytes: assetBytes.length };
694
+ return { verified: true, hash: hashHex, alg: alg, bytes: result.bytes };
618
695
  }
619
696
 
620
697
  // ---- swap ----
@@ -656,25 +733,47 @@ function _validateSwapOpts(opts, label) {
656
733
  validateOpts.shape(opts, schema, "selfUpdate." + label, SelfUpdateError, "selfupdate/bad-opts");
657
734
  }
658
735
 
659
- // _safeRollbackbest-effort restore of `to` from `backupTo` during
660
- // the swap failure paths. Returns null on success (or when no backup
661
- // existed); returns the rollback Error otherwise so the caller can
662
- // throw a distinct `selfupdate/swap-rollback-failed`. Emits the
663
- // `selfupdate.swap.rollback_failed` audit event when rollback fails
664
- // (the prior best-effort catch dropped this signal silently
665
- // operators with no audit row for `rollback_failed` couldn't tell a
666
- // successful swap-with-rollback from a failed both-binaries-lost
667
- // scenario). SSDF RV.1.
736
+ // _relocateFilemove `src` -> `dst`, preferring an atomic rename. A rename
737
+ // moves even a locked, RUNNING image on Windows (which refuses an in-place
738
+ // replace of a mapped executable but allows a rename/move) and needs no second
739
+ // copy; on EXDEV (cross-volume) it falls back to copy + unlink. This one
740
+ // primitive backs BOTH "move the outgoing `to` aside to its backup before an
741
+ // install" and "restore the backup over `to` on rollback", so the locked-image
742
+ // path is handled identically in the install and rollback directions.
743
+ async function _relocateFile(src, dst, fileMode) {
744
+ try {
745
+ atomicFile.renameWithRetry(src, dst);
746
+ return;
747
+ } catch (e) {
748
+ if (!e || e.code !== "EXDEV") throw e;
749
+ // Cross-volume: a rename can't cross the device boundary. Preserve the
750
+ // bytes by copy, then remove the source (best-effort — a locked cross-volume
751
+ // source is the documented limitation of this rare fallback).
752
+ await atomicFile.copy(src, dst, { fileMode: fileMode });
753
+ try { nodeFs.unlinkSync(src); } catch (_u) { /* cross-vol source cleanup — operator-cleanable */ }
754
+ }
755
+ }
756
+
757
+ // _safeRollback — best-effort restore of `to` from `backupTo` during the swap
758
+ // failure paths. Routes through _relocateFile so the moved-aside backup is
759
+ // renamed back over the (now-absent) `to` — a rename that succeeds even where an
760
+ // in-place copy-replace would be blocked by a lock. Returns null on success (or
761
+ // when no backup existed); returns the rollback Error otherwise so the caller
762
+ // can throw a distinct `selfupdate/swap-rollback-failed`. Emits the
763
+ // `selfupdate.swap.rollback_failed` audit event when rollback fails (the prior
764
+ // best-effort catch dropped this signal silently — operators with no audit row
765
+ // for `rollback_failed` couldn't tell a successful swap-with-rollback from a
766
+ // failed both-binaries-lost scenario). SSDF RV.1.
668
767
  async function _safeRollback(backupTo, to, hadOriginal) {
669
768
  if (!hadOriginal) return null;
670
769
  try {
671
- await atomicFile.copy(backupTo, to, { fileMode: 0o600 });
770
+ await _relocateFile(backupTo, to, 0o600);
672
771
  return null;
673
772
  } catch (re) {
674
773
  var err = re instanceof Error ? re : new Error(String(re));
675
774
  _safeAuditEmit("selfupdate.swap.rollback_failed", "denied", {
676
775
  to: to, backupTo: backupTo,
677
- reason: "rollback-copy-failed",
776
+ reason: "rollback-restore-failed",
678
777
  message: err.message,
679
778
  });
680
779
  return err;
@@ -684,29 +783,33 @@ async function _safeRollback(backupTo, to, hadOriginal) {
684
783
  // Atomic swap of `from` -> `to` with rollback on failure. Steps:
685
784
  //
686
785
  // 1. ensure `to` and `backupTo` parents exist
687
- // 2. if `to` exists — copy bytes to `backupTo` (atomic write of the
688
- // backup, preserving the original on `to` until step 3)
689
- // 3. rename `from` -> `to` (atomic on the same FS; cross-device is
690
- // detected and surfaced as selfupdate/cross-device)
786
+ // 2. if `to` exists — MOVE it aside to `backupTo` via a rename (this both
787
+ // frees `to` and IS the backup). A rename moves even a locked, running
788
+ // image on Windows, where an in-place replace of a mapped exe is refused;
789
+ // cross-volume (EXDEV) falls back to copy + unlink.
790
+ // 3. write the verified in-memory bytes to the now-free `to` (a create, not
791
+ // a replace of a locked file)
691
792
  // 4. fsync both directories (best-effort across platforms)
692
793
  //
693
- // If step 3 fails the backup remains; if step 4 fails the swap is
694
- // considered complete (operator can audit) but a warning is logged.
794
+ // If step 2 fails the original `to` is intact (surfaced as backup-failed); if
795
+ // step 3 fails the moved-aside backup is renamed back over `to` (rollback); if
796
+ // step 4 fails the swap is considered complete (operator can audit).
695
797
  /**
696
798
  * @primitive b.selfUpdate.swap
697
799
  * @signature b.selfUpdate.swap(opts)
698
800
  * @since 0.6.0
699
- * @related b.selfUpdate.verify, b.selfUpdate.rollback, b.atomicFile.copy
801
+ * @related b.selfUpdate.verify, b.selfUpdate.rollback, b.atomicFile.write
700
802
  *
701
803
  * Atomic install: re-hash `from` and refuse unless it matches `expectedHash`
702
804
  * (the hash selfUpdate.verify returned — this binds the installed bytes to the
703
- * signature-verified bytes and closes the verify→swap window), copy the
704
- * existing `to` to `backupTo`, rename `from` `to`, then fsync both
705
- * directories. Cross-device renames fall back to copy + unlink on the
706
- * destination filesystem. On any failure the original `to` is restored from
707
- * `backupTo`. Throws SelfUpdateError on a missing `from`, an
708
- * expectedHash mismatch, backup-copy failure, cross-device install failure,
709
- * or rename failure.
805
+ * signature-verified bytes and closes the verify→swap window), MOVE the existing
806
+ * `to` aside to `backupTo` with a rename (which succeeds on a locked, running
807
+ * image where an in-place replace is refused, and IS the backup), write the
808
+ * verified bytes to the now-free `to`, then fsync both directories. `backupTo`
809
+ * must be on the same volume as `to`; a cross-volume backup (EXDEV) falls back to
810
+ * copy + replace. On an install-write failure after the move-aside, the backup is
811
+ * restored over `to`. Throws SelfUpdateError on a missing `from`, an expectedHash
812
+ * mismatch, a move-aside/backup failure, or an install-write failure.
710
813
  *
711
814
  * @opts
712
815
  * from: string, // required — newly-installed asset path
@@ -777,26 +880,31 @@ async function swap(opts) {
777
880
  atomicFile.ensureDir(toDir);
778
881
  atomicFile.ensureDir(backupDir);
779
882
 
780
- // Step 2 — backup if `to` exists. Use atomicFile.copy so the backup
781
- // hits disk via temp+fsync+rename.
883
+ // Step 2 — move the outgoing `to` ASIDE to `backupTo` via a rename. A rename
884
+ // frees the path even when `to` is a locked, running image (Windows refuses an
885
+ // in-place replace of a mapped executable but allows the move), and the moved
886
+ // file IS the backup — so no separate copy of the old bytes is needed. The
887
+ // move-aside failing leaves the original `to` intact (surfaced as backup-failed).
782
888
  var hadOriginal = nodeFs.existsSync(to);
783
889
  if (hadOriginal) {
784
890
  try {
785
- await atomicFile.copy(to, backupTo, { fileMode: 0o600 });
891
+ await _relocateFile(to, backupTo, 0o600);
786
892
  } catch (e) {
787
893
  throw new SelfUpdateError("selfupdate/backup-failed",
788
- "selfUpdate.swap: failed to copy " + to + " -> " + backupTo + ": " +
894
+ "selfUpdate.swap: failed to move " + to + " aside to " + backupTo + ": " +
789
895
  ((e && e.message) || String(e)));
790
896
  }
791
897
  }
792
898
 
793
- // Step 3 — install the verified in-memory bytes via an atomic temp+fsync+
794
- // rename on the DESTINATION filesystem (atomicFile.write), so the installed
795
- // object is exactly the bytes just hashed: cross-device is handled (the temp
796
- // is created on the dest FS), there is no by-path re-read to race, and a
797
- // symlinked source can't be moved into place. Roll back from the backup on
798
- // failure; a rollback failure surfaces as a DISTINCT error class + audit
799
- // event so operators don't silently lose both binaries (SSDF RV.1).
899
+ // Step 3 — install the verified in-memory bytes at the now-free `to` via an
900
+ // atomic temp+fsync+rename (atomicFile.write). With `to` moved aside (or never
901
+ // present), this rename is a CREATE at a free path, not a replace of a locked
902
+ // file so it succeeds on a running Windows image. The installed object is
903
+ // exactly the bytes just hashed (installed from memory), so there is no by-path
904
+ // re-read to race and no symlinked source to move into place. On failure the
905
+ // moved-aside backup is renamed back over `to`; a rollback failure surfaces as
906
+ // a DISTINCT error class + audit event so operators don't silently lose both
907
+ // binaries (SSDF RV.1).
800
908
  try {
801
909
  await atomicFile.write(to, fromBytes, { fileMode: fromMode, overwrite: true });
802
910
  } catch (e) {
@@ -882,11 +990,313 @@ async function rollback(opts) {
882
990
  return { ok: true, restoredAt: Date.now(), to: to, backupTo: backupTo };
883
991
  }
884
992
 
993
+ // ---- probation / auto-rollback orchestration ----
994
+ //
995
+ // A swap installs the new binary; probation gives it a bounded window to prove
996
+ // itself before the install is considered final. The new binary (or the
997
+ // operator's shutdown hook) calls confirmHealthy() once it is up + healthy,
998
+ // which clears the marker — the "clean / healthy" signal. On the next boot
999
+ // evaluateOnBoot() reads the marker: still inside the window means a clean stop /
1000
+ // restart (NOT a crash) so it keeps; past the window with no confirmHealthy means
1001
+ // the binary never became healthy so it rolls the known-good backup back over the
1002
+ // target. Rollback re-verifies first (the installed bytes must still hash to the
1003
+ // probationary expectedHash and the backup must exist) so a marker left behind by
1004
+ // a swap that FAILED — where the new binary was never installed — never triggers
1005
+ // a phantom rollback.
1006
+
1007
+ var PROBATION_MARKER_SUFFIX = ".blamejs-probation.json";
1008
+ var PROBATION_MARKER_MAX = C.BYTES.kib(64); // marker is a small JSON record
1009
+
1010
+ function _resolveMarkerPath(opts) {
1011
+ if (typeof opts.markerPath === "string" && opts.markerPath.length > 0) return opts.markerPath;
1012
+ return opts.to + PROBATION_MARKER_SUFFIX;
1013
+ }
1014
+
1015
+ function _probationHashAlgo(value, method) {
1016
+ if (value !== undefined && (typeof value !== "string" || ALLOWED_HASH_ALGS.indexOf(value) === -1)) {
1017
+ throw new SelfUpdateError("selfupdate/bad-hash-algo",
1018
+ "selfUpdate." + method + ": opts.hashAlgo must be one of " + ALLOWED_HASH_ALGS.join(", "));
1019
+ }
1020
+ }
1021
+
1022
+ // Field-descriptor builders for the probation validators — the required/optional
1023
+ // string field shape composes from one definition instead of repeating an inline
1024
+ // `{ rule, code, label }` object per field across the three validators.
1025
+ function _reqStr(code, label) { return { rule: "required-string", code: code, label: label }; }
1026
+ function _optStr(code, label) { return { rule: "optional-string", code: code, label: label }; }
1027
+ function _probationLabel(method, field) { return "selfUpdate." + method + ": opts." + field; }
1028
+
1029
+ function _validateProbationBeginOpts(opts) {
1030
+ validateOpts.shape(opts, {
1031
+ to: _reqStr("selfupdate/bad-to", _probationLabel("beginProbation", "to")),
1032
+ backupTo: _reqStr("selfupdate/bad-backup", _probationLabel("beginProbation", "backupTo")),
1033
+ expectedHash: _reqStr("selfupdate/bad-expected-hash", _probationLabel("beginProbation", "expectedHash")),
1034
+ windowMs: function (value) {
1035
+ numericBounds.requirePositiveFiniteIntIfPresent(value,
1036
+ _probationLabel("beginProbation", "windowMs"), SelfUpdateError, "selfupdate/bad-window");
1037
+ },
1038
+ hashAlgo: function (value) { _probationHashAlgo(value, "beginProbation"); },
1039
+ markerPath: _optStr("selfupdate/bad-marker-path", _probationLabel("beginProbation", "markerPath")),
1040
+ }, "selfUpdate.beginProbation", SelfUpdateError, "selfupdate/bad-opts");
1041
+ }
1042
+
1043
+ function _validateConfirmOpts(opts) {
1044
+ validateOpts.shape(opts, {
1045
+ to: _reqStr("selfupdate/bad-to", _probationLabel("confirmHealthy", "to")),
1046
+ markerPath: _optStr("selfupdate/bad-marker-path", _probationLabel("confirmHealthy", "markerPath")),
1047
+ }, "selfUpdate.confirmHealthy", SelfUpdateError, "selfupdate/bad-opts");
1048
+ }
1049
+
1050
+ function _validateEvaluateOpts(opts) {
1051
+ validateOpts.shape(opts, {
1052
+ to: _reqStr("selfupdate/bad-to", _probationLabel("evaluateOnBoot", "to")),
1053
+ backupTo: _optStr("selfupdate/bad-backup", _probationLabel("evaluateOnBoot", "backupTo")),
1054
+ markerPath: _optStr("selfupdate/bad-marker-path", _probationLabel("evaluateOnBoot", "markerPath")),
1055
+ now: function (value) {
1056
+ numericBounds.requirePositiveFiniteIntIfPresent(value,
1057
+ _probationLabel("evaluateOnBoot", "now"), SelfUpdateError, "selfupdate/bad-now");
1058
+ },
1059
+ }, "selfUpdate.evaluateOnBoot", SelfUpdateError, "selfupdate/bad-opts");
1060
+ }
1061
+
1062
+ function _probationKeep(reason, to, markerPath) {
1063
+ // A boot with no probation marker is the steady-state no-op — don't audit it
1064
+ // every boot. Every other keep reason is a real probation transition.
1065
+ if (reason !== "no-probation-active") {
1066
+ _safeAuditEmit("selfupdate.probation.kept", "success", {
1067
+ to: to, markerPath: markerPath, reason: reason,
1068
+ });
1069
+ }
1070
+ return { action: "keep", reason: reason };
1071
+ }
1072
+
1073
+ /**
1074
+ * @primitive b.selfUpdate.beginProbation
1075
+ * @signature b.selfUpdate.beginProbation(opts)
1076
+ * @since 0.17.13
1077
+ * @status stable
1078
+ * @related b.selfUpdate.confirmHealthy, b.selfUpdate.evaluateOnBoot, b.selfUpdate.swap
1079
+ *
1080
+ * Arm a bounded post-install probation for a freshly-swapped binary. Writes an
1081
+ * atomic marker (`to` + `.blamejs-probation.json`, or `opts.markerPath`)
1082
+ * recording the target, the known-good backup, the installed bytes' hash, and an
1083
+ * `expiresAt` = now + `windowMs`. The new binary calls `confirmHealthy` once it
1084
+ * is up and healthy (clearing the marker); if the window elapses with no such
1085
+ * confirmation, the next `evaluateOnBoot` rolls the backup back over the target.
1086
+ *
1087
+ * The marker is written via `b.atomicFile.writeJson` (temp + fsync + rename), so
1088
+ * a process that dies mid-write leaves either the previous complete marker or
1089
+ * none — never a half-written record a boot could misread.
1090
+ *
1091
+ * @opts
1092
+ * to: string, // required — installed binary path (the probationary target)
1093
+ * backupTo: string, // required — known-good backup restored on a failed probation
1094
+ * expectedHash: string, // required — hash of the installed bytes (selfUpdate.verify/swap's hash)
1095
+ * windowMs: number, // probation window in ms; default 10 minutes
1096
+ * hashAlgo: string, // sha3-512 (default) | sha-256 | sha-512 | shake256
1097
+ * markerPath: string, // override marker path (default: `to` + ".blamejs-probation.json")
1098
+ *
1099
+ * @example
1100
+ * var v = await b.selfUpdate.verify({ assetPath, signaturePath, pubkeyPem });
1101
+ * await b.selfUpdate.swap({ from, to, backupTo, expectedHash: v.hash });
1102
+ * var p = await b.selfUpdate.beginProbation({ to, backupTo, expectedHash: v.hash });
1103
+ * p.expiresAt; // → epoch ms the probation window closes
1104
+ */
1105
+ async function beginProbation(opts) {
1106
+ _validateProbationBeginOpts(opts);
1107
+ var markerPath = _resolveMarkerPath(opts);
1108
+ var windowMs = typeof opts.windowMs === "number" ? opts.windowMs : C.TIME.minutes(10);
1109
+ var hashAlgo = opts.hashAlgo || DEFAULT_HASH_ALG;
1110
+ var installedAt = Date.now();
1111
+ var expiresAt = installedAt + windowMs;
1112
+
1113
+ // Carry a monotonically increasing generation across successive probations of
1114
+ // the same target — each install supersedes the prior probation record.
1115
+ var generation = 1;
1116
+ try {
1117
+ var prior = await atomicFile.readJson(markerPath, { maxBytes: PROBATION_MARKER_MAX });
1118
+ if (prior && typeof prior.generation === "number" && isFinite(prior.generation)) {
1119
+ generation = prior.generation + 1;
1120
+ }
1121
+ } catch (_p) { /* no prior marker (or unreadable) — first generation */ }
1122
+
1123
+ var marker = {
1124
+ schema: 1,
1125
+ installedAt: installedAt,
1126
+ expiresAt: expiresAt,
1127
+ windowMs: windowMs,
1128
+ to: opts.to,
1129
+ backupTo: opts.backupTo,
1130
+ expectedHash: opts.expectedHash,
1131
+ hashAlgo: hashAlgo,
1132
+ generation: generation,
1133
+ };
1134
+ var written = await atomicFile.writeJson(markerPath, marker, { computeHash: true, fileMode: 0o600 });
1135
+
1136
+ _safeAuditEmit("selfupdate.probation.begin", "success", {
1137
+ to: opts.to, backupTo: opts.backupTo, markerPath: markerPath,
1138
+ expiresAt: expiresAt, generation: generation, markerHash: written.hash,
1139
+ });
1140
+ log("selfUpdate.beginProbation to=" + opts.to + " expiresAt=" + expiresAt + " gen=" + generation);
1141
+ return { markerPath: markerPath, installedAt: installedAt, expiresAt: expiresAt, generation: generation };
1142
+ }
1143
+
1144
+ /**
1145
+ * @primitive b.selfUpdate.confirmHealthy
1146
+ * @signature b.selfUpdate.confirmHealthy(opts)
1147
+ * @since 0.17.13
1148
+ * @status stable
1149
+ * @related b.selfUpdate.beginProbation, b.selfUpdate.evaluateOnBoot
1150
+ *
1151
+ * Clear the probation marker — the explicit clean / healthy signal. The new
1152
+ * binary calls this once its own startup health checks pass (and an operator's
1153
+ * graceful-shutdown hook may call it too, marking a clean stop). With the marker
1154
+ * gone, a later `evaluateOnBoot` finds no probation and keeps the binary. Absence
1155
+ * of this signal at the next boot past the window is what `evaluateOnBoot` reads
1156
+ * as a failed probation. Idempotent: a missing marker returns `cleared: false`.
1157
+ *
1158
+ * @opts
1159
+ * to: string, // required — the probationary target (locates the marker)
1160
+ * markerPath: string, // override marker path (must match beginProbation)
1161
+ *
1162
+ * @example
1163
+ * // in the new binary, after startup health checks pass:
1164
+ * var r = await b.selfUpdate.confirmHealthy({ to: "/opt/app/app.bin" });
1165
+ * r.cleared; // → true (marker removed)
1166
+ */
1167
+ async function confirmHealthy(opts) {
1168
+ _validateConfirmOpts(opts);
1169
+ var markerPath = _resolveMarkerPath(opts);
1170
+ var cleared = false;
1171
+ if (nodeFs.existsSync(markerPath)) {
1172
+ try {
1173
+ nodeFs.unlinkSync(markerPath);
1174
+ cleared = true;
1175
+ } catch (e) {
1176
+ throw new SelfUpdateError("selfupdate/probation-confirm-failed",
1177
+ "selfUpdate.confirmHealthy: failed to clear probation marker " + markerPath + ": " +
1178
+ ((e && e.message) || String(e)));
1179
+ }
1180
+ }
1181
+ _safeAuditEmit("selfupdate.probation.confirmed", "success", {
1182
+ to: opts.to, markerPath: markerPath, cleared: cleared,
1183
+ });
1184
+ log("selfUpdate.confirmHealthy to=" + opts.to + " cleared=" + cleared);
1185
+ return { ok: true, cleared: cleared, markerPath: markerPath };
1186
+ }
1187
+
1188
+ /**
1189
+ * @primitive b.selfUpdate.evaluateOnBoot
1190
+ * @signature b.selfUpdate.evaluateOnBoot(opts)
1191
+ * @since 0.17.13
1192
+ * @status stable
1193
+ * @related b.selfUpdate.beginProbation, b.selfUpdate.confirmHealthy, b.selfUpdate.rollback
1194
+ *
1195
+ * Decide, at process start, whether a probationary install should be kept or
1196
+ * rolled back. Returns `{ action: "keep" | "rollback", reason }`. No marker, or a
1197
+ * marker still inside its window, keeps (a clean stop / restart within the window
1198
+ * is not a crash). A marker past its window with no `confirmHealthy` means the
1199
+ * binary never became healthy → the known-good backup is restored over the
1200
+ * target and the marker cleared.
1201
+ *
1202
+ * Before restoring, it RE-VERIFIES: the bytes currently at `to` must still hash
1203
+ * to the marker's `expectedHash` (so a marker left by a swap that FAILED — where
1204
+ * the probationary binary was never installed — never triggers a phantom
1205
+ * rollback), and the backup must exist (otherwise it keeps and defers to the
1206
+ * operator rather than destroying the only present binary). A corrupt / malformed
1207
+ * marker keeps, never rolls back.
1208
+ *
1209
+ * @opts
1210
+ * to: string, // required — the probationary target
1211
+ * backupTo: string, // override the marker's backup path
1212
+ * markerPath: string, // override marker path (must match beginProbation)
1213
+ * now: number, // override the wall clock (epoch ms) for deterministic evaluation
1214
+ *
1215
+ * @example
1216
+ * // at process start, before serving traffic:
1217
+ * var d = await b.selfUpdate.evaluateOnBoot({ to: "/opt/app/app.bin" });
1218
+ * if (d.action === "rollback") process.exit(1); // restart onto the restored binary
1219
+ */
1220
+ async function evaluateOnBoot(opts) {
1221
+ _validateEvaluateOpts(opts);
1222
+ var markerPath = _resolveMarkerPath(opts);
1223
+ var to = opts.to;
1224
+ var now = typeof opts.now === "number" ? opts.now : Date.now();
1225
+
1226
+ if (!nodeFs.existsSync(markerPath)) {
1227
+ return _probationKeep("no-probation-active", to, markerPath);
1228
+ }
1229
+ var marker;
1230
+ try {
1231
+ marker = await atomicFile.readJson(markerPath, { maxBytes: PROBATION_MARKER_MAX });
1232
+ } catch (_e) {
1233
+ // A corrupt / unreadable marker must not phantom-rollback.
1234
+ return _probationKeep("marker-unreadable", to, markerPath);
1235
+ }
1236
+ if (!marker || typeof marker.expiresAt !== "number" || typeof marker.expectedHash !== "string") {
1237
+ return _probationKeep("marker-malformed", to, markerPath);
1238
+ }
1239
+ // Inside the window — a clean stop / restart is not a crash.
1240
+ if (now < marker.expiresAt) {
1241
+ return _probationKeep("within-probation-window", to, markerPath);
1242
+ }
1243
+
1244
+ // Expired with no confirmHealthy. Re-verify before restoring.
1245
+ var backupTo = typeof opts.backupTo === "string" ? opts.backupTo : marker.backupTo;
1246
+ var alg = ALLOWED_HASH_ALGS.indexOf(marker.hashAlgo) !== -1 ? marker.hashAlgo : DEFAULT_HASH_ALG;
1247
+
1248
+ // The probationary binary must actually be the one installed at `to`; if `to`
1249
+ // is absent or holds different bytes (a swap that failed and left the old
1250
+ // binary), rolling back would be a phantom.
1251
+ var currentHash = null;
1252
+ try {
1253
+ var curBytes = atomicFile.fdSafeReadSync(to, { maxBytes: C.BYTES.gib(1) });
1254
+ currentHash = nodeCrypto.createHash(alg).update(curBytes).digest("hex");
1255
+ } catch (_r) { currentHash = null; }
1256
+ if (currentHash !== marker.expectedHash) {
1257
+ return _probationKeep("installed-binary-not-probationary", to, markerPath);
1258
+ }
1259
+ if (typeof backupTo !== "string" || !nodeFs.existsSync(backupTo)) {
1260
+ // No backup to restore — keep the current binary and defer to the operator
1261
+ // rather than leaving the target with nothing.
1262
+ return _probationKeep("backup-unavailable", to, markerPath);
1263
+ }
1264
+
1265
+ // Restore the known-good backup over the failed probationary binary. On boot
1266
+ // the target is not yet running, so an atomic write-replace is safe.
1267
+ try {
1268
+ var backupBytes = atomicFile.fdSafeReadSync(backupTo, { maxBytes: C.BYTES.gib(1) });
1269
+ var restoreMode;
1270
+ try { restoreMode = (nodeFs.statSync(to).mode & 0o777); } catch (_sm) { restoreMode = 0o600; }
1271
+ await atomicFile.write(to, backupBytes, { fileMode: restoreMode, overwrite: true });
1272
+ } catch (e) {
1273
+ _safeAuditEmit("selfupdate.probation.rollback_failed", "denied", {
1274
+ to: to, backupTo: backupTo, markerPath: markerPath,
1275
+ reason: "restore-failed", message: (e && e.message) || String(e),
1276
+ });
1277
+ throw new SelfUpdateError("selfupdate/probation-rollback-failed",
1278
+ "selfUpdate.evaluateOnBoot: probation rollback restore of " + to + " failed: " +
1279
+ ((e && e.message) || String(e)));
1280
+ }
1281
+ atomicFile.fsyncDir(nodePath.dirname(to));
1282
+ try { nodeFs.unlinkSync(markerPath); } catch (_u) { /* marker cleanup best-effort */ }
1283
+
1284
+ _safeAuditEmit("selfupdate.probation.rolled_back", "success", {
1285
+ to: to, backupTo: backupTo, markerPath: markerPath, generation: marker.generation,
1286
+ });
1287
+ log("selfUpdate.evaluateOnBoot rolled back to=" + to + " from=" + backupTo);
1288
+ return { action: "rollback", reason: "probation-window-elapsed-without-confirmation",
1289
+ to: to, backupTo: backupTo, generation: marker.generation };
1290
+ }
1291
+
885
1292
  module.exports = {
886
1293
  poll: poll,
887
1294
  verify: verify,
888
1295
  swap: swap,
889
1296
  rollback: rollback,
1297
+ beginProbation: beginProbation,
1298
+ confirmHealthy: confirmHealthy,
1299
+ evaluateOnBoot: evaluateOnBoot,
890
1300
  // Standalone verifier — zero-dep companion for install-pipeline
891
1301
  // contexts that run BEFORE the framework is installed (Dockerfile
892
1302
  // build stages, install.sh, update.sh). See the module's intro for