@cortexkit/antigravity-auth-core 3.0.15 → 3.0.17
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/dist/account-migration.d.ts +109 -3
- package/dist/account-migration.d.ts.map +1 -1
- package/dist/account-migration.js +1101 -65
- package/dist/account-migration.js.map +1 -1
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +13 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +159 -7
- package/dist/common-auth-embedded/pi-slot/lock.js +26 -1
- package/dist/common-auth-embedded/source-output.json +15 -15
- package/package.json +2 -2
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { link, lstat, mkdir, open, realpath, unlink } from 'node:fs/promises';
|
|
3
|
+
import { link, lstat, mkdir, open, realpath, rename, unlink, } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { isDeepStrictEqual } from 'node:util';
|
|
6
6
|
import { AccountCodecError, decodeProviderMetadata, decodeProviderState, decodeQuotaState, decodeRoutingSettings, encodeProviderMetadata, encodeProviderState, encodeQuotaState, encodeRoutingSettings, isValidProviderState, providerStateCredentialBound, QUOTA_CODEC, } from "./account-repository-codecs.js";
|
|
7
|
-
import { ACCOUNT_STORE_PROVIDER, legacyAccountsLock, MANAGEMENT_LOCK_NAME, MANAGEMENT_SETTINGS_KEY, ROUTING_SETTINGS_KEY, } from "./account-repository-types.js";
|
|
7
|
+
import { ACCOUNT_STORE_PROVIDER, legacyAccountsLock, MANAGEMENT_LOCK_NAME, MANAGEMENT_SETTINGS_KEY, ROUTING_SETTINGS_KEY, refreshProviderLock, } from "./account-repository-types.js";
|
|
8
|
+
import { createPrivateBytes, createPrivateDirectoryExclusive, verifySafeDirectory, windowsPrivateFiles, } from './common-auth-embedded/fs/private-files.js';
|
|
8
9
|
/** Derives store paths from the caller's absolute legacy file path, not HOME or host preferences. */
|
|
9
10
|
export function resolveAccountStorePaths(legacyPath, generationId) {
|
|
10
11
|
if (generationId !== undefined)
|
|
@@ -59,14 +60,96 @@ export function decodeAccountStorePointer(value, legacyPath) {
|
|
|
59
60
|
throw new AccountMigrationError('pointer directory basename differs from its derived generation');
|
|
60
61
|
return { schemaVersion: 1, id, directoryBasename: basename(paths.storeDir) };
|
|
61
62
|
}
|
|
62
|
-
|
|
63
|
+
/** Decodes either pointer schema; schema 1 keeps `decodeAccountStorePointer`'s exact rules. */
|
|
64
|
+
export function decodeAccountStorePointerRecord(value, legacyPath) {
|
|
65
|
+
if (object(value, 'pointer').schemaVersion !== 2)
|
|
66
|
+
return decodeAccountStorePointer(value, legacyPath);
|
|
67
|
+
const raw = exact(value, ['schemaVersion', 'id', 'directoryBasename', 'relocatedTo'], 'forward');
|
|
68
|
+
const id = text(raw.id, UUID, 'forward source generation');
|
|
69
|
+
const paths = generationPaths(legacyPath, id);
|
|
70
|
+
if (raw.directoryBasename !== basename(paths.storeDir))
|
|
71
|
+
throw new AccountMigrationError('forward directory basename differs from its derived generation');
|
|
72
|
+
const target = exact(raw.relocatedTo, ['legacyPath', 'id'], 'forward target');
|
|
73
|
+
if (typeof target.legacyPath !== 'string')
|
|
74
|
+
throw new AccountMigrationError('forward target path is invalid');
|
|
75
|
+
const targetId = text(target.id, UUID, 'forward target generation');
|
|
76
|
+
const targetPaths = generationPaths(target.legacyPath, targetId);
|
|
77
|
+
if (sameFilesystemPath(targetPaths.legacyPath, legacyPath))
|
|
78
|
+
throw new AccountMigrationError('forward names its own anchor');
|
|
79
|
+
return {
|
|
80
|
+
schemaVersion: 2,
|
|
81
|
+
id,
|
|
82
|
+
directoryBasename: basename(paths.storeDir),
|
|
83
|
+
relocatedTo: {
|
|
84
|
+
legacyPath: targetPaths.legacyPath,
|
|
85
|
+
id: targetId,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function readPointerRecord(legacyPath) {
|
|
63
90
|
const logical = resolveAccountStorePaths(legacyPath);
|
|
64
91
|
if (await exists(logical.storeDir))
|
|
65
92
|
throw new AccountMigrationError('foreign logical store directory is not pointer authority');
|
|
66
93
|
const path = accountStorePointerPath(legacyPath);
|
|
67
94
|
if (!(await exists(path)))
|
|
68
95
|
return undefined;
|
|
69
|
-
return
|
|
96
|
+
return decodeAccountStorePointerRecord(jsonBytes(await secureRead(path)), legacyPath);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The schema-1 pointer for operations that work on the generation published
|
|
100
|
+
* at this anchor. A forward refuses them: this build only reads a moved
|
|
101
|
+
* store (through `readAccountStoreBinding`) and never migrates, initializes,
|
|
102
|
+
* publishes over or writes beside one.
|
|
103
|
+
*/
|
|
104
|
+
async function readPointer(legacyPath) {
|
|
105
|
+
const record = await readPointerRecord(legacyPath);
|
|
106
|
+
if (record?.schemaVersion === 2)
|
|
107
|
+
throw new AccountMigrationError(`the account store moved to ${record.relocatedTo.legacyPath}; this operation does not follow a moved store`);
|
|
108
|
+
return record;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The anchor a forward leads to. The new anchor's pointer must publish
|
|
112
|
+
* exactly the generation the forward names; anything else (no pointer, a
|
|
113
|
+
* different generation, a second forward) refuses, because binding there
|
|
114
|
+
* would either serve the wrong store or report "no accounts" and let a login
|
|
115
|
+
* create a second store.
|
|
116
|
+
*/
|
|
117
|
+
async function forwardTarget(legacyPath, forward) {
|
|
118
|
+
const target = forward.relocatedTo.legacyPath;
|
|
119
|
+
const published = await readPointerRecord(target);
|
|
120
|
+
if (published?.schemaVersion !== 1 || published.id !== forward.relocatedTo.id)
|
|
121
|
+
throw new AccountMigrationError(`the forwarding pointer ${accountStorePointerPath(legacyPath)} names generation ${forward.relocatedTo.id}, which ${accountStorePointerPath(target)} does not publish`);
|
|
122
|
+
return target;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolves the caller's anchor to the one that holds the store: follows a
|
|
126
|
+
* schema-2 forward one step to the anchor it names, after checking that
|
|
127
|
+
* anchor still publishes the forwarded generation. Reads only; it writes
|
|
128
|
+
* nothing at either anchor.
|
|
129
|
+
*/
|
|
130
|
+
export async function resolveAccountStoreAnchor(legacyPath, options = {}) {
|
|
131
|
+
resolveAccountStorePaths(legacyPath);
|
|
132
|
+
const pointer = accountStorePointerPath(legacyPath);
|
|
133
|
+
for (const alternative of options.alternatives ?? []) {
|
|
134
|
+
if (sameFilesystemPath(alternative, legacyPath))
|
|
135
|
+
continue;
|
|
136
|
+
const other = accountStorePointerPath(alternative);
|
|
137
|
+
if ((await exists(pointer)) && (await exists(other)))
|
|
138
|
+
throw new AccountMigrationError(`account store pointers exist at both ${pointer} and ${other}; refusing to choose between them`);
|
|
139
|
+
}
|
|
140
|
+
const record = await readPointerRecord(legacyPath);
|
|
141
|
+
if (record?.schemaVersion !== 2)
|
|
142
|
+
return {
|
|
143
|
+
legacyPath: (await restoredRelocationAnchor(legacyPath, record)) ?? legacyPath,
|
|
144
|
+
};
|
|
145
|
+
const named = record.relocatedTo.legacyPath;
|
|
146
|
+
if (options.relocatedAnchor !== undefined &&
|
|
147
|
+
!sameFilesystemPath(options.relocatedAnchor, named))
|
|
148
|
+
throw new AccountMigrationError(`the forwarding pointer ${pointer} names ${named}, but this process keeps the account store at ${options.relocatedAnchor}`);
|
|
149
|
+
return {
|
|
150
|
+
legacyPath: await forwardTarget(legacyPath, record),
|
|
151
|
+
forwardedFrom: legacyPath,
|
|
152
|
+
};
|
|
70
153
|
}
|
|
71
154
|
export async function resolvePublishedAccountStorePaths(legacyPath) {
|
|
72
155
|
const pointer = await readPointer(legacyPath);
|
|
@@ -354,7 +437,9 @@ export function decodeAccountStoreGeneration(value) {
|
|
|
354
437
|
], 'generation');
|
|
355
438
|
if (raw.schemaVersion !== 1 ||
|
|
356
439
|
typeof raw.legacyPath !== 'string' ||
|
|
357
|
-
(raw.sourceKind !== 'file' &&
|
|
440
|
+
(raw.sourceKind !== 'file' &&
|
|
441
|
+
raw.sourceKind !== 'absent' &&
|
|
442
|
+
raw.sourceKind !== 'generation'))
|
|
358
443
|
throw new AccountMigrationError('generation schema is invalid');
|
|
359
444
|
const id = text(raw.id, UUID, 'generation id');
|
|
360
445
|
const paths = generationPaths(raw.legacyPath, id);
|
|
@@ -462,6 +547,7 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
462
547
|
'retiredSha256',
|
|
463
548
|
'rollback',
|
|
464
549
|
'cancelled',
|
|
550
|
+
'relocation',
|
|
465
551
|
], 'journal');
|
|
466
552
|
if (raw.schemaVersion !== 1)
|
|
467
553
|
throw new AccountMigrationError('unsupported journal schema');
|
|
@@ -481,12 +567,16 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
481
567
|
if (phase === undefined)
|
|
482
568
|
throw new AccountMigrationError('journal phase is invalid');
|
|
483
569
|
const sourceKind = raw.sourceKind;
|
|
484
|
-
if (sourceKind !== 'file' &&
|
|
570
|
+
if (sourceKind !== 'file' &&
|
|
571
|
+
sourceKind !== 'absent' &&
|
|
572
|
+
sourceKind !== 'generation')
|
|
485
573
|
throw new AccountMigrationError('journal source kind is invalid');
|
|
486
|
-
|
|
574
|
+
// A moved store keeps the same two hashes as a pool-file source: its
|
|
575
|
+
// snapshot of the old generation's files and its capture file.
|
|
576
|
+
const sourceSha256 = sourceKind !== 'absent'
|
|
487
577
|
? text(raw.sourceSha256, SHA256, 'journal.sourceSha256')
|
|
488
578
|
: undefined;
|
|
489
|
-
const captureSha256 = sourceKind
|
|
579
|
+
const captureSha256 = sourceKind !== 'absent'
|
|
490
580
|
? text(raw.captureSha256, SHA256, 'journal.captureSha256')
|
|
491
581
|
: undefined;
|
|
492
582
|
if (sourceKind === 'absent' && Object.hasOwn(raw, 'captureSha256'))
|
|
@@ -511,7 +601,7 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
511
601
|
sourceVersion !== 4)
|
|
512
602
|
throw new AccountMigrationError('manifest source version is invalid');
|
|
513
603
|
if ((sourceKind === 'file' && sourceVersion === null) ||
|
|
514
|
-
(sourceKind
|
|
604
|
+
(sourceKind !== 'file' && sourceVersion !== null))
|
|
515
605
|
throw new AccountMigrationError('manifest version differs from source kind');
|
|
516
606
|
if (!finite(m.normalizationClock) || !finite(m.effectiveActiveIndex))
|
|
517
607
|
throw new AccountMigrationError('manifest clock or cursor is invalid');
|
|
@@ -549,6 +639,15 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
549
639
|
}) ||
|
|
550
640
|
manifest.effectiveActiveIndex !== 0))
|
|
551
641
|
throw new AccountMigrationError('absent-source manifest must be explicitly empty with no invented cursors');
|
|
642
|
+
// A move copies the store's own files; its journal carries no rows and so
|
|
643
|
+
// no refresh token. The moved rows are listed in `relocation.rows`.
|
|
644
|
+
if (sourceKind === 'generation' &&
|
|
645
|
+
(accounts.length ||
|
|
646
|
+
!isDeepStrictEqual(encodeRoutingSettings(manifest.routing), {
|
|
647
|
+
schemaVersion: 1,
|
|
648
|
+
}) ||
|
|
649
|
+
manifest.effectiveActiveIndex !== 0))
|
|
650
|
+
throw new AccountMigrationError('moved-store manifest must be explicitly empty');
|
|
552
651
|
const effective = accounts.length
|
|
553
652
|
? Math.max(0, Math.min(manifest.routing.activeIndex ?? 0, accounts.length - 1))
|
|
554
653
|
: 0;
|
|
@@ -606,6 +705,33 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
606
705
|
throw new AccountMigrationError('absent source cannot claim retired bytes');
|
|
607
706
|
if (retiredSha256 !== undefined && retiredSha256 !== sourceSha256)
|
|
608
707
|
throw new AccountMigrationError('retired bytes are not associated with source');
|
|
708
|
+
const relocation = decodeRelocation(raw, sourceKind, paths.legacyPath);
|
|
709
|
+
if (sourceKind === 'generation') {
|
|
710
|
+
// The moved store's retirement is the forward at the old anchor, not a
|
|
711
|
+
// retired pool-file copy or a checked absence.
|
|
712
|
+
if (retiredSha256 !== undefined)
|
|
713
|
+
throw new AccountMigrationError('moved source cannot claim retired pool-file bytes');
|
|
714
|
+
if (relocation === undefined)
|
|
715
|
+
throw new AccountMigrationError('moved source lacks its relocation evidence');
|
|
716
|
+
if (operation === 'rollback')
|
|
717
|
+
throw new AccountMigrationError('a moved account store is not rolled back; move it back first with `antigravity-auth store relocate --back --offline`');
|
|
718
|
+
if (relocation.sourceForwarded && !relocation.sourceTombstoned)
|
|
719
|
+
throw new AccountMigrationError('source forward precedes its management tombstone');
|
|
720
|
+
if ((relocation.sourceTombstoned || relocation.sourceForwarded) &&
|
|
721
|
+
(!verification || (phase !== 'retire' && phase !== 'activate')))
|
|
722
|
+
throw new AccountMigrationError('source tombstone or forward precedes durable verification');
|
|
723
|
+
if (status === 'active' && !relocation.sourceForwarded)
|
|
724
|
+
throw new AccountMigrationError('active moved store lacks its source forward');
|
|
725
|
+
if (phase === 'activate' && !relocation.sourceForwarded)
|
|
726
|
+
throw new AccountMigrationError('moved-store activation lacks its source forward');
|
|
727
|
+
}
|
|
728
|
+
// What retired the source: the retired pool-file copy, the checked
|
|
729
|
+
// absence of a pool file, or the forward that replaced the old pointer.
|
|
730
|
+
const retirementRecorded = sourceKind === 'file'
|
|
731
|
+
? retiredSha256 !== undefined
|
|
732
|
+
: sourceKind === 'absent'
|
|
733
|
+
? raw.sourceAbsenceVerified === true
|
|
734
|
+
: relocation?.sourceForwarded === true;
|
|
609
735
|
let rollback;
|
|
610
736
|
if (Object.hasOwn(raw, 'rollback')) {
|
|
611
737
|
const r = exact(raw.rollback, ['id', 'snapshotSha256', 'exportSha256', 'rows', 'successorInactive'], 'rollback');
|
|
@@ -626,8 +752,12 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
626
752
|
const cancelled = raw.cancelled === true;
|
|
627
753
|
if (cancelled &&
|
|
628
754
|
(status === 'active' ||
|
|
629
|
-
|
|
630
|
-
|
|
755
|
+
(sourceKind === 'generation'
|
|
756
|
+
? operation !== 'migrate' ||
|
|
757
|
+
relocation?.sourceForwarded === true ||
|
|
758
|
+
phase === 'activate'
|
|
759
|
+
: operation !== 'rollback' ||
|
|
760
|
+
(status === 'inactive' && phase !== 'activate')) ||
|
|
631
761
|
rollback !== undefined ||
|
|
632
762
|
retiredSha256 !== undefined))
|
|
633
763
|
throw new AccountMigrationError('invalid pre-retirement cancellation');
|
|
@@ -643,18 +773,12 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
643
773
|
(retiredSha256 !== undefined || raw.sourceAbsenceVerified === true) &&
|
|
644
774
|
(!verification || (phase !== 'retire' && phase !== 'activate')))
|
|
645
775
|
throw new AccountMigrationError('retirement association precedes durable verification');
|
|
646
|
-
if (operation === 'migrate' &&
|
|
647
|
-
phase === 'activate' &&
|
|
648
|
-
(sourceKind === 'file'
|
|
649
|
-
? retiredSha256 === undefined
|
|
650
|
-
: raw.sourceAbsenceVerified !== true))
|
|
776
|
+
if (operation === 'migrate' && phase === 'activate' && !retirementRecorded)
|
|
651
777
|
throw new AccountMigrationError('activation lacks its actual source retirement association');
|
|
652
778
|
if (status === 'active' &&
|
|
653
779
|
(operation !== 'migrate' ||
|
|
654
780
|
phase !== 'activate' ||
|
|
655
|
-
|
|
656
|
-
? retiredSha256 === undefined
|
|
657
|
-
: raw.sourceAbsenceVerified !== true) ||
|
|
781
|
+
!retirementRecorded ||
|
|
658
782
|
rollback !== undefined ||
|
|
659
783
|
cancelled))
|
|
660
784
|
throw new AccountMigrationError('active receipt is incomplete');
|
|
@@ -662,10 +786,7 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
662
786
|
throw new AccountMigrationError('rollback lacks its current-credential snapshot');
|
|
663
787
|
if (operation === 'rollback' &&
|
|
664
788
|
!cancelled &&
|
|
665
|
-
(verification === undefined ||
|
|
666
|
-
(sourceKind === 'file'
|
|
667
|
-
? retiredSha256 === undefined
|
|
668
|
-
: raw.sourceAbsenceVerified !== true)))
|
|
789
|
+
(verification === undefined || !retirementRecorded))
|
|
669
790
|
throw new AccountMigrationError('rollback lacks the original verified retirement');
|
|
670
791
|
if (status === 'inactive' &&
|
|
671
792
|
!cancelled &&
|
|
@@ -697,6 +818,74 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
697
818
|
...(retiredSha256 === undefined ? {} : { retiredSha256 }),
|
|
698
819
|
...(rollback === undefined ? {} : { rollback }),
|
|
699
820
|
...(cancelled ? { cancelled: true } : {}),
|
|
821
|
+
...(relocation === undefined ? {} : { relocation }),
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/** Decodes `relocation`, which only a moved store's journal may carry. */
|
|
825
|
+
function decodeRelocation(raw, sourceKind, legacyPath) {
|
|
826
|
+
if (!Object.hasOwn(raw, 'relocation'))
|
|
827
|
+
return undefined;
|
|
828
|
+
if (sourceKind !== 'generation')
|
|
829
|
+
throw new AccountMigrationError('only a moved store records relocation evidence');
|
|
830
|
+
const r = exact(raw.relocation, [
|
|
831
|
+
'source',
|
|
832
|
+
'rows',
|
|
833
|
+
'sourceTombstoned',
|
|
834
|
+
'sourceForwarded',
|
|
835
|
+
'reversed',
|
|
836
|
+
'reverseSha256',
|
|
837
|
+
], 'relocation');
|
|
838
|
+
const s = exact(r.source, [
|
|
839
|
+
'legacyPath',
|
|
840
|
+
'storeDir',
|
|
841
|
+
'journalId',
|
|
842
|
+
'configSha256',
|
|
843
|
+
'stateSha256',
|
|
844
|
+
'statePresent',
|
|
845
|
+
], 'relocation source');
|
|
846
|
+
if (typeof s.legacyPath !== 'string')
|
|
847
|
+
throw new AccountMigrationError('relocation source path is invalid');
|
|
848
|
+
const journalId = text(s.journalId, UUID, 'relocation source journal');
|
|
849
|
+
const source = generationPaths(s.legacyPath, journalId);
|
|
850
|
+
if (s.storeDir !== source.storeDir)
|
|
851
|
+
throw new AccountMigrationError('relocation source association differs');
|
|
852
|
+
if (sameFilesystemPath(source.legacyPath, legacyPath))
|
|
853
|
+
throw new AccountMigrationError('relocation source and target share one anchor');
|
|
854
|
+
if (typeof s.statePresent !== 'boolean')
|
|
855
|
+
throw new AccountMigrationError('relocation state presence is invalid');
|
|
856
|
+
if (!s.statePresent && Object.hasOwn(s, 'stateSha256'))
|
|
857
|
+
throw new AccountMigrationError('absent source state cannot claim a file hash');
|
|
858
|
+
const rows = list(r.rows, 'relocation rows').map((id) => text(id, UUID, 'relocation row id'));
|
|
859
|
+
if (new Set(rows).size !== rows.length)
|
|
860
|
+
throw new AccountMigrationError('relocation rows are duplicated');
|
|
861
|
+
if (typeof r.sourceTombstoned !== 'boolean' ||
|
|
862
|
+
typeof r.sourceForwarded !== 'boolean')
|
|
863
|
+
throw new AccountMigrationError('relocation progress flags are invalid');
|
|
864
|
+
if (Object.hasOwn(r, 'reversed') &&
|
|
865
|
+
(r.reversed !== true || raw.status !== 'active' || !r.sourceForwarded))
|
|
866
|
+
throw new AccountMigrationError('reverse lacks an active forwarded generation');
|
|
867
|
+
if (Object.hasOwn(r, 'reverseSha256') !== (r.reversed === true))
|
|
868
|
+
throw new AccountMigrationError('reverse snapshot association is incomplete');
|
|
869
|
+
return {
|
|
870
|
+
source: {
|
|
871
|
+
legacyPath: source.legacyPath,
|
|
872
|
+
storeDir: source.storeDir,
|
|
873
|
+
journalId,
|
|
874
|
+
configSha256: text(s.configSha256, SHA256, 'relocation config hash'),
|
|
875
|
+
...(s.statePresent
|
|
876
|
+
? { stateSha256: text(s.stateSha256, SHA256, 'relocation state hash') }
|
|
877
|
+
: {}),
|
|
878
|
+
statePresent: s.statePresent,
|
|
879
|
+
},
|
|
880
|
+
rows,
|
|
881
|
+
sourceTombstoned: r.sourceTombstoned,
|
|
882
|
+
sourceForwarded: r.sourceForwarded,
|
|
883
|
+
...(r.reversed === true
|
|
884
|
+
? {
|
|
885
|
+
reversed: true,
|
|
886
|
+
reverseSha256: text(r.reverseSha256, SHA256, 'reverse snapshot hash'),
|
|
887
|
+
}
|
|
888
|
+
: {}),
|
|
700
889
|
};
|
|
701
890
|
}
|
|
702
891
|
function pendingLockFailure(modules, error) {
|
|
@@ -744,7 +933,10 @@ function privateMode(mode, expected) {
|
|
|
744
933
|
}
|
|
745
934
|
async function privateDirectory(path) {
|
|
746
935
|
try {
|
|
747
|
-
|
|
936
|
+
if (windowsPrivateFiles)
|
|
937
|
+
await createPrivateDirectoryExclusive(path);
|
|
938
|
+
else
|
|
939
|
+
await mkdir(path, { mode: 0o700 });
|
|
748
940
|
}
|
|
749
941
|
catch (error) {
|
|
750
942
|
if (errno(error) !== 'EEXIST')
|
|
@@ -754,6 +946,8 @@ async function privateDirectory(path) {
|
|
|
754
946
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
755
947
|
throw new AccountMigrationError('successor directory is not a regular directory');
|
|
756
948
|
privateMode(stat.mode, 0o700);
|
|
949
|
+
if (windowsPrivateFiles)
|
|
950
|
+
await verifySafeDirectory(path);
|
|
757
951
|
if (!sameFilesystemPath(await realpath(path), path))
|
|
758
952
|
throw new AccountMigrationError('successor directory traverses a symbolic link');
|
|
759
953
|
}
|
|
@@ -836,7 +1030,14 @@ async function syncDirectory(path, boundary) {
|
|
|
836
1030
|
async function exclusiveBytes(path, bytes, expectedHash, boundary, pointerStage = false) {
|
|
837
1031
|
let handle;
|
|
838
1032
|
try {
|
|
839
|
-
|
|
1033
|
+
if (windowsPrivateFiles) {
|
|
1034
|
+
// The backend accepts Uint8Array, although the embedded declaration only
|
|
1035
|
+
// lists strings. Preserve native JSON bytes without a UTF-8 round trip.
|
|
1036
|
+
const createBytes = createPrivateBytes;
|
|
1037
|
+
await createBytes(path, bytes);
|
|
1038
|
+
}
|
|
1039
|
+
else
|
|
1040
|
+
handle = await open(path, 'wx', 0o600);
|
|
840
1041
|
}
|
|
841
1042
|
catch (error) {
|
|
842
1043
|
if (errno(error) !== 'EEXIST')
|
|
@@ -856,8 +1057,9 @@ async function exclusiveBytes(path, bytes, expectedHash, boundary, pointerStage
|
|
|
856
1057
|
const reopened = await secureRead(path);
|
|
857
1058
|
if (digest(reopened) !== expectedHash || !reopened.equals(bytes))
|
|
858
1059
|
throw new AccountMigrationError('exclusive backup or recovery copy differs');
|
|
859
|
-
// A new pointer stage was synced through its write handle.
|
|
860
|
-
//
|
|
1060
|
+
// A new POSIX pointer stage was synced through its write handle. A stage the
|
|
1061
|
+
// Windows private writer created has no handle here, so it is synced like
|
|
1062
|
+
// reused stages and every backup, bootstrap, retired and export copy.
|
|
861
1063
|
if (handle === undefined || !pointerStage)
|
|
862
1064
|
await syncFile(path);
|
|
863
1065
|
await syncDirectory(dirname(path), boundary);
|
|
@@ -1046,6 +1248,8 @@ async function verifyCopies(paths, journal, retired) {
|
|
|
1046
1248
|
if (digest(capture) !== journal.captureSha256 ||
|
|
1047
1249
|
!isDeepStrictEqual(jsonBytes(capture), captureJson(journal)))
|
|
1048
1250
|
throw new AccountMigrationError('journal differs from its exclusive capture snapshot');
|
|
1251
|
+
if (journal.sourceKind === 'generation')
|
|
1252
|
+
return verifyRelocationSnapshot(backup, journal);
|
|
1049
1253
|
// Original backup JSON and the captured time reproduce the expected import.
|
|
1050
1254
|
// Compare that value with the journal without changing its selected rows, clock,
|
|
1051
1255
|
// source positions or UUID mapping.
|
|
@@ -1068,8 +1272,67 @@ function captureJson(journal) {
|
|
|
1068
1272
|
sourceSha256: journal.sourceSha256,
|
|
1069
1273
|
manifest: manifestJson(journal.manifest),
|
|
1070
1274
|
mapping: journal.mapping,
|
|
1275
|
+
...(journal.relocation === undefined
|
|
1276
|
+
? {}
|
|
1277
|
+
: { relocation: { source: journal.relocation.source } }),
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
function base64Bytes(value, path) {
|
|
1281
|
+
if (typeof value !== 'string')
|
|
1282
|
+
throw new AccountMigrationError(`${path} is invalid`);
|
|
1283
|
+
const bytes = Buffer.from(value, 'base64');
|
|
1284
|
+
// Only canonical base64 round-trips; anything else could hide extra bytes.
|
|
1285
|
+
if (bytes.toString('base64') !== value)
|
|
1286
|
+
throw new AccountMigrationError(`${path} is not canonical base64`);
|
|
1287
|
+
return bytes;
|
|
1288
|
+
}
|
|
1289
|
+
export function decodeRelocationSnapshot(value) {
|
|
1290
|
+
const raw = exact(value, ['config', 'state', 'journal'], 'moved snapshot');
|
|
1291
|
+
return {
|
|
1292
|
+
config: base64Bytes(raw.config, 'moved snapshot config'),
|
|
1293
|
+
...(Object.hasOwn(raw, 'state')
|
|
1294
|
+
? { state: base64Bytes(raw.state, 'moved snapshot state') }
|
|
1295
|
+
: {}),
|
|
1296
|
+
journal: base64Bytes(raw.journal, 'moved snapshot journal'),
|
|
1071
1297
|
};
|
|
1072
1298
|
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Ties a moved store's source snapshot to its journal: the copied files hash
|
|
1301
|
+
* to `relocation.source`, the copied journal is the active source generation
|
|
1302
|
+
* it names, and the copied config carries that generation's own settings
|
|
1303
|
+
* key. The live old generation is not compared: it gains a management
|
|
1304
|
+
* tombstone after the copy, and it is no longer served.
|
|
1305
|
+
*
|
|
1306
|
+
* Binding a copy at another anchor is sound because the store's files hold
|
|
1307
|
+
* no location. The common-auth "Store portability" contract (common-auth
|
|
1308
|
+
* `docs/adoption-inventory.md` at 672f6265, pinned by its
|
|
1309
|
+
* `test/store/store-portability.test.ts`) states that `config.json` and
|
|
1310
|
+
* `state.json` record no path, folder or file identity, or host identity,
|
|
1311
|
+
* and that a byte copy of both taken under the store's save locks opens
|
|
1312
|
+
* elsewhere as the same pool. Lock and sidecar files are not part of that
|
|
1313
|
+
* contract, and neither is any in-memory state kept by the process that
|
|
1314
|
+
* opened the old files; nothing here relies on either.
|
|
1315
|
+
*/
|
|
1316
|
+
function verifyRelocationSnapshot(backup, journal) {
|
|
1317
|
+
const source = journal.relocation?.source;
|
|
1318
|
+
if (source === undefined)
|
|
1319
|
+
throw new AccountMigrationError('moved source lacks its relocation evidence');
|
|
1320
|
+
const snapshot = decodeRelocationSnapshot(jsonBytes(backup));
|
|
1321
|
+
if (digest(snapshot.config) !== source.configSha256 ||
|
|
1322
|
+
(snapshot.state !== undefined) !== source.statePresent ||
|
|
1323
|
+
(snapshot.state !== undefined &&
|
|
1324
|
+
digest(snapshot.state) !== source.stateSha256))
|
|
1325
|
+
throw new AccountMigrationError('moved source snapshot differs from its recorded hashes');
|
|
1326
|
+
const copied = decodeAccountMigrationJournal(jsonBytes(snapshot.journal));
|
|
1327
|
+
if (copied.id !== source.journalId ||
|
|
1328
|
+
copied.legacyPath !== source.legacyPath ||
|
|
1329
|
+
copied.storeDir !== source.storeDir ||
|
|
1330
|
+
copied.status !== 'active')
|
|
1331
|
+
throw new AccountMigrationError('moved source snapshot is not the active source generation');
|
|
1332
|
+
const config = object(jsonBytes(snapshot.config), 'moved source config');
|
|
1333
|
+
if (!isDeepStrictEqual(decodeAccountStoreGeneration(config[ACCOUNT_STORE_GENERATION_SETTINGS_KEY]), generationJson(copied)))
|
|
1334
|
+
throw new AccountMigrationError('moved source snapshot belongs to another generation');
|
|
1335
|
+
}
|
|
1073
1336
|
async function synchronizedSnapshot(paths, boundary) {
|
|
1074
1337
|
await syncFile(paths.configPath);
|
|
1075
1338
|
const statePresent = await exists(paths.statePath);
|
|
@@ -1118,7 +1381,9 @@ async function assertStateOwnership(paths, journal, complete) {
|
|
|
1118
1381
|
throw new AccountMigrationError('successor state contains unrelated or missing credentials');
|
|
1119
1382
|
}
|
|
1120
1383
|
async function management(store, journal, clear = false) {
|
|
1121
|
-
const targets = journal.
|
|
1384
|
+
const targets = journal.relocation?.rows ??
|
|
1385
|
+
journal.rollback?.rows ??
|
|
1386
|
+
journal.mapping.map((row) => row.id);
|
|
1122
1387
|
await store.updateSettings((settings) => {
|
|
1123
1388
|
const prior = settings[MANAGEMENT_SETTINGS_KEY];
|
|
1124
1389
|
if (prior !== undefined) {
|
|
@@ -1153,7 +1418,7 @@ function receipt(journal) {
|
|
|
1153
1418
|
storeDir: journal.storeDir,
|
|
1154
1419
|
sourceKind: journal.sourceKind,
|
|
1155
1420
|
durability: journal.verification?.durability ?? accountMigrationDurability(),
|
|
1156
|
-
restartRequired: journal.sourceKind
|
|
1421
|
+
restartRequired: journal.sourceKind !== 'absent' || journal.operation === 'rollback',
|
|
1157
1422
|
};
|
|
1158
1423
|
}
|
|
1159
1424
|
async function currentSuccessor(store, paths, journal) {
|
|
@@ -1246,40 +1511,18 @@ async function trustedCurrentGeneration(store, paths, journal) {
|
|
|
1246
1511
|
/** A bound result establishes completed-generation ownership for repository recovery, not readiness to select or route credentials. */
|
|
1247
1512
|
export async function readAccountStoreBinding(legacyPath, modules, now, expectedGeneration) {
|
|
1248
1513
|
try {
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
if (
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
if (journal.status === 'pending')
|
|
1260
|
-
return {
|
|
1261
|
-
status: 'pending',
|
|
1262
|
-
phase: journal.phase,
|
|
1263
|
-
operation: journal.operation,
|
|
1264
|
-
};
|
|
1265
|
-
if (journal.status === 'inactive')
|
|
1266
|
-
return { status: 'inactive', id: journal.id };
|
|
1267
|
-
if (await exists(legacyPath))
|
|
1268
|
-
throw new AccountMigrationError('legacy source was recreated after activation');
|
|
1269
|
-
await verifyCopies(paths, journal, true);
|
|
1270
|
-
const settings = await trustedCurrentGeneration(openAdmissionStore(modules, paths, now), paths, journal);
|
|
1271
|
-
const pending = settings[MANAGEMENT_SETTINGS_KEY];
|
|
1272
|
-
if (pending !== undefined) {
|
|
1273
|
-
const record = object(pending, 'repository management');
|
|
1274
|
-
if (record.kind === 'migration')
|
|
1275
|
-
return { status: 'pending', phase: 'activate', operation: 'migrate' };
|
|
1276
|
-
if (record.kind !== 'clear' && record.kind !== 'replace-pool')
|
|
1277
|
-
throw new AccountMigrationError('unknown management owner prevents binding');
|
|
1278
|
-
// Ownership permits opening these physical paths. The repository must then
|
|
1279
|
-
// validate and resume its own strict clear/replace journal; this binding
|
|
1280
|
-
// neither validates that operation nor grants credential-serving readiness.
|
|
1514
|
+
// A forward at this anchor leads one step to the anchor the store moved
|
|
1515
|
+
// to; the bound paths are then the moved generation's own.
|
|
1516
|
+
const record = await readPointerRecord(legacyPath);
|
|
1517
|
+
const restored = await restoredRelocationAnchor(legacyPath, record);
|
|
1518
|
+
if (restored !== undefined)
|
|
1519
|
+
return await bindPublishedGeneration(restored, modules, now, expectedGeneration);
|
|
1520
|
+
if (record?.schemaVersion === 2) {
|
|
1521
|
+
if (await exists(legacyPath))
|
|
1522
|
+
throw new AccountMigrationError(`a pool file reappeared at ${legacyPath} after the account store moved to ${record.relocatedTo.legacyPath}`);
|
|
1523
|
+
return await bindPublishedGeneration(await forwardTarget(legacyPath, record), modules, now, expectedGeneration, legacyPath);
|
|
1281
1524
|
}
|
|
1282
|
-
return
|
|
1525
|
+
return await bindPublishedGeneration(legacyPath, modules, now, expectedGeneration);
|
|
1283
1526
|
}
|
|
1284
1527
|
catch (error) {
|
|
1285
1528
|
return {
|
|
@@ -1290,6 +1533,84 @@ export async function readAccountStoreBinding(legacyPath, modules, now, expected
|
|
|
1290
1533
|
};
|
|
1291
1534
|
}
|
|
1292
1535
|
}
|
|
1536
|
+
/**
|
|
1537
|
+
* A moved generation binds only while the old anchor still forwards to
|
|
1538
|
+
* exactly it. A pointer set back to schema 1 there, a missing forward, or a
|
|
1539
|
+
* forward to another generation refuses: either store could then be the
|
|
1540
|
+
* one a build writes to, and neither is chosen by age.
|
|
1541
|
+
*/
|
|
1542
|
+
async function assertSourceForwarded(journal, forwardedFrom) {
|
|
1543
|
+
const source = journal.relocation?.source;
|
|
1544
|
+
if (source === undefined)
|
|
1545
|
+
throw new AccountMigrationError('moved source lacks its relocation evidence');
|
|
1546
|
+
const pointer = accountStorePointerPath(source.legacyPath);
|
|
1547
|
+
if (forwardedFrom !== undefined && forwardedFrom !== source.legacyPath)
|
|
1548
|
+
throw new AccountMigrationError(`the forwarding pointer ${accountStorePointerPath(forwardedFrom)} leads to a store moved from ${source.legacyPath}`);
|
|
1549
|
+
if (await exists(source.legacyPath))
|
|
1550
|
+
throw new AccountMigrationError(`a pool file reappeared at ${source.legacyPath} after the account store moved to ${journal.legacyPath}`);
|
|
1551
|
+
const record = await readPointerRecord(source.legacyPath);
|
|
1552
|
+
if (record === undefined)
|
|
1553
|
+
throw new AccountMigrationError(`the forwarding pointer ${pointer} to the moved account store at ${journal.storeDir} is missing; restore it`);
|
|
1554
|
+
if (record.schemaVersion === 1)
|
|
1555
|
+
throw new AccountMigrationError(`the account store in ${dirname(source.legacyPath)} was reactivated: ${pointer} publishes generation ${record.id} while the moved store at ${journal.storeDir} is active; restore the forwarding pointer`);
|
|
1556
|
+
if (record.id !== source.journalId ||
|
|
1557
|
+
record.relocatedTo.legacyPath !== journal.legacyPath ||
|
|
1558
|
+
record.relocatedTo.id !== journal.id)
|
|
1559
|
+
throw new AccountMigrationError(`the forwarding pointer ${pointer} names another store than the moved account store at ${journal.storeDir}`);
|
|
1560
|
+
}
|
|
1561
|
+
async function bindPublishedGeneration(legacyPath, modules, now, expectedGeneration, forwardedFrom) {
|
|
1562
|
+
const paths = await resolvePublishedAccountStorePaths(legacyPath);
|
|
1563
|
+
if (!paths) {
|
|
1564
|
+
if (forwardedFrom !== undefined)
|
|
1565
|
+
throw new AccountMigrationError('forwarded anchor publishes no generation');
|
|
1566
|
+
return (await exists(legacyPath))
|
|
1567
|
+
? { status: 'pending' }
|
|
1568
|
+
: { status: 'initialization-required' };
|
|
1569
|
+
}
|
|
1570
|
+
const journal = await readJournal(paths);
|
|
1571
|
+
if (!journal)
|
|
1572
|
+
throw new AccountMigrationError('published binding journal missing');
|
|
1573
|
+
if (expectedGeneration !== undefined && journal.id !== expectedGeneration)
|
|
1574
|
+
throw new AccountMigrationError('bound repository generation differs from pointer');
|
|
1575
|
+
if (forwardedFrom !== undefined && journal.sourceKind !== 'generation')
|
|
1576
|
+
throw new AccountMigrationError(`the forwarding pointer ${accountStorePointerPath(forwardedFrom)} leads to a store that was not moved there`);
|
|
1577
|
+
// An unfinished move is finished by the build that started it; this build
|
|
1578
|
+
// neither resumes it nor serves either store meanwhile.
|
|
1579
|
+
if (journal.sourceKind === 'generation' && journal.status === 'pending')
|
|
1580
|
+
throw new AccountMigrationError(`a move of the account store to ${journal.storeDir} is unfinished; finish it with the version that started it`);
|
|
1581
|
+
if (journal.status === 'pending')
|
|
1582
|
+
return {
|
|
1583
|
+
status: 'pending',
|
|
1584
|
+
phase: journal.phase,
|
|
1585
|
+
operation: journal.operation,
|
|
1586
|
+
};
|
|
1587
|
+
if (journal.status === 'inactive')
|
|
1588
|
+
return { status: 'inactive', id: journal.id };
|
|
1589
|
+
if (await exists(legacyPath))
|
|
1590
|
+
throw new AccountMigrationError('legacy source was recreated after activation');
|
|
1591
|
+
await verifyCopies(paths, journal, true);
|
|
1592
|
+
if (journal.sourceKind === 'generation')
|
|
1593
|
+
await assertSourceForwarded(journal, forwardedFrom);
|
|
1594
|
+
const settings = await trustedCurrentGeneration(openAdmissionStore(modules, paths, now), paths, journal);
|
|
1595
|
+
const pending = settings[MANAGEMENT_SETTINGS_KEY];
|
|
1596
|
+
if (pending !== undefined) {
|
|
1597
|
+
const record = object(pending, 'repository management');
|
|
1598
|
+
// Only a store move writes a migration record owned by another
|
|
1599
|
+
// generation: it blocks the old generation once the copy is taken.
|
|
1600
|
+
// Reaching it here means this anchor's pointer was set back from the
|
|
1601
|
+
// forward to the old generation.
|
|
1602
|
+
if (record.kind === 'migration' && record.id !== journal.id)
|
|
1603
|
+
throw new AccountMigrationError(`the account store at ${paths.storeDir} was moved to generation ${String(record.id)}, but ${accountStorePointerPath(legacyPath)} publishes the old store again; restore the forwarding pointer`);
|
|
1604
|
+
if (record.kind === 'migration')
|
|
1605
|
+
return { status: 'pending', phase: 'activate', operation: 'migrate' };
|
|
1606
|
+
if (record.kind !== 'clear' && record.kind !== 'replace-pool')
|
|
1607
|
+
throw new AccountMigrationError('unknown management owner prevents binding');
|
|
1608
|
+
// Ownership permits opening these physical paths. The repository must then
|
|
1609
|
+
// validate and resume its own strict clear/replace journal; this binding
|
|
1610
|
+
// neither validates that operation nor grants credential-serving readiness.
|
|
1611
|
+
}
|
|
1612
|
+
return { status: 'bound', receipt: receipt(journal), paths };
|
|
1613
|
+
}
|
|
1293
1614
|
/** Serving requires healthy current rows and no repository management work. Binding alone is insufficient. */
|
|
1294
1615
|
export async function readAccountStoreAdmission(legacyPath, modules, now, expectedGeneration) {
|
|
1295
1616
|
const binding = await readAccountStoreBinding(legacyPath, modules, now, expectedGeneration);
|
|
@@ -1623,6 +1944,8 @@ export function createAccountMigrationFactory(modules) {
|
|
|
1623
1944
|
const existing = await readJournal(paths);
|
|
1624
1945
|
if (!existing)
|
|
1625
1946
|
throw new AccountMigrationError('published generation journal missing');
|
|
1947
|
+
if (existing.sourceKind === 'generation')
|
|
1948
|
+
throw new AccountMigrationError('a moved account store is finished by `antigravity-auth store relocate`, not by migrate');
|
|
1626
1949
|
if (existing.status === 'inactive') {
|
|
1627
1950
|
previous = existing;
|
|
1628
1951
|
await modules.fs.withLock(paths.configPath, {
|
|
@@ -1724,6 +2047,695 @@ export function createAccountMigrationFactory(modules) {
|
|
|
1724
2047
|
}
|
|
1725
2048
|
};
|
|
1726
2049
|
}
|
|
2050
|
+
function relocationOf(journal) {
|
|
2051
|
+
if (!journal.relocation)
|
|
2052
|
+
throw new AccountMigrationError('relocation evidence missing');
|
|
2053
|
+
return journal.relocation;
|
|
2054
|
+
}
|
|
2055
|
+
function withGenerationLease(modules, path, name, body) {
|
|
2056
|
+
return modules.fs.withLock(path, { name, ttlMs: 10_000, timeoutMs: 2_000, renew: true }, body);
|
|
2057
|
+
}
|
|
2058
|
+
function withSaveLocks(modules, paths, body) {
|
|
2059
|
+
return withGenerationLease(modules, paths.configPath, 'save', async (configLease) => withGenerationLease(modules, paths.statePath, 'save', async (stateLease) => {
|
|
2060
|
+
const result = await body([configLease, stateLease]);
|
|
2061
|
+
await configLease.assertOwned();
|
|
2062
|
+
await stateLease.assertOwned();
|
|
2063
|
+
return result;
|
|
2064
|
+
}));
|
|
2065
|
+
}
|
|
2066
|
+
async function relocationSnapshot(paths, journal) {
|
|
2067
|
+
await verifyCopies(paths, journal, false);
|
|
2068
|
+
return decodeRelocationSnapshot(jsonBytes(await secureRead(join(paths.backupsDir, `${journal.sourceSha256}.json`))));
|
|
2069
|
+
}
|
|
2070
|
+
function withoutManagement(value) {
|
|
2071
|
+
const config = object(jsonBytes(value), 'source config');
|
|
2072
|
+
delete config[MANAGEMENT_SETTINGS_KEY];
|
|
2073
|
+
return config;
|
|
2074
|
+
}
|
|
2075
|
+
async function sourceMatches(modules, source, snapshot, journal) {
|
|
2076
|
+
return withSaveLocks(modules, source, async () => {
|
|
2077
|
+
const current = await synchronizedSnapshot(source);
|
|
2078
|
+
const config = object(jsonBytes(current.config), 'source config');
|
|
2079
|
+
const pending = config[MANAGEMENT_SETTINGS_KEY];
|
|
2080
|
+
if (pending !== undefined &&
|
|
2081
|
+
!isDeepStrictEqual(pending, sourceTombstone(journal)))
|
|
2082
|
+
throw new AccountMigrationError('source management record belongs to another operation');
|
|
2083
|
+
return (isDeepStrictEqual(withoutManagement(current.config), withoutManagement(snapshot.config)) &&
|
|
2084
|
+
(current.state === undefined
|
|
2085
|
+
? snapshot.state === undefined
|
|
2086
|
+
: snapshot.state !== undefined && current.state.equals(snapshot.state)));
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
function sourceTombstone(journal) {
|
|
2090
|
+
return {
|
|
2091
|
+
id: journal.id,
|
|
2092
|
+
kind: 'migration',
|
|
2093
|
+
targets: relocationOf(journal).rows,
|
|
2094
|
+
progress: { step: 'activate', completedTargets: [] },
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
async function captureRelocation(modules, source, paths, lease, options) {
|
|
2098
|
+
const captured = await withSaveLocks(modules, source, async () => {
|
|
2099
|
+
const files = await synchronizedSnapshot(source, options.onBoundary);
|
|
2100
|
+
return { ...files, journal: await secureRead(source.migrationPath) };
|
|
2101
|
+
});
|
|
2102
|
+
const sourceJournal = decodeAccountMigrationJournal(jsonBytes(captured.journal));
|
|
2103
|
+
if (sourceJournal.status !== 'active')
|
|
2104
|
+
throw new AccountMigrationError('relocation requires an active source');
|
|
2105
|
+
const config = object(jsonBytes(captured.config), 'source config');
|
|
2106
|
+
if (config[MANAGEMENT_SETTINGS_KEY] !== undefined)
|
|
2107
|
+
throw new AccountMigrationError('source management is pending');
|
|
2108
|
+
const sourceStore = openAdmissionStore(modules, source, options.now);
|
|
2109
|
+
const rows = await rowsOf(sourceStore);
|
|
2110
|
+
const bytes = Buffer.from(JSON.stringify({
|
|
2111
|
+
config: captured.config.toString('base64'),
|
|
2112
|
+
...(captured.state === undefined
|
|
2113
|
+
? {}
|
|
2114
|
+
: { state: captured.state.toString('base64') }),
|
|
2115
|
+
journal: captured.journal.toString('base64'),
|
|
2116
|
+
}));
|
|
2117
|
+
const journal = {
|
|
2118
|
+
schemaVersion: 1,
|
|
2119
|
+
id: pathGenerationId(paths),
|
|
2120
|
+
legacyPath: paths.legacyPath,
|
|
2121
|
+
storeDir: paths.storeDir,
|
|
2122
|
+
status: 'pending',
|
|
2123
|
+
operation: 'migrate',
|
|
2124
|
+
phase: 'capture',
|
|
2125
|
+
sourceKind: 'generation',
|
|
2126
|
+
sourceSha256: digest(bytes),
|
|
2127
|
+
manifest: {
|
|
2128
|
+
sourceVersion: null,
|
|
2129
|
+
normalizationClock: options.now(),
|
|
2130
|
+
accounts: [],
|
|
2131
|
+
routing: { schemaVersion: 1 },
|
|
2132
|
+
effectiveActiveIndex: 0,
|
|
2133
|
+
},
|
|
2134
|
+
mapping: [],
|
|
2135
|
+
completedRows: [],
|
|
2136
|
+
relocation: {
|
|
2137
|
+
source: {
|
|
2138
|
+
legacyPath: source.legacyPath,
|
|
2139
|
+
storeDir: source.storeDir,
|
|
2140
|
+
journalId: sourceJournal.id,
|
|
2141
|
+
configSha256: digest(captured.config),
|
|
2142
|
+
statePresent: captured.state !== undefined,
|
|
2143
|
+
...(captured.state === undefined
|
|
2144
|
+
? {}
|
|
2145
|
+
: { stateSha256: digest(captured.state) }),
|
|
2146
|
+
},
|
|
2147
|
+
rows: rows.map((row) => row.id),
|
|
2148
|
+
sourceTombstoned: false,
|
|
2149
|
+
sourceForwarded: false,
|
|
2150
|
+
},
|
|
2151
|
+
};
|
|
2152
|
+
await exclusiveBytes(join(paths.backupsDir, `${journal.sourceSha256}.json`), bytes, digest(bytes), options.onBoundary);
|
|
2153
|
+
await options.onBoundary?.('relocate:capture:after-backup');
|
|
2154
|
+
const capture = Buffer.from(`${JSON.stringify(captureJson(journal), null, 2)}\n`);
|
|
2155
|
+
journal.captureSha256 = digest(capture);
|
|
2156
|
+
await exclusiveBytes(join(paths.transfersDir, `${journal.id}.capture.json`), capture, digest(capture), options.onBoundary);
|
|
2157
|
+
await saveJournal(modules, paths, lease, journal, true, options.onBoundary);
|
|
2158
|
+
return journal;
|
|
2159
|
+
}
|
|
2160
|
+
async function buildRelocation(store, paths, snapshot, journal, options) {
|
|
2161
|
+
const target = generationJson(journal);
|
|
2162
|
+
const buildManagement = {
|
|
2163
|
+
id: journal.id,
|
|
2164
|
+
kind: 'migration',
|
|
2165
|
+
targets: relocationOf(journal).rows,
|
|
2166
|
+
progress: { step: 'build' },
|
|
2167
|
+
};
|
|
2168
|
+
const source = object(jsonBytes(snapshot.config), 'snapshot config')[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
|
|
2169
|
+
// A crash after updateSettings leaves a rewritten config, not the original clone.
|
|
2170
|
+
// Accept it only after proving its entire native config differs solely in our two keys.
|
|
2171
|
+
let rewritten = false;
|
|
2172
|
+
if (await exists(paths.configPath)) {
|
|
2173
|
+
const current = object(jsonBytes(await secureRead(paths.configPath)), 'build config');
|
|
2174
|
+
rewritten = isDeepStrictEqual(current[ACCOUNT_STORE_GENERATION_SETTINGS_KEY], target);
|
|
2175
|
+
if (rewritten) {
|
|
2176
|
+
const expected = object(jsonBytes(snapshot.config), 'snapshot config');
|
|
2177
|
+
expected[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] = target;
|
|
2178
|
+
expected[MANAGEMENT_SETTINGS_KEY] = buildManagement;
|
|
2179
|
+
if (!isDeepStrictEqual(current, expected))
|
|
2180
|
+
throw new AccountMigrationError('partial clone config differs from snapshot');
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
if (snapshot.state !== undefined)
|
|
2184
|
+
await exclusiveBytes(paths.statePath, snapshot.state, digest(snapshot.state), options.onBoundary);
|
|
2185
|
+
await options.onBoundary?.('relocate:build:after-state');
|
|
2186
|
+
if (!rewritten)
|
|
2187
|
+
await exclusiveBytes(paths.configPath, snapshot.config, digest(snapshot.config), options.onBoundary);
|
|
2188
|
+
await options.onBoundary?.('relocate:build:after-config');
|
|
2189
|
+
await store.updateSettings((settings) => {
|
|
2190
|
+
const prior = settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
|
|
2191
|
+
if (!isDeepStrictEqual(prior, source) && !isDeepStrictEqual(prior, target))
|
|
2192
|
+
throw new AccountMigrationError('clone generation differs from source and target');
|
|
2193
|
+
const management = settings[MANAGEMENT_SETTINGS_KEY];
|
|
2194
|
+
if (management !== undefined &&
|
|
2195
|
+
!isDeepStrictEqual(management, buildManagement))
|
|
2196
|
+
throw new AccountMigrationError('clone management belongs to another operation');
|
|
2197
|
+
settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] = target;
|
|
2198
|
+
settings[MANAGEMENT_SETTINGS_KEY] = buildManagement;
|
|
2199
|
+
return settings;
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
async function cancelRelocation(modules, sourceStore, paths, lease, journal, options) {
|
|
2203
|
+
const relocation = relocationOf(journal);
|
|
2204
|
+
const pointer = await readPointerRecord(relocation.source.legacyPath);
|
|
2205
|
+
if (relocation.sourceForwarded ||
|
|
2206
|
+
pointer?.schemaVersion !== 1 ||
|
|
2207
|
+
pointer.id !== relocation.source.journalId)
|
|
2208
|
+
throw new AccountMigrationError('nothing cancels after the source forward');
|
|
2209
|
+
await sourceStore.updateSettings((settings) => {
|
|
2210
|
+
const prior = settings[MANAGEMENT_SETTINGS_KEY];
|
|
2211
|
+
if (prior !== undefined) {
|
|
2212
|
+
if (!isDeepStrictEqual(prior, sourceTombstone(journal)))
|
|
2213
|
+
throw new AccountMigrationError("cancellation cannot remove another operation's tombstone");
|
|
2214
|
+
delete settings[MANAGEMENT_SETTINGS_KEY];
|
|
2215
|
+
}
|
|
2216
|
+
return settings;
|
|
2217
|
+
});
|
|
2218
|
+
relocation.sourceTombstoned = false;
|
|
2219
|
+
journal.status = 'inactive';
|
|
2220
|
+
journal.cancelled = true;
|
|
2221
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
2222
|
+
return { status: 'cancelled', id: journal.id };
|
|
2223
|
+
}
|
|
2224
|
+
async function replaceRelocationPointer(modules, anchor, prior, next, id, leases, boundary, prefix = 'relocate:forward') {
|
|
2225
|
+
const path = accountStorePointerPath(anchor);
|
|
2226
|
+
const stage = `${path}.${id}.tmp`;
|
|
2227
|
+
const assertOwned = async () => {
|
|
2228
|
+
for (const lease of leases)
|
|
2229
|
+
await lease.assertOwned();
|
|
2230
|
+
};
|
|
2231
|
+
if (await exists(stage)) {
|
|
2232
|
+
if (!isDeepStrictEqual(jsonBytes(await secureRead(stage)), next))
|
|
2233
|
+
throw new AccountMigrationError('foreign relocation pointer stage');
|
|
2234
|
+
await assertOwned();
|
|
2235
|
+
await unlink(stage);
|
|
2236
|
+
}
|
|
2237
|
+
const current = await readPointerRecord(anchor);
|
|
2238
|
+
if (!isDeepStrictEqual(current, next)) {
|
|
2239
|
+
if (!isDeepStrictEqual(current, prior))
|
|
2240
|
+
throw new AccountMigrationError('source pointer changed before relocation');
|
|
2241
|
+
await modules.fs.writeJsonAtomic(path, next, {
|
|
2242
|
+
stageName: () => id,
|
|
2243
|
+
beforeRename: async () => {
|
|
2244
|
+
await assertOwned();
|
|
2245
|
+
if (!isDeepStrictEqual(await readPointerRecord(anchor), prior))
|
|
2246
|
+
throw new AccountMigrationError('source pointer changed before rename');
|
|
2247
|
+
await syncFile(stage);
|
|
2248
|
+
await boundary?.(`${prefix}:before-rename`);
|
|
2249
|
+
},
|
|
2250
|
+
});
|
|
2251
|
+
await boundary?.(`${prefix}:after-rename-before-sync`);
|
|
2252
|
+
}
|
|
2253
|
+
// Also finish these barriers when recovering a rename that preceded a crash.
|
|
2254
|
+
await syncFile(path);
|
|
2255
|
+
await syncDirectory(dirname(anchor));
|
|
2256
|
+
if (!isDeepStrictEqual(await readPointerRecord(anchor), next))
|
|
2257
|
+
throw new AccountMigrationError('relocation pointer readback differs');
|
|
2258
|
+
await assertOwned();
|
|
2259
|
+
}
|
|
2260
|
+
/** Offline relocation only. The host must prevent forward and reverse relocations from running concurrently. */
|
|
2261
|
+
export function createAccountRelocationFactory(modules) {
|
|
2262
|
+
return async (options) => {
|
|
2263
|
+
if (options.offline?.processesStopped !== true)
|
|
2264
|
+
throw new AccountMigrationError('stopped processes and timers must be explicitly confirmed');
|
|
2265
|
+
resolveAccountStorePaths(options.legacyPath);
|
|
2266
|
+
resolveAccountStorePaths(options.targetPath);
|
|
2267
|
+
if (sameFilesystemPath(options.legacyPath, options.targetPath))
|
|
2268
|
+
throw new AccountMigrationError('relocation requires distinct anchors');
|
|
2269
|
+
try {
|
|
2270
|
+
return await withParentTopology(modules, options.legacyPath, (sourceParent) => withParentTopology(modules, options.targetPath, async (targetParent) => {
|
|
2271
|
+
if (options.back)
|
|
2272
|
+
return reverseRelocation(modules, options, sourceParent, targetParent);
|
|
2273
|
+
const sourceRecord = await readPointerRecord(options.legacyPath);
|
|
2274
|
+
if (!sourceRecord)
|
|
2275
|
+
throw new AccountMigrationError('relocation source pointer missing');
|
|
2276
|
+
if (sourceRecord.schemaVersion === 1)
|
|
2277
|
+
await finishPointerPublication(options.legacyPath, sourceRecord, sourceParent);
|
|
2278
|
+
const source = generationPaths(options.legacyPath, sourceRecord.id);
|
|
2279
|
+
if ((await exists(source.legacyPath)) ||
|
|
2280
|
+
(await exists(options.targetPath)))
|
|
2281
|
+
throw new AccountMigrationError('relocation refuses a reappeared pool file');
|
|
2282
|
+
return withGenerationLease(modules, source.configPath, MANAGEMENT_LOCK_NAME, async (sourceLease) => withGenerationLease(modules, await realpath(source.configPath), 'antigravity-topology', async (topologyLease) => {
|
|
2283
|
+
const sourceJournal = await readJournal(source);
|
|
2284
|
+
if (!sourceJournal)
|
|
2285
|
+
throw new AccountMigrationError('relocation source journal missing');
|
|
2286
|
+
const prior = await readPointer(options.targetPath);
|
|
2287
|
+
if (prior)
|
|
2288
|
+
await finishPointerPublication(options.targetPath, prior, targetParent);
|
|
2289
|
+
let paths = prior
|
|
2290
|
+
? generationPaths(options.targetPath, prior.id)
|
|
2291
|
+
: undefined;
|
|
2292
|
+
let existing = paths
|
|
2293
|
+
? await readJournal(paths)
|
|
2294
|
+
: undefined;
|
|
2295
|
+
if (prior &&
|
|
2296
|
+
(existing?.sourceKind !== 'generation' ||
|
|
2297
|
+
relocationOf(existing).source.legacyPath !==
|
|
2298
|
+
source.legacyPath ||
|
|
2299
|
+
relocationOf(existing).source.journalId !==
|
|
2300
|
+
sourceJournal.id))
|
|
2301
|
+
throw new AccountMigrationError('destination belongs to another source');
|
|
2302
|
+
if (sourceJournal.status !== 'active' &&
|
|
2303
|
+
!(existing &&
|
|
2304
|
+
sourceJournal.operation === 'rollback' &&
|
|
2305
|
+
isDeepStrictEqual((await settingsOf(openAdmissionStore(modules, source, options.now)))[MANAGEMENT_SETTINGS_KEY], sourceTombstone(existing))))
|
|
2306
|
+
throw new AccountMigrationError('relocation requires an active source journal or its own tombstone');
|
|
2307
|
+
if (existing?.relocation?.reversed &&
|
|
2308
|
+
(await restoredRelocationAnchor(options.targetPath, prior)) !== source.legacyPath)
|
|
2309
|
+
throw new AccountMigrationError('finish the reverse relocation before moving forward again');
|
|
2310
|
+
if (existing?.status === 'active' &&
|
|
2311
|
+
!existing.relocation?.reversed)
|
|
2312
|
+
await assertSourceForwarded(existing, undefined);
|
|
2313
|
+
if ((existing?.cancelled &&
|
|
2314
|
+
existing.status === 'inactive') ||
|
|
2315
|
+
existing?.relocation?.reversed) {
|
|
2316
|
+
paths = undefined;
|
|
2317
|
+
existing = undefined;
|
|
2318
|
+
}
|
|
2319
|
+
if (!paths) {
|
|
2320
|
+
if (sourceRecord.schemaVersion !== 1)
|
|
2321
|
+
throw new AccountMigrationError('forwarded source has no resumable destination');
|
|
2322
|
+
await currentSuccessor(openAdmissionStore(modules, source, options.now), source, sourceJournal);
|
|
2323
|
+
await verifyCopies(source, sourceJournal, true);
|
|
2324
|
+
if (sourceJournal.sourceKind === 'generation')
|
|
2325
|
+
await assertSourceForwarded(sourceJournal, undefined);
|
|
2326
|
+
await options.onBoundary?.('relocate:before-capture');
|
|
2327
|
+
paths = await allocateGeneration(options.targetPath);
|
|
2328
|
+
}
|
|
2329
|
+
const physical = paths;
|
|
2330
|
+
return withGenerationLease(modules, physical.configPath, MANAGEMENT_LOCK_NAME, async (lease) => {
|
|
2331
|
+
let journal = existing;
|
|
2332
|
+
if (!journal) {
|
|
2333
|
+
journal = await captureRelocation(modules, source, physical, lease, options);
|
|
2334
|
+
await publishGenerationPointer(modules, physical, journal, targetParent, lease, prior, options.onBoundary);
|
|
2335
|
+
await options.onBoundary?.('relocate:after-capture');
|
|
2336
|
+
}
|
|
2337
|
+
const relocation = relocationOf(journal);
|
|
2338
|
+
const sourceStore = openMigrationStore(modules, source, options.now, writeObserver(source, sourceLease, (point) => options.onBoundary?.(point.startsWith('public:')
|
|
2339
|
+
? `relocate:source:${point}`
|
|
2340
|
+
: point)));
|
|
2341
|
+
const store = openMigrationStore(modules, physical, options.now, writeObserver(physical, lease, options.onBoundary));
|
|
2342
|
+
const save = async () => {
|
|
2343
|
+
await sourceParent.assertOwned();
|
|
2344
|
+
await targetParent.assertOwned();
|
|
2345
|
+
await topologyLease.assertOwned();
|
|
2346
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
2347
|
+
};
|
|
2348
|
+
const forward = {
|
|
2349
|
+
schemaVersion: 2,
|
|
2350
|
+
id: sourceJournal.id,
|
|
2351
|
+
directoryBasename: basename(source.storeDir),
|
|
2352
|
+
relocatedTo: {
|
|
2353
|
+
legacyPath: physical.legacyPath,
|
|
2354
|
+
id: journal.id,
|
|
2355
|
+
},
|
|
2356
|
+
};
|
|
2357
|
+
const current = await readPointerRecord(source.legacyPath);
|
|
2358
|
+
if (isDeepStrictEqual(current, forward)) {
|
|
2359
|
+
// The rename may have completed without its journal acknowledgment.
|
|
2360
|
+
// Finish its barriers before recording the point of no return.
|
|
2361
|
+
await replaceRelocationPointer(modules, source.legacyPath, forward, forward, journal.id, [sourceParent, sourceLease, topologyLease], options.onBoundary);
|
|
2362
|
+
relocation.sourceForwarded = true;
|
|
2363
|
+
relocation.sourceTombstoned = true;
|
|
2364
|
+
journal.phase = 'activate';
|
|
2365
|
+
await save();
|
|
2366
|
+
}
|
|
2367
|
+
else if (current?.schemaVersion !== 1 ||
|
|
2368
|
+
current.id !== sourceJournal.id ||
|
|
2369
|
+
relocation.sourceForwarded) {
|
|
2370
|
+
throw new AccountMigrationError('source forward differs from relocation evidence');
|
|
2371
|
+
}
|
|
2372
|
+
if (options.cancel)
|
|
2373
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2374
|
+
if (journal.status === 'active') {
|
|
2375
|
+
await assertSourceForwarded(journal, undefined);
|
|
2376
|
+
await verifyCopies(physical, journal, true);
|
|
2377
|
+
await currentSuccessor(store, physical, journal);
|
|
2378
|
+
await management(store, journal, true);
|
|
2379
|
+
await cleanupPointerStage(physical.legacyPath, {
|
|
2380
|
+
schemaVersion: 1,
|
|
2381
|
+
id: journal.id,
|
|
2382
|
+
directoryBasename: basename(physical.storeDir),
|
|
2383
|
+
}, targetParent);
|
|
2384
|
+
return {
|
|
2385
|
+
status: 'completed',
|
|
2386
|
+
receipt: receipt(journal),
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
const snapshot = await relocationSnapshot(physical, journal);
|
|
2390
|
+
if (!relocation.sourceForwarded &&
|
|
2391
|
+
!(await sourceMatches(modules, source, snapshot, journal)))
|
|
2392
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2393
|
+
if (journal.phase === 'capture' ||
|
|
2394
|
+
journal.phase === 'build') {
|
|
2395
|
+
journal.phase = 'build';
|
|
2396
|
+
await save();
|
|
2397
|
+
await buildRelocation(store, physical, snapshot, journal, options);
|
|
2398
|
+
journal.phase = 'verify';
|
|
2399
|
+
await save();
|
|
2400
|
+
await options.onBoundary?.('relocate:after-build');
|
|
2401
|
+
}
|
|
2402
|
+
if (journal.phase === 'verify') {
|
|
2403
|
+
await options.onBoundary?.('relocate:before-verify');
|
|
2404
|
+
await currentSuccessor(store, physical, journal);
|
|
2405
|
+
const sourceRows = await rowsOf(openAdmissionStore(modules, source, options.now));
|
|
2406
|
+
if (!isDeepStrictEqual(sourceRows, await rowsOf(store)) ||
|
|
2407
|
+
!isDeepStrictEqual(sourceRows.map((row) => row.id), relocation.rows))
|
|
2408
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2409
|
+
const strip = (settings) => {
|
|
2410
|
+
const copy = { ...settings };
|
|
2411
|
+
delete copy[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
|
|
2412
|
+
delete copy[MANAGEMENT_SETTINGS_KEY];
|
|
2413
|
+
return copy;
|
|
2414
|
+
};
|
|
2415
|
+
if (!isDeepStrictEqual(strip(await settingsOf(store)), strip(await settingsOf(sourceStore))))
|
|
2416
|
+
throw new AccountMigrationError('cloned settings differ from source');
|
|
2417
|
+
const verified = await synchronizedSnapshot(physical, options.onBoundary);
|
|
2418
|
+
journal.verification = {
|
|
2419
|
+
manifestSha256: digest(canonical(manifestJson(journal.manifest))),
|
|
2420
|
+
configSha256: digest(verified.config),
|
|
2421
|
+
statePresent: verified.state !== undefined,
|
|
2422
|
+
...(verified.state === undefined
|
|
2423
|
+
? {}
|
|
2424
|
+
: { stateSha256: digest(verified.state) }),
|
|
2425
|
+
durability: accountMigrationDurability(),
|
|
2426
|
+
};
|
|
2427
|
+
if (!(await sourceMatches(modules, source, snapshot, journal)))
|
|
2428
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2429
|
+
journal.phase = 'retire';
|
|
2430
|
+
await save();
|
|
2431
|
+
await options.onBoundary?.('relocate:after-verify');
|
|
2432
|
+
}
|
|
2433
|
+
if (journal.phase === 'retire') {
|
|
2434
|
+
await sourceStore.updateSettings((settings) => {
|
|
2435
|
+
if (!isDeepStrictEqual(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY], generationJson(sourceJournal)))
|
|
2436
|
+
throw new AccountMigrationError('source generation changed before tombstone');
|
|
2437
|
+
const prior = settings[MANAGEMENT_SETTINGS_KEY];
|
|
2438
|
+
if (prior !== undefined &&
|
|
2439
|
+
!isDeepStrictEqual(prior, sourceTombstone(journal)))
|
|
2440
|
+
throw new AccountMigrationError('source management belongs to another operation');
|
|
2441
|
+
settings[MANAGEMENT_SETTINGS_KEY] =
|
|
2442
|
+
sourceTombstone(journal);
|
|
2443
|
+
return settings;
|
|
2444
|
+
});
|
|
2445
|
+
relocation.sourceTombstoned = true;
|
|
2446
|
+
await save();
|
|
2447
|
+
await options.onBoundary?.('relocate:retire:after-tombstone');
|
|
2448
|
+
const keys = [
|
|
2449
|
+
...new Set((await rowsOf(sourceStore)).map((row) => refreshProviderLock(source.statePath, row)
|
|
2450
|
+
.name)),
|
|
2451
|
+
].sort();
|
|
2452
|
+
const drain = async (index) => {
|
|
2453
|
+
const name = keys[index];
|
|
2454
|
+
if (name === undefined)
|
|
2455
|
+
return sourceMatches(modules, source, snapshot, journal);
|
|
2456
|
+
return modules.fs.withLock(source.statePath, {
|
|
2457
|
+
name,
|
|
2458
|
+
ttlMs: 10_000,
|
|
2459
|
+
timeoutMs: 15_000,
|
|
2460
|
+
renew: true,
|
|
2461
|
+
}, async (refreshLease) => {
|
|
2462
|
+
const matched = await drain(index + 1);
|
|
2463
|
+
await refreshLease.assertOwned();
|
|
2464
|
+
return matched;
|
|
2465
|
+
});
|
|
2466
|
+
};
|
|
2467
|
+
if (!(await drain(0)))
|
|
2468
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2469
|
+
await options.onBoundary?.('relocate:retire:after-drain');
|
|
2470
|
+
await withGenerationLease(modules, source.legacyPath, 'accounts', (accountsLease) => replaceRelocationPointer(modules, source.legacyPath, {
|
|
2471
|
+
schemaVersion: 1,
|
|
2472
|
+
id: sourceJournal.id,
|
|
2473
|
+
directoryBasename: basename(source.storeDir),
|
|
2474
|
+
}, forward, journal.id, [
|
|
2475
|
+
sourceParent,
|
|
2476
|
+
targetParent,
|
|
2477
|
+
sourceLease,
|
|
2478
|
+
topologyLease,
|
|
2479
|
+
lease,
|
|
2480
|
+
accountsLease,
|
|
2481
|
+
], options.onBoundary));
|
|
2482
|
+
relocation.sourceForwarded = true;
|
|
2483
|
+
journal.phase = 'activate';
|
|
2484
|
+
await save();
|
|
2485
|
+
await options.onBoundary?.('relocate:after-retire');
|
|
2486
|
+
}
|
|
2487
|
+
await options.onBoundary?.('relocate:before-activate');
|
|
2488
|
+
await assertSourceForwarded(journal, undefined);
|
|
2489
|
+
await verifyCopies(physical, journal, true);
|
|
2490
|
+
await currentSuccessor(store, physical, journal);
|
|
2491
|
+
journal.status = 'active';
|
|
2492
|
+
await save();
|
|
2493
|
+
await management(store, journal, true);
|
|
2494
|
+
await cleanupPointerStage(physical.legacyPath, {
|
|
2495
|
+
schemaVersion: 1,
|
|
2496
|
+
id: journal.id,
|
|
2497
|
+
directoryBasename: basename(physical.storeDir),
|
|
2498
|
+
}, targetParent);
|
|
2499
|
+
await options.onBoundary?.('relocate:after-activate');
|
|
2500
|
+
return {
|
|
2501
|
+
status: 'completed',
|
|
2502
|
+
receipt: receipt(journal),
|
|
2503
|
+
};
|
|
2504
|
+
});
|
|
2505
|
+
}));
|
|
2506
|
+
}));
|
|
2507
|
+
}
|
|
2508
|
+
catch (error) {
|
|
2509
|
+
const pending = pendingLockFailure(modules, error);
|
|
2510
|
+
if (pending)
|
|
2511
|
+
return pending;
|
|
2512
|
+
throw error;
|
|
2513
|
+
}
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
async function restoredRelocationAnchor(anchor, pointer) {
|
|
2517
|
+
if (pointer?.schemaVersion !== 1)
|
|
2518
|
+
return undefined;
|
|
2519
|
+
const paths = generationPaths(anchor, pointer.id);
|
|
2520
|
+
const journal = await readJournal(paths);
|
|
2521
|
+
if (!journal?.relocation?.reversed)
|
|
2522
|
+
return undefined;
|
|
2523
|
+
const source = journal.relocation.source;
|
|
2524
|
+
const restored = await readPointerRecord(source.legacyPath);
|
|
2525
|
+
if (restored?.schemaVersion !== 1)
|
|
2526
|
+
return undefined;
|
|
2527
|
+
if (restored.id !== source.journalId)
|
|
2528
|
+
throw new AccountMigrationError(`the reversed store at ${paths.storeDir} expects generation ${source.journalId} at ${source.legacyPath}, but that anchor publishes ${restored.id}`);
|
|
2529
|
+
await verifyCopies(paths, journal, true);
|
|
2530
|
+
await reverseEvidence(paths, journal);
|
|
2531
|
+
return source.legacyPath;
|
|
2532
|
+
}
|
|
2533
|
+
async function reverseEvidence(paths, journal) {
|
|
2534
|
+
const relocation = relocationOf(journal);
|
|
2535
|
+
const hash = relocation.reverseSha256;
|
|
2536
|
+
const bytes = await secureRead(join(paths.transfersDir, `${hash}.reverse-snapshot.json`));
|
|
2537
|
+
if (!hash || digest(bytes) !== hash)
|
|
2538
|
+
throw new AccountMigrationError('reverse snapshot digest differs');
|
|
2539
|
+
const evidence = exact(jsonBytes(bytes), ['config', 'state', 'journal', 'sourceId', 'targetId'], 'reverse snapshot');
|
|
2540
|
+
if (evidence.sourceId !== journal.id ||
|
|
2541
|
+
evidence.targetId !== relocation.source.journalId)
|
|
2542
|
+
throw new AccountMigrationError('reverse snapshot belongs to another generation');
|
|
2543
|
+
return decodeRelocationSnapshot({
|
|
2544
|
+
config: evidence.config,
|
|
2545
|
+
...(evidence.state === undefined ? {} : { state: evidence.state }),
|
|
2546
|
+
journal: evidence.journal,
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
async function reverseRelocation(modules, options, sourceParent, targetParent) {
|
|
2550
|
+
if (options.cancel)
|
|
2551
|
+
throw new AccountMigrationError('reverse relocation cannot be cancelled');
|
|
2552
|
+
const pointer = await readPointer(options.legacyPath);
|
|
2553
|
+
if (!pointer)
|
|
2554
|
+
throw new AccountMigrationError('reverse source pointer missing');
|
|
2555
|
+
const paths = generationPaths(options.legacyPath, pointer.id);
|
|
2556
|
+
return withGenerationLease(modules, paths.configPath, MANAGEMENT_LOCK_NAME, (lease) => withGenerationLease(modules, paths.configPath, 'antigravity-topology', async (topologyLease) => {
|
|
2557
|
+
const journal = await readJournal(paths);
|
|
2558
|
+
if (journal?.status !== 'active' ||
|
|
2559
|
+
journal.sourceKind !== 'generation')
|
|
2560
|
+
throw new AccountMigrationError('reverse requires an active relocated store');
|
|
2561
|
+
const relocation = relocationOf(journal);
|
|
2562
|
+
if (options.targetPath !== relocation.source.legacyPath)
|
|
2563
|
+
throw new AccountMigrationError('reverse target is not the original anchor');
|
|
2564
|
+
const original = generationPaths(options.targetPath, relocation.source.journalId);
|
|
2565
|
+
if ((await exists(paths.legacyPath)) ||
|
|
2566
|
+
(await exists(original.legacyPath)))
|
|
2567
|
+
throw new AccountMigrationError('reverse relocation refuses a reappeared pool file');
|
|
2568
|
+
return withGenerationLease(modules, original.configPath, MANAGEMENT_LOCK_NAME, (originalLease) => withGenerationLease(modules, original.configPath, 'antigravity-topology', async (originalTopology) => {
|
|
2569
|
+
const oldBytes = await secureRead(original.migrationPath);
|
|
2570
|
+
const oldJournal = await readJournal(original);
|
|
2571
|
+
if (oldJournal?.status !== 'active' ||
|
|
2572
|
+
oldJournal.sourceKind === 'generation')
|
|
2573
|
+
throw new AccountMigrationError('reverse original journal is not released-compatible');
|
|
2574
|
+
await verifyCopies(original, oldJournal, true);
|
|
2575
|
+
const forward = {
|
|
2576
|
+
schemaVersion: 2,
|
|
2577
|
+
id: oldJournal.id,
|
|
2578
|
+
directoryBasename: basename(original.storeDir),
|
|
2579
|
+
relocatedTo: {
|
|
2580
|
+
legacyPath: paths.legacyPath,
|
|
2581
|
+
id: journal.id,
|
|
2582
|
+
},
|
|
2583
|
+
};
|
|
2584
|
+
const restored = {
|
|
2585
|
+
schemaVersion: 1,
|
|
2586
|
+
id: oldJournal.id,
|
|
2587
|
+
directoryBasename: basename(original.storeDir),
|
|
2588
|
+
};
|
|
2589
|
+
const current = await readPointerRecord(original.legacyPath);
|
|
2590
|
+
if (!isDeepStrictEqual(current, forward) &&
|
|
2591
|
+
!(relocation.reversed &&
|
|
2592
|
+
isDeepStrictEqual(current, restored)))
|
|
2593
|
+
throw new AccountMigrationError('reverse target forward changed');
|
|
2594
|
+
const store = openMigrationStore(modules, paths, options.now);
|
|
2595
|
+
const progressPath = join(paths.transfersDir, `${journal.id}.reverse.json`);
|
|
2596
|
+
const progress = async (phase, snapshotSha256) => {
|
|
2597
|
+
const record = {
|
|
2598
|
+
schemaVersion: 1,
|
|
2599
|
+
id: journal.id,
|
|
2600
|
+
sourcePath: paths.legacyPath,
|
|
2601
|
+
targetPath: original.legacyPath,
|
|
2602
|
+
targetId: oldJournal.id,
|
|
2603
|
+
snapshotSha256,
|
|
2604
|
+
phase,
|
|
2605
|
+
};
|
|
2606
|
+
const stage = `${progressPath}.${journal.id}.tmp`;
|
|
2607
|
+
if (await exists(stage)) {
|
|
2608
|
+
await secureRead(stage);
|
|
2609
|
+
await lease.assertOwned();
|
|
2610
|
+
await unlink(stage);
|
|
2611
|
+
}
|
|
2612
|
+
await modules.fs.writeJsonAtomic(progressPath, record, {
|
|
2613
|
+
stageName: () => journal.id,
|
|
2614
|
+
beforeRename: async () => {
|
|
2615
|
+
await lease.assertOwned();
|
|
2616
|
+
await syncFile(stage);
|
|
2617
|
+
},
|
|
2618
|
+
});
|
|
2619
|
+
await syncFile(progressPath);
|
|
2620
|
+
await syncDirectory(paths.transfersDir);
|
|
2621
|
+
if (!isDeepStrictEqual(jsonBytes(await secureRead(progressPath)), record))
|
|
2622
|
+
throw new AccountMigrationError('reverse progress readback differs');
|
|
2623
|
+
};
|
|
2624
|
+
await verifyCopies(paths, journal, true);
|
|
2625
|
+
await currentSuccessor(store, paths, journal);
|
|
2626
|
+
if (!relocation.reversed) {
|
|
2627
|
+
const settings = await settingsOf(store);
|
|
2628
|
+
if (settings[MANAGEMENT_SETTINGS_KEY] !== undefined)
|
|
2629
|
+
throw new AccountMigrationError('reverse source management is pending');
|
|
2630
|
+
await options.onBoundary?.('relocate:back:before-clone');
|
|
2631
|
+
await withSaveLocks(modules, paths, async (sourceLeases) => {
|
|
2632
|
+
const snapshot = await synchronizedSnapshot(paths, options.onBoundary);
|
|
2633
|
+
const evidence = Buffer.from(JSON.stringify({
|
|
2634
|
+
sourceId: journal.id,
|
|
2635
|
+
targetId: oldJournal.id,
|
|
2636
|
+
config: snapshot.config.toString('base64'),
|
|
2637
|
+
...(snapshot.state === undefined
|
|
2638
|
+
? {}
|
|
2639
|
+
: { state: snapshot.state.toString('base64') }),
|
|
2640
|
+
journal: oldBytes.toString('base64'),
|
|
2641
|
+
}));
|
|
2642
|
+
const evidencePath = join(paths.transfersDir, `${digest(evidence)}.reverse-snapshot.json`);
|
|
2643
|
+
await exclusiveBytes(evidencePath, evidence, digest(evidence), options.onBoundary);
|
|
2644
|
+
await progress('clone', digest(evidence));
|
|
2645
|
+
await withSaveLocks(modules, original, async (targetLeases) => {
|
|
2646
|
+
const assertOwned = async () => {
|
|
2647
|
+
for (const held of [
|
|
2648
|
+
lease,
|
|
2649
|
+
originalLease,
|
|
2650
|
+
...sourceLeases,
|
|
2651
|
+
...targetLeases,
|
|
2652
|
+
])
|
|
2653
|
+
await held.assertOwned();
|
|
2654
|
+
};
|
|
2655
|
+
const copy = async (path, bytes) => {
|
|
2656
|
+
const stage = `${path}.${journal.id}.tmp`;
|
|
2657
|
+
await exclusiveBytes(stage, bytes, digest(bytes), options.onBoundary);
|
|
2658
|
+
await assertOwned();
|
|
2659
|
+
await rename(stage, path);
|
|
2660
|
+
await syncFile(path);
|
|
2661
|
+
await syncDirectory(original.storeDir);
|
|
2662
|
+
};
|
|
2663
|
+
// Native files are copied, never exported through the lossy V4 representation.
|
|
2664
|
+
if (snapshot.state !== undefined)
|
|
2665
|
+
await copy(original.statePath, snapshot.state);
|
|
2666
|
+
else if (await exists(original.statePath)) {
|
|
2667
|
+
await assertOwned();
|
|
2668
|
+
await unlink(original.statePath);
|
|
2669
|
+
await syncDirectory(original.storeDir);
|
|
2670
|
+
}
|
|
2671
|
+
await options.onBoundary?.('relocate:back:after-state');
|
|
2672
|
+
await copy(original.configPath, snapshot.config);
|
|
2673
|
+
await progress('restore-generation', digest(evidence));
|
|
2674
|
+
await options.onBoundary?.('relocate:back:after-clone');
|
|
2675
|
+
});
|
|
2676
|
+
relocation.reverseSha256 = digest(evidence);
|
|
2677
|
+
});
|
|
2678
|
+
// The original journal is never rewritten. Only its generation key
|
|
2679
|
+
// is restored in the current native pool.
|
|
2680
|
+
const originalStore = openMigrationStore(modules, original, options.now, async (step, info) => {
|
|
2681
|
+
await originalLease.assertOwned();
|
|
2682
|
+
if (step.startsWith('after-')) {
|
|
2683
|
+
await syncFile(step.includes('config')
|
|
2684
|
+
? original.configPath
|
|
2685
|
+
: original.statePath);
|
|
2686
|
+
await syncDirectory(original.storeDir);
|
|
2687
|
+
}
|
|
2688
|
+
await options.onBoundary?.(`back:public:${info.operation}:${step}`);
|
|
2689
|
+
});
|
|
2690
|
+
await originalStore.updateSettings((settings) => {
|
|
2691
|
+
if (!isDeepStrictEqual(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY], generationJson(journal)))
|
|
2692
|
+
throw new AccountMigrationError('reverse clone generation changed');
|
|
2693
|
+
if (settings[MANAGEMENT_SETTINGS_KEY] !== undefined)
|
|
2694
|
+
throw new AccountMigrationError('reverse clone retained management');
|
|
2695
|
+
settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] =
|
|
2696
|
+
generationJson(oldJournal);
|
|
2697
|
+
return settings;
|
|
2698
|
+
});
|
|
2699
|
+
await currentSuccessor(originalStore, original, oldJournal);
|
|
2700
|
+
if (!isDeepStrictEqual(await rowsOf(originalStore), await rowsOf(store)))
|
|
2701
|
+
throw new AccountMigrationError('reverse clone rows differ');
|
|
2702
|
+
await options.onBoundary?.('relocate:back:after-management-clear');
|
|
2703
|
+
if (!relocation.reverseSha256)
|
|
2704
|
+
throw new AccountMigrationError('reverse snapshot missing');
|
|
2705
|
+
await progress('publish', relocation.reverseSha256);
|
|
2706
|
+
relocation.reversed = true;
|
|
2707
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
2708
|
+
await options.onBoundary?.('relocate:back:after-record');
|
|
2709
|
+
}
|
|
2710
|
+
const evidence = await reverseEvidence(paths, journal);
|
|
2711
|
+
if (!evidence.journal.equals(oldBytes))
|
|
2712
|
+
throw new AccountMigrationError('reverse original journal differs from snapshot');
|
|
2713
|
+
if (current?.schemaVersion === 2) {
|
|
2714
|
+
const native = object(jsonBytes(evidence.config), 'reverse config');
|
|
2715
|
+
native[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] =
|
|
2716
|
+
generationJson(oldJournal);
|
|
2717
|
+
const actual = await withSaveLocks(modules, original, () => synchronizedSnapshot(original, options.onBoundary));
|
|
2718
|
+
if (!isDeepStrictEqual(jsonBytes(actual.config), native) ||
|
|
2719
|
+
!isDeepStrictEqual(actual.state, evidence.state))
|
|
2720
|
+
throw new AccountMigrationError('reverse clone differs before pointer publication');
|
|
2721
|
+
}
|
|
2722
|
+
if (!(await secureRead(original.migrationPath)).equals(oldBytes))
|
|
2723
|
+
throw new AccountMigrationError('reverse changed original journal');
|
|
2724
|
+
await withGenerationLease(modules, original.legacyPath, 'accounts', (accountsLease) => replaceRelocationPointer(modules, original.legacyPath, forward, restored, journal.id, [
|
|
2725
|
+
sourceParent,
|
|
2726
|
+
targetParent,
|
|
2727
|
+
lease,
|
|
2728
|
+
topologyLease,
|
|
2729
|
+
originalLease,
|
|
2730
|
+
originalTopology,
|
|
2731
|
+
accountsLease,
|
|
2732
|
+
], options.onBoundary, 'relocate:back:pointer'));
|
|
2733
|
+
await progress('complete', relocation.reverseSha256 ?? '');
|
|
2734
|
+
await options.onBoundary?.('relocate:back:after-pointer');
|
|
2735
|
+
return { status: 'completed', receipt: receipt(oldJournal) };
|
|
2736
|
+
}));
|
|
2737
|
+
}));
|
|
2738
|
+
}
|
|
1727
2739
|
function refMatches(ref, row) {
|
|
1728
2740
|
return (ref !== null && ref !== undefined && isDeepStrictEqual(ref, rowRef(row)));
|
|
1729
2741
|
}
|
|
@@ -1812,6 +2824,11 @@ export function createAccountRollbackFactory(modules) {
|
|
|
1812
2824
|
throw new AccountMigrationError('stopped processes and timers must be explicitly confirmed');
|
|
1813
2825
|
try {
|
|
1814
2826
|
return await withParentTopology(modules, options.legacyPath, async () => {
|
|
2827
|
+
// A moved store is rolled back from its pool file only after it was
|
|
2828
|
+
// moved back to this anchor; this build never does either.
|
|
2829
|
+
const record = await readPointerRecord(options.legacyPath);
|
|
2830
|
+
if (record?.schemaVersion === 2)
|
|
2831
|
+
throw new AccountMigrationError(`the account store moved to ${record.relocatedTo.legacyPath}; move it back with \`antigravity-auth store relocate --back --offline\` before rolling it back`);
|
|
1815
2832
|
const paths = await resolvePublishedAccountStorePaths(options.legacyPath);
|
|
1816
2833
|
if (!paths)
|
|
1817
2834
|
throw new AccountMigrationError('rollback requires a published generation');
|
|
@@ -1824,6 +2841,8 @@ export function createAccountRollbackFactory(modules) {
|
|
|
1824
2841
|
const initialJournal = await readJournal(paths);
|
|
1825
2842
|
if (!initialJournal)
|
|
1826
2843
|
throw new AccountMigrationError('rollback requires an owned migration generation');
|
|
2844
|
+
if (initialJournal.sourceKind === 'generation')
|
|
2845
|
+
throw new AccountMigrationError(`this account store was moved here from ${initialJournal.relocation?.source.legacyPath}; move it back with \`antigravity-auth store relocate --back --offline\` before rolling it back`);
|
|
1827
2846
|
let journal = initialJournal;
|
|
1828
2847
|
const store = openMigrationStore(modules, paths, options.now, writeObserver(paths, lease, options.onBoundary));
|
|
1829
2848
|
if (journal.status === 'inactive') {
|
|
@@ -2334,7 +3353,10 @@ async function allocateGeneration(legacyPath) {
|
|
|
2334
3353
|
if (await exists(resolveAccountStorePaths(legacyPath).storeDir))
|
|
2335
3354
|
throw new AccountMigrationError('foreign logical directory refuses generation allocation');
|
|
2336
3355
|
const paths = generationPaths(legacyPath, randomUUID());
|
|
2337
|
-
|
|
3356
|
+
if (windowsPrivateFiles)
|
|
3357
|
+
await createPrivateDirectoryExclusive(paths.storeDir);
|
|
3358
|
+
else
|
|
3359
|
+
await mkdir(paths.storeDir, { mode: 0o700 });
|
|
2338
3360
|
await prepareDirectories(paths);
|
|
2339
3361
|
return paths;
|
|
2340
3362
|
}
|
|
@@ -2369,6 +3391,9 @@ async function publishGenerationPointer(modules, paths, journal, parentLease, ma
|
|
|
2369
3391
|
throw new AccountMigrationError('foreign logical store directory appeared before publication');
|
|
2370
3392
|
if (journal.sourceKind === 'absent')
|
|
2371
3393
|
await assertSourceAbsent(paths);
|
|
3394
|
+
else if (journal.sourceKind === 'generation') {
|
|
3395
|
+
await assertSourceAbsent(paths);
|
|
3396
|
+
}
|
|
2372
3397
|
else if (digest(await secureRead(paths.legacyPath)) !== journal.sourceSha256)
|
|
2373
3398
|
throw new AccountMigrationError('captured source changed immediately before pointer link');
|
|
2374
3399
|
try {
|
|
@@ -2389,9 +3414,18 @@ async function publishGenerationPointer(modules, paths, journal, parentLease, ma
|
|
|
2389
3414
|
else {
|
|
2390
3415
|
const oldPaths = generationPaths(paths.legacyPath, prior.id);
|
|
2391
3416
|
const oldJournal = await readJournal(oldPaths);
|
|
2392
|
-
if (oldJournal
|
|
3417
|
+
if (!oldJournal)
|
|
3418
|
+
throw new AccountMigrationError('prior generation journal missing');
|
|
3419
|
+
const reversed = oldJournal?.relocation?.reversed === true &&
|
|
3420
|
+
journal.sourceKind === 'generation' &&
|
|
3421
|
+
isDeepStrictEqual(oldJournal.relocation.source.legacyPath, journal.relocation?.source.legacyPath) &&
|
|
3422
|
+
oldJournal.relocation.source.journalId ===
|
|
3423
|
+
journal.relocation?.source.journalId;
|
|
3424
|
+
if (oldJournal?.status !== 'inactive' && !reversed)
|
|
2393
3425
|
throw new AccountMigrationError('pointer transition requires an owned inactive prior generation');
|
|
2394
3426
|
await verifyCopies(oldPaths, oldJournal, !oldJournal.cancelled);
|
|
3427
|
+
if (reversed)
|
|
3428
|
+
await reverseEvidence(oldPaths, oldJournal);
|
|
2395
3429
|
const stageName = journal.id;
|
|
2396
3430
|
await modules.fs.writeJsonAtomic(path, pointer, {
|
|
2397
3431
|
stageName: () => stageName,
|
|
@@ -2399,7 +3433,9 @@ async function publishGenerationPointer(modules, paths, journal, parentLease, ma
|
|
|
2399
3433
|
await parentLease.assertOwned();
|
|
2400
3434
|
await managementLease.assertOwned();
|
|
2401
3435
|
if (!isDeepStrictEqual(await readPointer(paths.legacyPath), prior) ||
|
|
2402
|
-
(
|
|
3436
|
+
(reversed
|
|
3437
|
+
? !isDeepStrictEqual(await readJournal(oldPaths), oldJournal)
|
|
3438
|
+
: (await readJournal(oldPaths))?.status !== 'inactive'))
|
|
2403
3439
|
throw new AccountMigrationError('inactive pointer changed before transition');
|
|
2404
3440
|
await syncFile(`${path}.${stageName}.tmp`);
|
|
2405
3441
|
},
|