@ran-sh/dsh-crew 1.10.1 → 1.10.2
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/package.json
CHANGED
package/src/dsh-cli-runtime.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
existsSync,
|
|
12
12
|
lstatSync,
|
|
13
13
|
mkdirSync,
|
|
14
|
+
readdirSync,
|
|
14
15
|
readFileSync,
|
|
15
16
|
realpathSync,
|
|
16
17
|
renameSync,
|
|
@@ -528,9 +529,21 @@ async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned,
|
|
|
528
529
|
return recovery;
|
|
529
530
|
}
|
|
530
531
|
}
|
|
531
|
-
try { rmSync(liveRoot, { recursive: true, force: true }); } catch {}
|
|
532
532
|
try {
|
|
533
|
-
|
|
533
|
+
rmSync(liveRoot, { recursive: true, force: true });
|
|
534
|
+
} catch (error) {
|
|
535
|
+
// A failed removal must stop the recovery. Swallowing it left the window
|
|
536
|
+
// open: the restore was attempted over whatever remained, and
|
|
537
|
+
// `startOwned()` ran regardless — so a tree that could not be removed was
|
|
538
|
+
// then asked to start again, which on Windows is exactly the case where
|
|
539
|
+
// live handles blocked the removal.
|
|
540
|
+
recovery.removeError = String(error?.message ?? error);
|
|
541
|
+
recovery.ok = false;
|
|
542
|
+
return recovery;
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
if (!existsSync(prevRoot)) recovery.restoreError = `previous runtime is missing at ${prevRoot}`;
|
|
546
|
+
else { rename(prevRoot, liveRoot); recovery.restore = true; }
|
|
534
547
|
} catch (error) {
|
|
535
548
|
recovery.restoreError = String(error?.message ?? error);
|
|
536
549
|
}
|
|
@@ -538,6 +551,14 @@ async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned,
|
|
|
538
551
|
recovery.ok = recovery.restore === true;
|
|
539
552
|
return recovery;
|
|
540
553
|
}
|
|
554
|
+
// Restart only on positive proof that the prior runtime is the tree now at the
|
|
555
|
+
// live root. Starting without it would run the failed candidate again, or
|
|
556
|
+
// nothing at all, and report it as a recovery.
|
|
557
|
+
if (recovery.restore !== true || !existsSync(liveRoot)) {
|
|
558
|
+
recovery.restartSkipped = 'the previous runtime was not restored';
|
|
559
|
+
recovery.ok = false;
|
|
560
|
+
return recovery;
|
|
561
|
+
}
|
|
541
562
|
try {
|
|
542
563
|
const restarted = await startOwned();
|
|
543
564
|
recovery.restart = restarted?.ok === true;
|
|
@@ -551,6 +572,8 @@ async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned,
|
|
|
551
572
|
|
|
552
573
|
// ---- retained runtime cohorts -------------------------------------------------
|
|
553
574
|
|
|
575
|
+
const DISPLACED_RUNTIME_PREFIX = 'runtime-displaced-';
|
|
576
|
+
|
|
554
577
|
function retainedRuntimesRoot({ home }) {
|
|
555
578
|
return join(crewDshHome({ home }), RETAINED_RUNTIMES_DIRNAME);
|
|
556
579
|
}
|
|
@@ -572,12 +595,22 @@ function runtimeTreeVersion(root, read = readFileSync) {
|
|
|
572
595
|
// Best-effort retention of the swapped-out prior runtime tree. Never throws
|
|
573
596
|
// and never fails the caller: retention is an optimization for offline
|
|
574
597
|
// rollback, not a correctness requirement of the migration itself.
|
|
575
|
-
|
|
598
|
+
/**
|
|
599
|
+
* Retain a parked runtime tree under the version it ships.
|
|
600
|
+
*
|
|
601
|
+
* `removeUnreadable` exists for one caller: the rollback rotation, where the
|
|
602
|
+
* parked tree is the ONLY copy of the cohort just displaced. There, failing to
|
|
603
|
+
* read its version is not a reason to delete it — that would destroy the very
|
|
604
|
+
* copy the rotation exists to keep — so it stays parked and is found later by
|
|
605
|
+
* `findRetainedRuntime`. The forward migration path keeps the default, where an
|
|
606
|
+
* unreadable tree really is junk.
|
|
607
|
+
*/
|
|
608
|
+
function retainPriorRuntime({ home, prevRoot, rename = renameSync, removeUnreadable = true }) {
|
|
576
609
|
try {
|
|
577
610
|
if (!existsSync(prevRoot)) return { ok: true, retained: false };
|
|
578
611
|
const version = runtimeTreeVersion(prevRoot);
|
|
579
612
|
if (!version) {
|
|
580
|
-
|
|
613
|
+
if (!removeUnreadable) return { ok: false, retained: false, prevRoot, error: 'parked cohort version unreadable; left in place' };
|
|
581
614
|
try { rmSync(prevRoot, { recursive: true, force: true }); } catch {}
|
|
582
615
|
return { ok: true, retained: false, reason: 'prior tree version unreadable; removed' };
|
|
583
616
|
}
|
|
@@ -601,9 +634,24 @@ function retainPriorRuntime({ home, prevRoot, rename = renameSync }) {
|
|
|
601
634
|
export function findRetainedRuntime({ home = homedir(), version, exists = existsSync, read = readFileSync } = {}) {
|
|
602
635
|
if (typeof version !== 'string' || version.length === 0) return null;
|
|
603
636
|
const dir = retainedRuntimeDir({ home, version });
|
|
604
|
-
if (
|
|
605
|
-
|
|
606
|
-
|
|
637
|
+
if (exists(dir) && runtimeTreeVersion(dir, read) === version) return dir;
|
|
638
|
+
// A rotation whose retain step failed leaves the displaced cohort parked in the
|
|
639
|
+
// harness home. It is a complete, valid copy of that cohort, so it is found
|
|
640
|
+
// here rather than being written off: otherwise a later offline rollback to
|
|
641
|
+
// that version reports RETAINED_MISSING while the tree sits on disk.
|
|
642
|
+
return findDisplacedRuntime({ home, version, exists, read });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function findDisplacedRuntime({ home, version, exists = existsSync, read = readFileSync }) {
|
|
646
|
+
let names;
|
|
647
|
+
try { names = readdirSync(crewDshHome({ home })); } catch { return null; }
|
|
648
|
+
for (const name of names) {
|
|
649
|
+
if (!name.startsWith(DISPLACED_RUNTIME_PREFIX)) continue;
|
|
650
|
+
const dir = join(crewDshHome({ home }), name);
|
|
651
|
+
if (!exists(dir)) continue;
|
|
652
|
+
if (runtimeTreeVersion(dir, read) === version) return dir;
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
607
655
|
}
|
|
608
656
|
|
|
609
657
|
// Offline cohort restore for cross-cohort rollback: move the retained tree
|
|
@@ -642,14 +690,34 @@ export async function restoreRetainedRuntime({
|
|
|
642
690
|
if (!stop.ok) {
|
|
643
691
|
return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
|
|
644
692
|
}
|
|
693
|
+
// Park the displaced cohort instead of deleting it. Deleting it was what made a
|
|
694
|
+
// second rollback impossible: after B→A the B tree was gone, so a later A→B
|
|
695
|
+
// found no retained B. Retention has to rotate, like the forward path does.
|
|
696
|
+
const parked = join(crewDshHome({ home }), `${DISPLACED_RUNTIME_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`);
|
|
697
|
+
let parkedLive = false;
|
|
698
|
+
try {
|
|
699
|
+
if (existsSync(liveRoot)) { rename(liveRoot, parked); parkedLive = true; }
|
|
700
|
+
} catch (error) {
|
|
701
|
+
return { ok: false, code: 'DSH_RUNTIME_PARK_FAILED', error: String(error?.message ?? error) };
|
|
702
|
+
}
|
|
645
703
|
try {
|
|
646
|
-
if (existsSync(liveRoot)) rmSync(liveRoot, { recursive: true, force: true });
|
|
647
704
|
rename(retained, liveRoot);
|
|
648
705
|
} catch (error) {
|
|
649
|
-
//
|
|
650
|
-
//
|
|
706
|
+
// Put the displaced tree back: leaving the live root empty because a rename
|
|
707
|
+
// failed would take a working runtime down for nothing.
|
|
708
|
+
try { if (parkedLive && !existsSync(liveRoot)) rename(parked, liveRoot); } catch { /* reported below */ }
|
|
651
709
|
return { ok: false, code: 'DSH_RUNTIME_RESTORE_SWAP_FAILED', error: String(error?.message ?? error) };
|
|
652
710
|
}
|
|
711
|
+
// The cohort just displaced becomes the retained one, so the version this
|
|
712
|
+
// restore moved away from can still be rolled back to. This parked tree is the
|
|
713
|
+
// only copy of that cohort, so an unreadable version keeps it rather than
|
|
714
|
+
// deleting it, and a failure is surfaced instead of passing as a clean restore.
|
|
715
|
+
const rotated = parkedLive
|
|
716
|
+
? retainPriorRuntime({ home, prevRoot: parked, rename, removeUnreadable: false })
|
|
717
|
+
: { ok: true, retained: false };
|
|
718
|
+
if (parkedLive && rotated.ok === false) {
|
|
719
|
+
log(`! could not retain the displaced runtime cohort; it remains at ${parked}: ${rotated.error ?? ''}`);
|
|
720
|
+
}
|
|
653
721
|
if (prepareOnly) {
|
|
654
722
|
log(`- runtime tree prepared offline from retained tree (@${version}); process not started`);
|
|
655
723
|
return { ok: true, version, liveRoot, prepared: true };
|
|
@@ -161,21 +161,62 @@ export function readCurrentPointerState({ home = homedir() } = {}) {
|
|
|
161
161
|
return { status: 'malformed', file, code: 'POINTER_FIELDS_INVALID' };
|
|
162
162
|
}
|
|
163
163
|
if (!isAbsolute(raw.path)) return { status: 'malformed', file, code: 'POINTER_PATH_NOT_ABSOLUTE' };
|
|
164
|
-
|
|
164
|
+
// The pointer names the release that is live, and recovery acts on it. A path
|
|
165
|
+
// outside the managed releases directory is a corrupt or tampered pointer, not
|
|
166
|
+
// a release, so it fails closed exactly like the other malformed cases — and
|
|
167
|
+
// the canonical value is what the caller gets, so every later use acts on the
|
|
168
|
+
// path that was checked.
|
|
169
|
+
const canonical = managedReleasePath({ home, value: raw.path });
|
|
170
|
+
if (canonical === null) {
|
|
171
|
+
return { status: 'malformed', file, code: 'POINTER_PATH_OUTSIDE_RELEASES' };
|
|
172
|
+
}
|
|
173
|
+
return { status: 'valid', file, pointer: { ...raw, path: canonical } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve a path that a journal, the pointer, or recovery is about to act on, and
|
|
178
|
+
* refuse it unless it is inside the Crew-owned releases directory.
|
|
179
|
+
*
|
|
180
|
+
* An absolute path is not enough. Recovery recursively deletes the journal's
|
|
181
|
+
* candidate stage directory and moves the pointer's release, so a syntactically
|
|
182
|
+
* valid but corrupt or tampered journal naming any absolute path would grant
|
|
183
|
+
* delete authority over it — including the official `~/.dsh` tree this plugin is
|
|
184
|
+
* required never to touch. Containment is therefore checked before any read,
|
|
185
|
+
* write, delete or activation, and a path that fails it is treated exactly like
|
|
186
|
+
* a malformed journal: nothing is touched and the state is retained.
|
|
187
|
+
*/
|
|
188
|
+
export function managedReleasePath({ home = homedir(), value } = {}) {
|
|
189
|
+
if (typeof value !== 'string' || value.length === 0 || !isAbsolute(value)) return null;
|
|
190
|
+
const releases = realpathOr(resolve(crewReleasesDir({ home })));
|
|
191
|
+
const candidate = realpathOr(resolve(value));
|
|
192
|
+
// Resolve both sides before comparing: `..` segments and symlinks must not be
|
|
193
|
+
// able to leave the managed directory after the check.
|
|
194
|
+
const relative = relativeTo(releases, candidate);
|
|
195
|
+
if (relative === null || relative === '' || relative.startsWith('..') || isAbsolute(relative)) return null;
|
|
196
|
+
return candidate;
|
|
165
197
|
}
|
|
166
198
|
|
|
167
|
-
function
|
|
199
|
+
function realpathOr(path) {
|
|
200
|
+
try { return realpathSync(path); } catch { return path; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** `path` expressed relative to `from`, or null when they are on different roots. */
|
|
204
|
+
function relativeTo(from, path) {
|
|
205
|
+
return relative(from, path);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function validJournalRelease({ home, value }) {
|
|
168
209
|
return !!value && typeof value === 'object'
|
|
169
210
|
&& typeof value.name === 'string' && value.name.length > 0
|
|
170
211
|
&& typeof value.version === 'string' && value.version.length > 0
|
|
171
|
-
&&
|
|
212
|
+
&& managedReleasePath({ home, value: value.path }) !== null;
|
|
172
213
|
}
|
|
173
214
|
|
|
174
|
-
function validJournalCandidate(value) {
|
|
215
|
+
function validJournalCandidate({ home, value }) {
|
|
175
216
|
return !!value && typeof value === 'object'
|
|
176
217
|
&& typeof value.name === 'string' && value.name.length > 0
|
|
177
218
|
&& typeof value.version === 'string' && value.version.length > 0
|
|
178
|
-
&&
|
|
219
|
+
&& managedReleasePath({ home, value: value.stageDir }) !== null;
|
|
179
220
|
}
|
|
180
221
|
|
|
181
222
|
function writeFileAtomic(file, content) {
|
|
@@ -359,13 +400,23 @@ function readUpdateJournal({ home = homedir() } = {}) {
|
|
|
359
400
|
return { malformed: true, file };
|
|
360
401
|
}
|
|
361
402
|
// Full schema check: a semantically broken journal (null candidate,
|
|
362
|
-
// incomplete prior) must fail closed, never enter recovery.
|
|
363
|
-
|
|
403
|
+
// incomplete prior) must fail closed, never enter recovery. The paths are
|
|
404
|
+
// checked for containment too, because recovery deletes and moves them, and
|
|
405
|
+
// they are replaced with the canonical value that was checked so that every
|
|
406
|
+
// later use acts on the path that was validated rather than the string that
|
|
407
|
+
// happened to be written.
|
|
408
|
+
const canonicalCandidate = managedReleasePath({ home, value: raw.candidate?.stageDir });
|
|
409
|
+
if (!validJournalCandidate({ home, value: raw.candidate }) || canonicalCandidate === null) {
|
|
364
410
|
return { malformed: true, file, code: 'JOURNAL_CANDIDATE_SCHEMA_INVALID' };
|
|
365
411
|
}
|
|
366
|
-
if (raw.prior !== null && raw.prior !== undefined
|
|
367
|
-
|
|
412
|
+
if (raw.prior !== null && raw.prior !== undefined) {
|
|
413
|
+
const canonicalPrior = managedReleasePath({ home, value: raw.prior?.path });
|
|
414
|
+
if (!validJournalRelease({ home, value: raw.prior }) || canonicalPrior === null) {
|
|
415
|
+
return { malformed: true, file, code: 'JOURNAL_PRIOR_SCHEMA_INVALID' };
|
|
416
|
+
}
|
|
417
|
+
raw = { ...raw, prior: { ...raw.prior, path: canonicalPrior } };
|
|
368
418
|
}
|
|
419
|
+
raw = { ...raw, candidate: { ...raw.candidate, stageDir: canonicalCandidate } };
|
|
369
420
|
return raw;
|
|
370
421
|
}
|
|
371
422
|
|
|
@@ -554,7 +605,9 @@ export function reconcileUpdateJournal({ home = homedir(), log = () => {}, insta
|
|
|
554
605
|
return { ok: false, code: 'POINTER_MALFORMED', file: pointerState.file, error: pointerState.code ?? 'pointer unreadable; refusing recovery' };
|
|
555
606
|
}
|
|
556
607
|
const pointer = pointerState.status === 'valid' ? pointerState.pointer : null;
|
|
557
|
-
|
|
608
|
+
// Act on the canonical path that was validated, not the raw string that was
|
|
609
|
+
// written: the delete below is only as safe as the path it is handed.
|
|
610
|
+
const candidateDir = managedReleasePath({ home, value: journal.candidate?.stageDir ?? null });
|
|
558
611
|
const candidateManifest = candidateDir && existsSync(candidateDir) ? readManifest(candidateDir) : null;
|
|
559
612
|
|
|
560
613
|
// A coordinated-update journal MUST carry a runtime segment; recovery may
|
|
@@ -74,12 +74,23 @@ export function capturePayloadContent(root, suppliedManifest) {
|
|
|
74
74
|
} catch { return null; }
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* This digest covers first-party shipped files, not dependency tamper attestation.
|
|
79
|
+
*
|
|
80
|
+
* The permission bits are part of it: the copy below writes each file with the
|
|
81
|
+
* mode captured from the source, so a payload whose `0644` became `0755` with
|
|
82
|
+
* identical bytes is not the same payload — it is the case where an intended
|
|
83
|
+
* permission repair would otherwise be skipped as already done.
|
|
84
|
+
*/
|
|
78
85
|
export function payloadContentDigest(root) {
|
|
79
86
|
const snapshot = capturePayloadContent(root);
|
|
80
87
|
if (!snapshot) return null;
|
|
81
|
-
const
|
|
82
|
-
|
|
88
|
+
const entries = [...snapshot.files].map(([name, bytes]) => [
|
|
89
|
+
name,
|
|
90
|
+
snapshot.modes.get(name) ?? null,
|
|
91
|
+
createHash('sha256').update(bytes).digest('hex'),
|
|
92
|
+
]);
|
|
93
|
+
return createHash('sha256').update(JSON.stringify(entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))).digest('hex');
|
|
83
94
|
}
|
|
84
95
|
|
|
85
96
|
export function samePayloadContent(sourceRoot, installedRoot) {
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '1.10.
|
|
39
|
+
export const RUNTIME_VERSION = '1.10.2';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|