@ijfw/memory-server 1.4.0 → 1.4.3
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/README.md +67 -0
- package/package.json +1 -1
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +314 -8
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +411 -1
- package/src/dashboard-server.js +350 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +272 -1
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/extension-installer.js +39 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +140 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +1289 -0
- package/src/extension-signer.js +270 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/memory-feedback.js +194 -10
- package/src/runtime-mediator.js +61 -1
- package/src/server.js +180 -18
package/src/extension-signer.js
CHANGED
|
@@ -45,6 +45,16 @@ import {
|
|
|
45
45
|
SIGNATURE_PATTERN,
|
|
46
46
|
PUBLISHER_KEY_ID_PATTERN,
|
|
47
47
|
} from './extension-manifest-schema.js';
|
|
48
|
+
import {
|
|
49
|
+
SOFTWARE_BACKEND,
|
|
50
|
+
SSH_AGENT_BACKEND,
|
|
51
|
+
resolveBackend,
|
|
52
|
+
} from './hardware-signer.js';
|
|
53
|
+
|
|
54
|
+
// B15: re-export backend primitives so callers can sign/verify via the
|
|
55
|
+
// backend abstraction without a second import. Software backend remains
|
|
56
|
+
// the default; ssh-agent backend is opt-in via manifest.publisher_key_backend.
|
|
57
|
+
export { SOFTWARE_BACKEND, SSH_AGENT_BACKEND, resolveBackend };
|
|
48
58
|
|
|
49
59
|
/**
|
|
50
60
|
* Recursively sort object keys to produce a stable canonical representation.
|
|
@@ -580,6 +590,59 @@ export function signManifest(manifest, privateKeyPem) {
|
|
|
580
590
|
return computeIntegrity(signed);
|
|
581
591
|
}
|
|
582
592
|
|
|
593
|
+
/**
|
|
594
|
+
* B15: Backend-aware async manifest signer. Dispatches to the backend
|
|
595
|
+
* named by `manifest.publisher_key_backend` (default: 'software').
|
|
596
|
+
*
|
|
597
|
+
* The software backend reads the on-disk private PEM at
|
|
598
|
+
* `<home>/.ijfw/keys/<keyId>/private.pem` and signs in-process. The
|
|
599
|
+
* ssh-agent backend forwards the signing op to the running SSH agent
|
|
600
|
+
* — the private key never enters the IJFW process.
|
|
601
|
+
*
|
|
602
|
+
* Identity selection at sign time:
|
|
603
|
+
* - keyId is supplied via `opts.keyId`. The chosen backend uses keyId
|
|
604
|
+
* to look up its key material (PEM on disk for software; pubkey blob
|
|
605
|
+
* in backend.json for ssh-agent, then identity match in the agent).
|
|
606
|
+
*
|
|
607
|
+
* Returns a NEW manifest with `publisher_key_id`, `publisher_key_backend`
|
|
608
|
+
* (when explicitly non-software), `signature`, and re-computed `integrity`.
|
|
609
|
+
*
|
|
610
|
+
* @param {object} manifest
|
|
611
|
+
* @param {object} opts
|
|
612
|
+
* @param {string} opts.keyId required — the keyId to sign with
|
|
613
|
+
* @param {string} [opts.home] override for ~/.ijfw root (test isolation)
|
|
614
|
+
* @param {string} [opts.socketPath] override SSH_AUTH_SOCK (test isolation)
|
|
615
|
+
* @returns {Promise<object>}
|
|
616
|
+
*/
|
|
617
|
+
export async function signManifestWithBackend(manifest, opts = {}) {
|
|
618
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
619
|
+
throw new TypeError('signManifestWithBackend: manifest must be an object');
|
|
620
|
+
}
|
|
621
|
+
if (typeof opts.keyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(opts.keyId)) {
|
|
622
|
+
throw new TypeError('signManifestWithBackend: opts.keyId required (sha256 hex)');
|
|
623
|
+
}
|
|
624
|
+
const backendName = manifest.publisher_key_backend; // undefined OK
|
|
625
|
+
const backend = resolveBackend(backendName);
|
|
626
|
+
|
|
627
|
+
const toSign = {
|
|
628
|
+
...manifest,
|
|
629
|
+
publisher_key_id: opts.keyId,
|
|
630
|
+
};
|
|
631
|
+
// Only emit `publisher_key_backend` field when explicitly non-default to
|
|
632
|
+
// keep software-backend manifests byte-identical to the v1.4.0 shape.
|
|
633
|
+
if (backendName !== undefined && backendName !== 'software') {
|
|
634
|
+
toSign.publisher_key_backend = backendName;
|
|
635
|
+
}
|
|
636
|
+
const bytes = canonicalSigningBytes(toSign);
|
|
637
|
+
const sigBuf = await backend.sign(bytes, opts.keyId, {
|
|
638
|
+
home: opts.home,
|
|
639
|
+
socketPath: opts.socketPath,
|
|
640
|
+
});
|
|
641
|
+
const signature = `ed25519:${Buffer.from(sigBuf).toString('base64')}`;
|
|
642
|
+
const signed = { ...toSign, signature };
|
|
643
|
+
return computeIntegrity(signed);
|
|
644
|
+
}
|
|
645
|
+
|
|
583
646
|
/**
|
|
584
647
|
* Verify a manifest's signature against a map of trusted publishers.
|
|
585
648
|
*
|
|
@@ -641,6 +704,52 @@ export function verifyManifestSignature(manifest, trustedKeys) {
|
|
|
641
704
|
return { valid: true, publisherKeyId: kid, reason: 'ok' };
|
|
642
705
|
}
|
|
643
706
|
|
|
707
|
+
// === B6: Revoked publishers store ========================================
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Read the revoked publishers list from ~/.ijfw/state/revoked-publishers.json.
|
|
711
|
+
* Returns an empty list when absent or malformed.
|
|
712
|
+
*
|
|
713
|
+
* @returns {Promise<Array<{keyId: string, revoked_at: string, reason: string, superseded_by: string|null}>>}
|
|
714
|
+
*/
|
|
715
|
+
export async function readRevokedPublishers() {
|
|
716
|
+
const path = join(homedir(), '.ijfw', 'state', 'revoked-publishers.json');
|
|
717
|
+
let raw;
|
|
718
|
+
try {
|
|
719
|
+
raw = await readFile(path, 'utf8');
|
|
720
|
+
} catch {
|
|
721
|
+
return [];
|
|
722
|
+
}
|
|
723
|
+
let parsed;
|
|
724
|
+
try {
|
|
725
|
+
parsed = JSON.parse(raw);
|
|
726
|
+
} catch {
|
|
727
|
+
return [];
|
|
728
|
+
}
|
|
729
|
+
if (!parsed || !Array.isArray(parsed.revoked)) return [];
|
|
730
|
+
return parsed.revoked.filter(r => typeof r.keyId === 'string');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Module-level revoked set cache — loaded once per process, refreshed by applyRegistry.
|
|
734
|
+
// Export for test isolation only (allows tests to reset after changing HOME).
|
|
735
|
+
export function _resetRevokedCacheForTest() { _revokedSet = null; }
|
|
736
|
+
let _revokedSet = null;
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* O(1) check: is a keyId on the revoked list?
|
|
740
|
+
* Loads the set on first call; cached for the process lifetime.
|
|
741
|
+
*
|
|
742
|
+
* @param {string} keyId
|
|
743
|
+
* @returns {Promise<boolean>}
|
|
744
|
+
*/
|
|
745
|
+
export async function isRevoked(keyId) {
|
|
746
|
+
if (_revokedSet === null) {
|
|
747
|
+
const list = await readRevokedPublishers();
|
|
748
|
+
_revokedSet = new Set(list.map(r => r.keyId));
|
|
749
|
+
}
|
|
750
|
+
return _revokedSet.has(keyId);
|
|
751
|
+
}
|
|
752
|
+
|
|
644
753
|
/**
|
|
645
754
|
* Read the trusted publishers JSON. Returns `{publishers: {}}` when absent or
|
|
646
755
|
* malformed (fail-closed for verification: no trusted keys means nothing is
|
|
@@ -701,6 +810,10 @@ export async function addTrustedPublisher(keyId, publicKey, name) {
|
|
|
701
810
|
if (typeof publicKey !== 'string' || publicKey.indexOf('BEGIN PUBLIC KEY') === -1) {
|
|
702
811
|
return { ok: false, error: 'publicKey must be PEM-encoded' };
|
|
703
812
|
}
|
|
813
|
+
// B6: refuse to add a revoked publisher
|
|
814
|
+
if (await isRevoked(keyId)) {
|
|
815
|
+
return { ok: false, error: 'publisher revoked by IJFW registry' };
|
|
816
|
+
}
|
|
704
817
|
let fp;
|
|
705
818
|
try {
|
|
706
819
|
fp = publicKeyFingerprint(publicKey);
|
|
@@ -720,6 +833,163 @@ export async function addTrustedPublisher(keyId, publicKey, name) {
|
|
|
720
833
|
return { ok: true, store };
|
|
721
834
|
}
|
|
722
835
|
|
|
836
|
+
// === B8: Key rotation + revocation ==========================================
|
|
837
|
+
//
|
|
838
|
+
// Rotation token is signed by the OLD private key — proof of control.
|
|
839
|
+
// An attacker without the old private key cannot produce a valid token.
|
|
840
|
+
// A publisher who lost their old private key must contact the registry
|
|
841
|
+
// maintainer for out-of-band manual key replacement (see docs/REGISTRY-MAINTAINER.md).
|
|
842
|
+
//
|
|
843
|
+
// Token shape: { rotated_at, old_key_id, new_key_id, new_public_key, signature }
|
|
844
|
+
// Canonical signing bytes: all fields except `signature`, sorted by key (sortKeysDeep).
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Produce a rotation token asserting that newPublicKey supersedes the key
|
|
848
|
+
* identified by oldPrivateKey. Signed by the old private key.
|
|
849
|
+
*
|
|
850
|
+
* @param {string} oldPrivateKeyPem
|
|
851
|
+
* @param {string} newPublicKeyPem
|
|
852
|
+
* @param {object} [opts]
|
|
853
|
+
* @param {string} [opts.rotated_at] ISO timestamp (defaults to now)
|
|
854
|
+
* @returns {{ rotated_at: string, old_key_id: string, new_key_id: string, new_public_key: string, signature: string }}
|
|
855
|
+
*/
|
|
856
|
+
export function signRotationToken(oldPrivateKeyPem, newPublicKeyPem, opts = {}) {
|
|
857
|
+
const priv = createPrivateKey(oldPrivateKeyPem);
|
|
858
|
+
// Derive old_key_id from the old private key's matching public key.
|
|
859
|
+
const oldPub = createPublicKey(priv);
|
|
860
|
+
const old_key_id = publicKeyFingerprint(oldPub.export({ type: 'spki', format: 'pem' }).toString());
|
|
861
|
+
const new_key_id = publicKeyFingerprint(newPublicKeyPem);
|
|
862
|
+
|
|
863
|
+
const token = {
|
|
864
|
+
rotated_at: opts.rotated_at || new Date().toISOString(),
|
|
865
|
+
old_key_id,
|
|
866
|
+
new_key_id,
|
|
867
|
+
new_public_key: newPublicKeyPem,
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
// Canonical signing bytes: sortKeysDeep of token (signature excluded — not present yet).
|
|
871
|
+
const bytes = Buffer.from(JSON.stringify(sortKeysDeep(token)), 'utf8');
|
|
872
|
+
const sigBuf = cryptoSign(null, bytes, priv);
|
|
873
|
+
return { ...token, signature: `ed25519:${sigBuf.toString('base64')}` };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* B15: Backend-aware async rotation-token signer. Mirrors signRotationToken
|
|
878
|
+
* but dispatches the actual signing operation to the named backend.
|
|
879
|
+
*
|
|
880
|
+
* The token shape is identical to the software-backend version
|
|
881
|
+
* (`signRotationToken`); the only difference is WHO holds the private
|
|
882
|
+
* key. For ssh-agent backend, the OLD key's signing op is forwarded to
|
|
883
|
+
* the agent — the old private key never enters IJFW process memory.
|
|
884
|
+
*
|
|
885
|
+
* @param {object} opts
|
|
886
|
+
* @param {string} opts.oldKeyId required — keyId for the OLD key
|
|
887
|
+
* @param {string} opts.newPublicKeyPem required — PEM of the NEW key
|
|
888
|
+
* @param {string} [opts.backend] 'software' | 'ssh-agent' (default software)
|
|
889
|
+
* @param {string} [opts.rotated_at] ISO timestamp override
|
|
890
|
+
* @param {string} [opts.home] override for ~/.ijfw root (test isolation)
|
|
891
|
+
* @param {string} [opts.socketPath] override SSH_AUTH_SOCK (test isolation)
|
|
892
|
+
* @returns {Promise<{rotated_at, old_key_id, new_key_id, new_public_key, signature}>}
|
|
893
|
+
*/
|
|
894
|
+
export async function signRotationTokenWithBackend(opts = {}) {
|
|
895
|
+
if (typeof opts.oldKeyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(opts.oldKeyId)) {
|
|
896
|
+
throw new TypeError('signRotationTokenWithBackend: opts.oldKeyId required (sha256 hex)');
|
|
897
|
+
}
|
|
898
|
+
if (typeof opts.newPublicKeyPem !== 'string' || opts.newPublicKeyPem.indexOf('BEGIN PUBLIC KEY') === -1) {
|
|
899
|
+
throw new TypeError('signRotationTokenWithBackend: opts.newPublicKeyPem must be PEM');
|
|
900
|
+
}
|
|
901
|
+
const backend = resolveBackend(opts.backend);
|
|
902
|
+
const new_key_id = publicKeyFingerprint(opts.newPublicKeyPem);
|
|
903
|
+
|
|
904
|
+
const token = {
|
|
905
|
+
rotated_at: opts.rotated_at || new Date().toISOString(),
|
|
906
|
+
old_key_id: opts.oldKeyId,
|
|
907
|
+
new_key_id,
|
|
908
|
+
new_public_key: opts.newPublicKeyPem,
|
|
909
|
+
};
|
|
910
|
+
const bytes = Buffer.from(JSON.stringify(sortKeysDeep(token)), 'utf8');
|
|
911
|
+
const sigBuf = await backend.sign(bytes, opts.oldKeyId, {
|
|
912
|
+
home: opts.home,
|
|
913
|
+
socketPath: opts.socketPath,
|
|
914
|
+
});
|
|
915
|
+
return { ...token, signature: `ed25519:${Buffer.from(sigBuf).toString('base64')}` };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Verify a rotation token against the old public key.
|
|
920
|
+
* Checks:
|
|
921
|
+
* 1. Signature is valid Ed25519 over canonical bytes (signature field excluded).
|
|
922
|
+
* 2. fingerprint(oldPublicKey) === token.old_key_id.
|
|
923
|
+
* 3. token.rotated_at is within opts.max_age_ms (default 90 days).
|
|
924
|
+
*
|
|
925
|
+
* @param {object} token
|
|
926
|
+
* @param {string} oldPublicKeyPem
|
|
927
|
+
* @param {object} [opts]
|
|
928
|
+
* @param {number} [opts.max_age_ms] Maximum token age in ms (default 90 days)
|
|
929
|
+
* @returns {{ valid: boolean, reason: string }}
|
|
930
|
+
*/
|
|
931
|
+
export function verifyRotationToken(token, oldPublicKeyPem, opts = {}) {
|
|
932
|
+
if (!token || typeof token !== 'object') {
|
|
933
|
+
return { valid: false, reason: 'token must be an object' };
|
|
934
|
+
}
|
|
935
|
+
const { rotated_at, old_key_id, new_key_id, new_public_key, signature } = token;
|
|
936
|
+
if (!rotated_at || !old_key_id || !new_key_id || !new_public_key || !signature) {
|
|
937
|
+
return { valid: false, reason: 'token missing required fields' };
|
|
938
|
+
}
|
|
939
|
+
if (typeof signature !== 'string' || !signature.startsWith('ed25519:')) {
|
|
940
|
+
return { valid: false, reason: 'signature must be "ed25519:<base64>"' };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Expiry check: reject tokens older than max_age_ms (default 90 days).
|
|
944
|
+
const MAX_AGE_MS = opts.max_age_ms ?? (90 * 24 * 60 * 60 * 1000);
|
|
945
|
+
const rotatedAtMs = new Date(rotated_at).getTime();
|
|
946
|
+
if (isNaN(rotatedAtMs)) {
|
|
947
|
+
return { valid: false, reason: 'rotated_at is not a valid date' };
|
|
948
|
+
}
|
|
949
|
+
if (Date.now() - rotatedAtMs > MAX_AGE_MS) {
|
|
950
|
+
return { valid: false, reason: 'rotation token expired' };
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Check old_key_id matches the supplied public key fingerprint.
|
|
954
|
+
let fp;
|
|
955
|
+
try {
|
|
956
|
+
fp = publicKeyFingerprint(oldPublicKeyPem);
|
|
957
|
+
} catch (err) {
|
|
958
|
+
return { valid: false, reason: `old public key parse failed: ${err.message}` };
|
|
959
|
+
}
|
|
960
|
+
if (fp !== old_key_id) {
|
|
961
|
+
return { valid: false, reason: `old_key_id mismatch: token says ${old_key_id} but supplied key fingerprints to ${fp}` };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Reconstruct canonical signing bytes (exclude signature field).
|
|
965
|
+
const payload = { rotated_at, old_key_id, new_key_id, new_public_key };
|
|
966
|
+
const bytes = Buffer.from(JSON.stringify(sortKeysDeep(payload)), 'utf8');
|
|
967
|
+
|
|
968
|
+
let pubKey;
|
|
969
|
+
try {
|
|
970
|
+
pubKey = createPublicKey(oldPublicKeyPem);
|
|
971
|
+
} catch (err) {
|
|
972
|
+
return { valid: false, reason: `old public key unparseable: ${err.message}` };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
let sigBuf;
|
|
976
|
+
try {
|
|
977
|
+
sigBuf = Buffer.from(signature.slice('ed25519:'.length), 'base64');
|
|
978
|
+
} catch {
|
|
979
|
+
return { valid: false, reason: 'signature base64 decode failed' };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
let ok;
|
|
983
|
+
try {
|
|
984
|
+
ok = cryptoVerify(null, bytes, pubKey, sigBuf);
|
|
985
|
+
} catch (err) {
|
|
986
|
+
return { valid: false, reason: `verify threw: ${err.message}` };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (!ok) return { valid: false, reason: 'signature does not verify' };
|
|
990
|
+
return { valid: true, reason: 'ok' };
|
|
991
|
+
}
|
|
992
|
+
|
|
723
993
|
/**
|
|
724
994
|
* Remove a trusted publisher entry by keyId. Idempotent.
|
|
725
995
|
*
|
package/src/fs-lock.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs-lock.js — Cross-platform directory-based exclusive lock primitive.
|
|
3
|
+
*
|
|
4
|
+
* Frozen contract for v1.4.3 W9-A (consumed by W9-A1 cache writes + W9-A3
|
|
5
|
+
* quota counters). The directory itself is the lock; `mkdir` with
|
|
6
|
+
* `recursive:false` is atomic on POSIX + Windows, so any two processes racing
|
|
7
|
+
* to create the same path will see exactly one winner (EEXIST for the other).
|
|
8
|
+
*
|
|
9
|
+
* NO `process.on('exit')` cleanup. If a holder is SIGKILL'd between mkdir and
|
|
10
|
+
* the `finally` clause, the next caller's stale-recovery path handles it
|
|
11
|
+
* (single retry after rm).
|
|
12
|
+
*
|
|
13
|
+
* Closes SEC-H-01 (cross-process race) from v1.4.3 cross-audit round 1.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises';
|
|
17
|
+
import { join, dirname } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5000;
|
|
20
|
+
const DEFAULT_STALE_MS = 30000;
|
|
21
|
+
const BACKOFF_START_MS = 25;
|
|
22
|
+
const BACKOFF_MAX_MS = 250;
|
|
23
|
+
|
|
24
|
+
export class FsLockBusyError extends Error {
|
|
25
|
+
constructor(lockPath, timeoutMs) {
|
|
26
|
+
super(
|
|
27
|
+
`fs-lock: could not acquire "${lockPath}" within ${timeoutMs}ms (holder still alive)`,
|
|
28
|
+
);
|
|
29
|
+
this.name = 'FsLockBusyError';
|
|
30
|
+
this.code = 'FS_LOCK_BUSY';
|
|
31
|
+
this.lockPath = lockPath;
|
|
32
|
+
this.timeoutMs = timeoutMs;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class FsLockStaleError extends Error {
|
|
37
|
+
constructor(lockPath, cause) {
|
|
38
|
+
super(
|
|
39
|
+
`fs-lock: stale lock recovery failed for "${lockPath}"${
|
|
40
|
+
cause ? `: ${cause.message || cause}` : ''
|
|
41
|
+
}`,
|
|
42
|
+
);
|
|
43
|
+
this.name = 'FsLockStaleError';
|
|
44
|
+
this.code = 'FS_LOCK_STALE';
|
|
45
|
+
this.lockPath = lockPath;
|
|
46
|
+
if (cause) this.cause = cause;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sleep(ms) {
|
|
51
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readHolder(lockPath) {
|
|
55
|
+
try {
|
|
56
|
+
const raw = await readFile(join(lockPath, 'holder.json'), 'utf8');
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
if (
|
|
59
|
+
parsed &&
|
|
60
|
+
typeof parsed === 'object' &&
|
|
61
|
+
typeof parsed.acquired_at === 'number'
|
|
62
|
+
) {
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
} catch {
|
|
67
|
+
// holder.json may not exist yet (mkdir landed, write hadn't), or be
|
|
68
|
+
// truncated. Treat as "no readable holder" — caller decides what to do.
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function tryAcquireOnce(lockPath) {
|
|
74
|
+
await mkdir(lockPath, { recursive: false });
|
|
75
|
+
// Write holder file inside the lock dir — purely forensic. The directory's
|
|
76
|
+
// existence is what holds the lock.
|
|
77
|
+
const holder = { pid: process.pid, acquired_at: Date.now() };
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(
|
|
80
|
+
join(lockPath, 'holder.json'),
|
|
81
|
+
JSON.stringify(holder),
|
|
82
|
+
'utf8',
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
// Best-effort. The lock is still held even if forensic write failed.
|
|
86
|
+
}
|
|
87
|
+
return holder;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* withFsLock(lockPath, fn, { staleMs, acquireTimeoutMs })
|
|
92
|
+
*
|
|
93
|
+
* See module docstring for contract details.
|
|
94
|
+
*/
|
|
95
|
+
export async function withFsLock(lockPath, fn, opts = {}) {
|
|
96
|
+
const staleMs =
|
|
97
|
+
typeof opts.staleMs === 'number' ? opts.staleMs : DEFAULT_STALE_MS;
|
|
98
|
+
const acquireTimeoutMs =
|
|
99
|
+
typeof opts.acquireTimeoutMs === 'number'
|
|
100
|
+
? opts.acquireTimeoutMs
|
|
101
|
+
: DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
102
|
+
|
|
103
|
+
// Ensure the lock's parent directory exists. `tryAcquireOnce` uses
|
|
104
|
+
// `mkdir(lockPath, { recursive: false })` which fails with ENOENT when any
|
|
105
|
+
// parent is missing — surfacing as a non-EEXIST error and breaking callers
|
|
106
|
+
// that expect locks to "just work" in fresh tmp HOMEs. Single best-effort
|
|
107
|
+
// recursive mkdir up-front is cheap (one stat on the common case) and makes
|
|
108
|
+
// the lock primitive safe to invoke against any path under a writable root.
|
|
109
|
+
// Surfaced as a Windows CI regression in v1.4.3 (test-extension-registry
|
|
110
|
+
// tests 523/527/534/535 — Linux/macOS passed only because earlier tests
|
|
111
|
+
// happened to create ~/.ijfw/state by side-effect).
|
|
112
|
+
try {
|
|
113
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
114
|
+
} catch {
|
|
115
|
+
// Ignore — the subsequent mkdir(lockPath) will surface a clearer error.
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const deadline = Date.now() + acquireTimeoutMs;
|
|
119
|
+
let staleRecoveryUsed = false;
|
|
120
|
+
let backoff = BACKOFF_START_MS;
|
|
121
|
+
|
|
122
|
+
// Acquire loop. We try mkdir; if EEXIST, decide between waiting and stale
|
|
123
|
+
// recovery; otherwise propagate the error.
|
|
124
|
+
// eslint-disable-next-line no-constant-condition
|
|
125
|
+
while (true) {
|
|
126
|
+
try {
|
|
127
|
+
await tryAcquireOnce(lockPath);
|
|
128
|
+
break;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err && err.code !== 'EEXIST') {
|
|
131
|
+
// Real filesystem error (ENOENT on parent, EACCES, etc.) — surface.
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// R12-M-01: when holder.json is missing or unparseable (crash between
|
|
136
|
+
// mkdir and the holder write, OR an attacker pre-creating an empty lock
|
|
137
|
+
// dir to starve callers), fall back to the lock directory's own mtime so
|
|
138
|
+
// stale-recovery can still fire. Without this fallback the local-DoS
|
|
139
|
+
// surface is "mkdir <lockPath> && walk away".
|
|
140
|
+
const holder = await readHolder(lockPath);
|
|
141
|
+
let age = null;
|
|
142
|
+
if (holder) {
|
|
143
|
+
age = Date.now() - holder.acquired_at;
|
|
144
|
+
} else {
|
|
145
|
+
try {
|
|
146
|
+
const st = await stat(lockPath);
|
|
147
|
+
age = Date.now() - st.mtimeMs;
|
|
148
|
+
} catch {
|
|
149
|
+
// The lock dir vanished between EEXIST and stat — fall through to
|
|
150
|
+
// the deadline check; the next loop iteration will try mkdir again.
|
|
151
|
+
age = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const isStale = age != null && age > staleMs;
|
|
155
|
+
|
|
156
|
+
if (isStale && !staleRecoveryUsed) {
|
|
157
|
+
staleRecoveryUsed = true;
|
|
158
|
+
try {
|
|
159
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
160
|
+
} catch (rmErr) {
|
|
161
|
+
throw new FsLockStaleError(lockPath, rmErr);
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await tryAcquireOnce(lockPath);
|
|
165
|
+
break;
|
|
166
|
+
} catch (retryErr) {
|
|
167
|
+
if (retryErr && retryErr.code === 'EEXIST') {
|
|
168
|
+
throw new FsLockStaleError(lockPath, retryErr);
|
|
169
|
+
}
|
|
170
|
+
throw retryErr;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (Date.now() >= deadline) {
|
|
175
|
+
throw new FsLockBusyError(lockPath, acquireTimeoutMs);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Bounded exponential backoff; clamp to remaining time so we don't
|
|
179
|
+
// overshoot the deadline by a full BACKOFF_MAX_MS slice.
|
|
180
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
181
|
+
const waitMs = Math.min(backoff, BACKOFF_MAX_MS, remaining);
|
|
182
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
183
|
+
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Lock acquired — run fn and ALWAYS release.
|
|
188
|
+
let fnResult;
|
|
189
|
+
let fnError;
|
|
190
|
+
try {
|
|
191
|
+
fnResult = await fn();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
fnError = err;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
198
|
+
} catch {
|
|
199
|
+
// Release failures don't override the caller's result/error. The next
|
|
200
|
+
// caller's stale-recovery will reclaim if needed.
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (fnError !== undefined) throw fnError;
|
|
204
|
+
return fnResult;
|
|
205
|
+
}
|