run402 4.64.0 → 4.66.0
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/gitvault-surface.json +1 -1
- package/lib/gitvault-daemon.mjs +30 -1
- package/lib/remote-helper-session.mjs +30 -3
- package/package.json +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +8 -1
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-address.d.ts +41 -10
- package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-address.js +51 -10
- package/sdk/dist/node/gitvault-address.js.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.d.ts +12 -1
- package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +66 -1
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +100 -0
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-mirror.js +16 -1
- package/sdk/dist/node/gitvault-mirror.js.map +1 -1
- package/sdk/dist/node/gitvault-prewarm.d.ts +31 -0
- package/sdk/dist/node/gitvault-prewarm.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-prewarm.js +67 -0
- package/sdk/dist/node/gitvault-prewarm.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +176 -5
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +475 -71
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/index.d.ts +1 -1
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +1 -1
- package/sdk/dist/node/index.js.map +1 -1
|
@@ -634,6 +634,35 @@ export function gitvaultLedgerId(read) {
|
|
|
634
634
|
return pinManifestLedgerId(read.pin_manifest_version);
|
|
635
635
|
return String(read.object_id);
|
|
636
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* `scheme://host` for a URL string, or `null` for anything that fails to
|
|
639
|
+
* parse (never thrown) — the sole normalizer between a full presigned/edge
|
|
640
|
+
* URL (path, query, signature, everything) and the bare origin
|
|
641
|
+
* {@link GitvaultHttpTransportOptions.onObjectStoreOriginObserved} hands a
|
|
642
|
+
* caller. Deliberately NOT exported: this module is the only place that
|
|
643
|
+
* observes raw object-store URLs, so the normalizer has exactly one call
|
|
644
|
+
* site and needs no wider audience.
|
|
645
|
+
*/
|
|
646
|
+
function gitvaultObjectStoreOrigin(url) {
|
|
647
|
+
try {
|
|
648
|
+
return new URL(url).origin;
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** Dedup, in order, dropping anything that failed to parse. */
|
|
655
|
+
function gitvaultObjectStoreOrigins(urls) {
|
|
656
|
+
const out = [];
|
|
657
|
+
for (const u of urls) {
|
|
658
|
+
if (!u)
|
|
659
|
+
continue;
|
|
660
|
+
const origin = gitvaultObjectStoreOrigin(u);
|
|
661
|
+
if (origin && !out.includes(origin))
|
|
662
|
+
out.push(origin);
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
637
666
|
function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
|
|
638
667
|
function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
|
|
639
668
|
/**
|
|
@@ -706,8 +735,45 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
706
735
|
}
|
|
707
736
|
return new Uint8Array(await r.arrayBuffer());
|
|
708
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* Resolve ONE `object-reads` presign target to bytes (gitvault-small-
|
|
740
|
+
* object-inline design D3): consume `inline` when present and EITHER no
|
|
741
|
+
* `expectedSha256` was supplied OR the decoded bytes hash to it, else
|
|
742
|
+
* fall through to the ordinary `url`/`edge_url` fetch exactly as before
|
|
743
|
+
* this change. A lying `inline` is therefore a PLAIN MISS for this one
|
|
744
|
+
* slot — the fetch below reproduces the URL-only result and failure
|
|
745
|
+
* envelope byte-for-byte, never a special error. A caller that supplies
|
|
746
|
+
* no expectation cannot detect a lying `inline` at all, but that is no
|
|
747
|
+
* new exposure: `inline` is the SAME stored bytes, to the SAME
|
|
748
|
+
* authorized caller, the entry's own `url` would have served — nothing
|
|
749
|
+
* here becomes a new source of truth (design D5).
|
|
750
|
+
*
|
|
751
|
+
* gitvault-object-host-predial (task 1.2): once the fetch below
|
|
752
|
+
* COMPLETES (any status — a thrown fetch error is NOT this), reports the
|
|
753
|
+
* target's origin(s) via `options.onObjectStoreOriginObserved`. Never
|
|
754
|
+
* fired for the `inline` short-circuit above — no URL was dialed.
|
|
755
|
+
*/
|
|
756
|
+
async function resolveObjectReadTarget(repoId, target, expectedSha256, path) {
|
|
757
|
+
if (target.inline !== undefined) {
|
|
758
|
+
const bytes = fromBase64url(target.inline, "reads[].inline");
|
|
759
|
+
if (expectedSha256 === undefined || sha256Hex(bytes) === expectedSha256)
|
|
760
|
+
return bytes;
|
|
761
|
+
}
|
|
762
|
+
const r = await fetchGitvaultObjectBytes(client, target);
|
|
763
|
+
try {
|
|
764
|
+
options.onObjectStoreOriginObserved?.(repoId, gitvaultObjectStoreOrigins([target.url, target.edge_url]));
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
/* a caller's own persistence failure must never surface into this read */
|
|
768
|
+
}
|
|
769
|
+
if (r.status === 404)
|
|
770
|
+
return null;
|
|
771
|
+
if (!r.ok)
|
|
772
|
+
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
|
|
773
|
+
return new Uint8Array(await r.arrayBuffer());
|
|
774
|
+
}
|
|
709
775
|
/** Presign + fetch one object by its ledger identity (`POST …/object-reads`). */
|
|
710
|
-
async function getObjectBytes(repoId, path) {
|
|
776
|
+
async function getObjectBytes(repoId, path, expectedSha256) {
|
|
711
777
|
const ref = gitvaultWireRefForPath(path);
|
|
712
778
|
if (!ref)
|
|
713
779
|
fail("GITVAULT_OBJECT_READ_FAILED", `${path} has no control-plane wire identity; it is not a readable vault object`, "reading gitvault object", { path });
|
|
@@ -742,12 +808,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
742
808
|
const target = presigned.reads[0];
|
|
743
809
|
if (!target)
|
|
744
810
|
return null;
|
|
745
|
-
|
|
746
|
-
if (r.status === 404)
|
|
747
|
-
return null;
|
|
748
|
-
if (!r.ok)
|
|
749
|
-
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
|
|
750
|
-
return new Uint8Array(await r.arrayBuffer());
|
|
811
|
+
return resolveObjectReadTarget(repoId, target, expectedSha256, path);
|
|
751
812
|
}
|
|
752
813
|
/**
|
|
753
814
|
* Presign + fetch N independent objects (gitvault-client-round-trips
|
|
@@ -759,7 +820,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
759
820
|
* restore's carrier and pack reads, so it fails closed rather than
|
|
760
821
|
* silently costing an extra round trip through `getGenerationBytes`.
|
|
761
822
|
*/
|
|
762
|
-
async function getObjectsBytes(repoId, paths) {
|
|
823
|
+
async function getObjectsBytes(repoId, paths, expected) {
|
|
763
824
|
if (paths.length === 0)
|
|
764
825
|
return [];
|
|
765
826
|
const targets = await presignObjectBatch(repoId, paths);
|
|
@@ -768,12 +829,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
768
829
|
return mapBounded(targets, GITVAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
|
|
769
830
|
if (!target)
|
|
770
831
|
return null;
|
|
771
|
-
|
|
772
|
-
if (r.status === 404)
|
|
773
|
-
return null;
|
|
774
|
-
if (!r.ok)
|
|
775
|
-
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
|
|
776
|
-
return new Uint8Array(await r.arrayBuffer());
|
|
832
|
+
return resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]);
|
|
777
833
|
});
|
|
778
834
|
}
|
|
779
835
|
/** The shared presign step of the batched read: ONE `object-reads` POST; `null` means the not-found shapes `getObjects` maps to an all-absent result. */
|
|
@@ -803,10 +859,12 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
803
859
|
* D2): identical presign + bounded GETs, but each index's promise settles
|
|
804
860
|
* when ITS bytes land. Failure semantics per index match `getObjects`'s
|
|
805
861
|
* per-element behavior (absent → null, a failed GET → the same
|
|
806
|
-
* GITVAULT_OBJECT_READ_FAILED)
|
|
807
|
-
*
|
|
862
|
+
* GITVAULT_OBJECT_READ_FAILED), including `expected` (gitvault-small-
|
|
863
|
+
* object-inline design D3 — see {@link GitvaultTransport.getObjects}'s
|
|
864
|
+
* doc comment); every promise is pre-marked handled so an abandoned tail
|
|
865
|
+
* never becomes an unhandled rejection.
|
|
808
866
|
*/
|
|
809
|
-
async function getObjectsSettledBytes(repoId, paths) {
|
|
867
|
+
async function getObjectsSettledBytes(repoId, paths, expected) {
|
|
810
868
|
if (paths.length === 0)
|
|
811
869
|
return [];
|
|
812
870
|
const targets = await presignObjectBatch(repoId, paths);
|
|
@@ -835,14 +893,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
835
893
|
deferreds[i].resolve(null);
|
|
836
894
|
continue;
|
|
837
895
|
}
|
|
838
|
-
|
|
839
|
-
if (r.status === 404) {
|
|
840
|
-
deferreds[i].resolve(null);
|
|
841
|
-
continue;
|
|
842
|
-
}
|
|
843
|
-
if (!r.ok)
|
|
844
|
-
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
|
|
845
|
-
deferreds[i].resolve(new Uint8Array(await r.arrayBuffer()));
|
|
896
|
+
deferreds[i].resolve(await resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]));
|
|
846
897
|
}
|
|
847
898
|
catch (e) {
|
|
848
899
|
deferreds[i].reject(e);
|
|
@@ -908,11 +959,17 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
908
959
|
return null;
|
|
909
960
|
}
|
|
910
961
|
}
|
|
911
|
-
/** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL (preferring its `edge_url` companion, gitvault-read-edge-cache design D5), `null` on a 404 (mirrors {@link getObjectBytes}'s absent reading; both arms indistinguishable after this). */
|
|
912
|
-
async function resolveVaultStateCarrier(carrier) {
|
|
962
|
+
/** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL (preferring its `edge_url` companion, gitvault-read-edge-cache design D5), `null` on a 404 (mirrors {@link getObjectBytes}'s absent reading; both arms indistinguishable after this). Origin observation (gitvault-object-host-predial task 1.2) mirrors `resolveObjectReadTarget`'s. */
|
|
963
|
+
async function resolveVaultStateCarrier(repoId, carrier) {
|
|
913
964
|
if ("inline" in carrier)
|
|
914
965
|
return fromBase64url(carrier.inline, "carriers.inline");
|
|
915
966
|
const r = await fetchGitvaultObjectBytes(client, { url: carrier.presigned_url, edge_url: carrier.edge_url });
|
|
967
|
+
try {
|
|
968
|
+
options.onObjectStoreOriginObserved?.(repoId, gitvaultObjectStoreOrigins([carrier.presigned_url, carrier.edge_url]));
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
/* a caller's own persistence failure must never surface into this read */
|
|
972
|
+
}
|
|
916
973
|
if (r.status === 404)
|
|
917
974
|
return null;
|
|
918
975
|
if (!r.ok)
|
|
@@ -925,11 +982,16 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
925
982
|
* resolved to raw bytes here, verified nowhere here (see the interface's
|
|
926
983
|
* own doc comment on {@link GitvaultTransport.getState}).
|
|
927
984
|
*/
|
|
928
|
-
async function getVaultStateOut(repoId, since) {
|
|
929
|
-
const
|
|
985
|
+
async function getVaultStateOut(repoId, since, restore) {
|
|
986
|
+
const qs = [];
|
|
987
|
+
if (since)
|
|
988
|
+
qs.push(`since=${encodeURIComponent(since)}`);
|
|
989
|
+
if (restore)
|
|
990
|
+
qs.push("restore=1");
|
|
991
|
+
const raw = await client.request(`${base(repoId)}/state${qs.length > 0 ? `?${qs.join("&")}` : ""}`, { context: "reading the gitvault vault state" });
|
|
930
992
|
const head = raw.head ? { stored_bytes: fromBase64url(raw.head.stored_bytes, "head.stored_bytes"), stored_bytes_sha256: raw.head.stored_bytes_sha256 } : null;
|
|
931
993
|
const carriers = raw.carriers
|
|
932
|
-
? { ref_state: await resolveVaultStateCarrier(raw.carriers.ref_state), retention_roots: await resolveVaultStateCarrier(raw.carriers.retention_roots) }
|
|
994
|
+
? { ref_state: await resolveVaultStateCarrier(repoId, raw.carriers.ref_state), retention_roots: await resolveVaultStateCarrier(repoId, raw.carriers.retention_roots) }
|
|
933
995
|
: null;
|
|
934
996
|
// Delta decode is deliberately forgiving: a malformed entry is dropped,
|
|
935
997
|
// never thrown — the delta is an accelerator and the ordinary reads own
|
|
@@ -949,7 +1011,63 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
949
1011
|
delta = null;
|
|
950
1012
|
}
|
|
951
1013
|
}
|
|
952
|
-
|
|
1014
|
+
// Restore-plan decode (gitvault-restore-recipe design D1-D5): the SAME
|
|
1015
|
+
// forgiving posture as `delta` above — ANY structural or resolution
|
|
1016
|
+
// anomaly drops the whole plan, never throws, so a hiccup fetching one
|
|
1017
|
+
// above-cap pack never fails the state read itself (head/carriers/delta
|
|
1018
|
+
// already succeeded by the time this runs). Heads and checkpoint bytes
|
|
1019
|
+
// are a pure base64url decode (the gateway sends them inline ALWAYS —
|
|
1020
|
+
// design D4, no network here); packs reuse `resolveObjectReadTarget`
|
|
1021
|
+
// verbatim (db8d745c) — the SAME self-consistency-gated inline decode
|
|
1022
|
+
// with url/edge_url fallback every `object-reads` consumer gets — under
|
|
1023
|
+
// bounded concurrency, so an all-inline plan costs this call NOTHING
|
|
1024
|
+
// beyond the one `GET …/state` already in flight, and an above-cap plan
|
|
1025
|
+
// pays exactly one GET per uncapped pack (the presign already happened
|
|
1026
|
+
// server-side assembling the plan; no client-side `object-reads` POST).
|
|
1027
|
+
// Decoding is its own function (never throwing `Error` — the public-SDK
|
|
1028
|
+
// plain-`Error` contract — `null`/early-return is the disqualification
|
|
1029
|
+
// signal instead) wrapped in one try/catch for the genuine exceptions
|
|
1030
|
+
// `fromBase64url`/`resolveObjectReadTarget` can still raise.
|
|
1031
|
+
const decodeRestorePlan = async () => {
|
|
1032
|
+
if (!raw.restore_plan || !Array.isArray(raw.restore_plan.heads) || !Array.isArray(raw.restore_plan.packs))
|
|
1033
|
+
return null;
|
|
1034
|
+
try {
|
|
1035
|
+
const rp = raw.restore_plan;
|
|
1036
|
+
const heads = rp.heads
|
|
1037
|
+
.filter((h) => typeof h?.generation === "string" && typeof h.stored_bytes === "string" && typeof h.stored_bytes_sha256 === "string")
|
|
1038
|
+
.map((h) => ({ generation: h.generation, stored_bytes: fromBase64url(h.stored_bytes, "restore_plan.heads[].stored_bytes"), stored_bytes_sha256: h.stored_bytes_sha256 }));
|
|
1039
|
+
if (heads.length !== rp.heads.length)
|
|
1040
|
+
return null; // a malformed head entry disqualifies the whole plan
|
|
1041
|
+
const checkpoint = rp.checkpoint
|
|
1042
|
+
? {
|
|
1043
|
+
claim_set: { object_id: rp.checkpoint.claim_set.object_id, stored_bytes: fromBase64url(rp.checkpoint.claim_set.stored_bytes, "restore_plan.checkpoint.claim_set.stored_bytes") },
|
|
1044
|
+
manifest: { object_id: rp.checkpoint.manifest.object_id, stored_bytes: fromBase64url(rp.checkpoint.manifest.stored_bytes, "restore_plan.checkpoint.manifest.stored_bytes") },
|
|
1045
|
+
}
|
|
1046
|
+
: null;
|
|
1047
|
+
const packEntries = rp.packs.filter((pk) => typeof pk?.object_kind === "string" && typeof pk.object_id === "string");
|
|
1048
|
+
if (packEntries.length !== rp.packs.length)
|
|
1049
|
+
return null; // a malformed pack entry disqualifies the whole plan
|
|
1050
|
+
const resolved = await mapBounded(packEntries, GITVAULT_TRANSPORT_CONCURRENCY, async (pk) => {
|
|
1051
|
+
const target = { object_kind: pk.object_kind, object_id: pk.object_id, url: pk.url ?? "", edge_url: pk.edge_url, inline: pk.inline, stored_bytes_sha256: pk.stored_bytes_sha256 ?? "", size_bytes: pk.size_bytes ?? "0", expires_at: pk.expires_at ?? "" };
|
|
1052
|
+
// Self-consistency only (the same gate `inline` gets everywhere
|
|
1053
|
+
// else) — real verification against the head/manifest's OWN
|
|
1054
|
+
// receipt happens later, in `GitvaultVault`, before any byte is
|
|
1055
|
+
// trusted. A pack the gateway declined to presign (`url` absent,
|
|
1056
|
+
// `inline` absent) is `null` here and falls back to the ordinary
|
|
1057
|
+
// object-reads fetch by (object_kind, object_id) in the caller.
|
|
1058
|
+
if (!target.url && target.inline === undefined)
|
|
1059
|
+
return null;
|
|
1060
|
+
return resolveObjectReadTarget(repoId, target, pk.stored_bytes_sha256, `restore_plan:${pk.object_kind}:${pk.object_id}`);
|
|
1061
|
+
});
|
|
1062
|
+
const packs = packEntries.map((pk, i) => ({ object_kind: pk.object_kind, object_id: pk.object_id, bytes: resolved[i] ?? null }));
|
|
1063
|
+
return { boundary_generation: rp.boundary_generation, heads, checkpoint, packs };
|
|
1064
|
+
}
|
|
1065
|
+
catch {
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
const restorePlan = await decodeRestorePlan();
|
|
1070
|
+
return { vault: raw.vault, newest_generation: raw.newest_generation, head, carriers, ...(delta ? { delta } : {}), ...(restorePlan ? { restore_plan: restorePlan } : {}) };
|
|
953
1071
|
}
|
|
954
1072
|
/** Pair a `finalize`-shaped response's receipts back onto `objects` by ledger id — shared by the inline and presigned upload paths so neither forks the other's receipt-compare logic. */
|
|
955
1073
|
function receiptsFromFinalize(objects, entries, fin) {
|
|
@@ -1100,9 +1218,9 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1100
1218
|
const [r] = await upload(request.repo_id, [{ path: request.path, object_kind: "key_envelope", object_id: null, bytes: request.bytes, sha256: request.expected_sha256, size_bytes: request.expected_size_bytes }], undefined, request.byo);
|
|
1101
1219
|
return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
|
|
1102
1220
|
},
|
|
1103
|
-
getObject: ({ repo_id, path }) => getObjectBytes(repo_id, path),
|
|
1104
|
-
getObjects: ({ repo_id, paths }) => getObjectsBytes(repo_id, paths),
|
|
1105
|
-
getObjectsSettled: ({ repo_id, paths }) => getObjectsSettledBytes(repo_id, paths),
|
|
1221
|
+
getObject: ({ repo_id, path, expected_sha256 }) => getObjectBytes(repo_id, path, expected_sha256),
|
|
1222
|
+
getObjects: ({ repo_id, paths, expected }) => getObjectsBytes(repo_id, paths, expected),
|
|
1223
|
+
getObjectsSettled: ({ repo_id, paths, expected }) => getObjectsSettledBytes(repo_id, paths, expected),
|
|
1106
1224
|
getHeads: ({ repo_id, generations }) => getHeadsBytes(repo_id, generations),
|
|
1107
1225
|
async admitGenesis(request) {
|
|
1108
1226
|
try {
|
|
@@ -1151,7 +1269,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1151
1269
|
return { cleared: r.advisory_cleared ?? r.cleared ?? false };
|
|
1152
1270
|
},
|
|
1153
1271
|
getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the gitvault record" }),
|
|
1154
|
-
getState: ({ repo_id, since }) => getVaultStateOut(repo_id, since),
|
|
1272
|
+
getState: ({ repo_id, since, restore }) => getVaultStateOut(repo_id, since, restore),
|
|
1155
1273
|
openCompactionGrant: ({ repo_id }) => client.request(`${base(repo_id)}/compaction-grant`, { method: "POST", context: "opening the gitvault compaction headroom grant" }),
|
|
1156
1274
|
closeCompactionGrant: async ({ repo_id }) => {
|
|
1157
1275
|
const r = await client.request(`${base(repo_id)}/compaction-grant`, { method: "DELETE", context: "closing the gitvault compaction headroom grant" });
|
|
@@ -1461,6 +1579,18 @@ export class GitvaultVault {
|
|
|
1461
1579
|
* consumed and cleared by the next restore).
|
|
1462
1580
|
*/
|
|
1463
1581
|
stateDeltaPacks = null;
|
|
1582
|
+
/**
|
|
1583
|
+
* The state read's restore plan (gitvault-restore-recipe design D1-D5),
|
|
1584
|
+
* stashed RAW — never verified at stash time, unlike `stateDeltaPacks`
|
|
1585
|
+
* (whose heads self-check before entering the shared keystore cache): a
|
|
1586
|
+
* plan's heads/checkpoint/packs are only ever verified by {@link
|
|
1587
|
+
* restoreObjectsInto}'s own full obligation set, and a partially-checked
|
|
1588
|
+
* plan sitting here would be a foot-gun for a future caller that forgot
|
|
1589
|
+
* the difference. Consumed and cleared exactly once, by the next
|
|
1590
|
+
* `restoreObjectsInto` call (either its own materialize, or a dedicated
|
|
1591
|
+
* plan-only read it issues when the incremental walk it started aborts).
|
|
1592
|
+
*/
|
|
1593
|
+
stateRestorePlan = null;
|
|
1464
1594
|
retries;
|
|
1465
1595
|
servicePublicKey;
|
|
1466
1596
|
genesisCache = null;
|
|
@@ -1649,7 +1779,7 @@ export class GitvaultVault {
|
|
|
1649
1779
|
* comment). A wrong or absent byte this method wrote is therefore just a
|
|
1650
1780
|
* cache MISS on the next read, never a verification bypass.
|
|
1651
1781
|
*/
|
|
1652
|
-
async tryStateFastPath(pin, deltaSince) {
|
|
1782
|
+
async tryStateFastPath(pin, deltaSince, restore) {
|
|
1653
1783
|
// gitvault-delta-fetch: carry the caller's MATERIALIZED position as
|
|
1654
1784
|
// `since` — a capable gateway answers small spans with the heads +
|
|
1655
1785
|
// inline WAL packs in this same response; every other gateway/span
|
|
@@ -1659,7 +1789,15 @@ export class GitvaultVault {
|
|
|
1659
1789
|
// keystore, so pin-as-since would report "current" for a standing clone
|
|
1660
1790
|
// that is generations behind — exactly the multi-checkout shape the
|
|
1661
1791
|
// bench runs.
|
|
1662
|
-
|
|
1792
|
+
//
|
|
1793
|
+
// `restore` (gitvault-restore-recipe design D2/D6) rides the SAME state
|
|
1794
|
+
// read — never a second round trip — so the caller (`restoreObjectsInto`,
|
|
1795
|
+
// via `materialize`/`verifyToNewest`) declares it only on the wholesale
|
|
1796
|
+
// shape (see that method's own doc comment). This call declining the
|
|
1797
|
+
// shortcut entirely (more than one generation behind `pin`) also means
|
|
1798
|
+
// no plan is ever requested on this attempt — the listing-walk path that
|
|
1799
|
+
// owns catch-up already has its own batched primitives.
|
|
1800
|
+
const state = await this.transport.getState({ repo_id: this.repoId, since: deltaSince ?? pin.generation, restore });
|
|
1663
1801
|
// §6.4: the vault's newest generation may never fall below the
|
|
1664
1802
|
// authenticated pin — checked here regardless of eligibility below, so
|
|
1665
1803
|
// a regressed vault is caught exactly as loudly as it always was, even
|
|
@@ -1679,6 +1817,13 @@ export class GitvaultVault {
|
|
|
1679
1817
|
// is actually genesis's bytes.
|
|
1680
1818
|
if (state.delta)
|
|
1681
1819
|
this.consumeStateDelta(state.delta);
|
|
1820
|
+
// gitvault-restore-recipe: stashed RAW (never verified here — see the
|
|
1821
|
+
// field's own doc comment); a genesis-only response (below) still runs
|
|
1822
|
+
// this line first, but `assembleVaultRestorePlan` never fires server-side
|
|
1823
|
+
// before an ordinary head exists, so `state.restore_plan` is simply
|
|
1824
|
+
// absent in that window — nothing to stash either way.
|
|
1825
|
+
if (state.restore_plan)
|
|
1826
|
+
this.stateRestorePlan = state.restore_plan;
|
|
1682
1827
|
const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation === GITVAULT_GENESIS_GENERATION;
|
|
1683
1828
|
const pinBig = generationToBigInt(pin.generation);
|
|
1684
1829
|
const newestBig = noOrdinaryHeadYet ? 0n : generationToBigInt(state.newest_generation);
|
|
@@ -1811,7 +1956,10 @@ export class GitvaultVault {
|
|
|
1811
1956
|
// own epoch could not be resolved without the network read this path
|
|
1812
1957
|
// exists to avoid) — the caller falls straight through to the UNCHANGED
|
|
1813
1958
|
// readHead + listHeads flow below, byte-identical to before this change.
|
|
1814
|
-
|
|
1959
|
+
// `options.restore` (gitvault-restore-recipe) rides along unchanged —
|
|
1960
|
+
// declined exactly when the fast path itself is declined, so a caller
|
|
1961
|
+
// more than one generation behind never gets (or needs) a plan here.
|
|
1962
|
+
const fastPath = await this.tryStateFastPath(pin, options.deltaSince, options.restore);
|
|
1815
1963
|
let lastHead = fastPath ? fastPath.pinnedHead : pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
|
|
1816
1964
|
const anchor = pin.generation;
|
|
1817
1965
|
let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH);
|
|
@@ -1989,7 +2137,7 @@ export class GitvaultVault {
|
|
|
1989
2137
|
}
|
|
1990
2138
|
}
|
|
1991
2139
|
if (carriers.length > 1) {
|
|
1992
|
-
const fetched = await this.transport.getObjects({ repo_id: this.repoId, paths: carriers.map((c) => c.path) });
|
|
2140
|
+
const fetched = await this.transport.getObjects({ repo_id: this.repoId, paths: carriers.map((c) => c.path), expected: carriers.map((c) => c.sha) });
|
|
1993
2141
|
for (let i = 0; i < carriers.length; i++) {
|
|
1994
2142
|
const bytes = fetched[i] ?? null;
|
|
1995
2143
|
if (bytes && sha256Hex(bytes) === carriers[i].sha)
|
|
@@ -2255,7 +2403,7 @@ export class GitvaultVault {
|
|
|
2255
2403
|
const cacheable = kind === "ref_state" || kind === "retention_roots";
|
|
2256
2404
|
const cached = cacheable ? this.keystore.readCachedCarrier(this.repoId, receipt.object_id) : null;
|
|
2257
2405
|
const cacheHit = cached && sha256Hex(cached.bytes) === receipt.ciphertext_sha256;
|
|
2258
|
-
const frame = cacheHit ? cached.bytes : await this.transport.getObject({ repo_id: this.repoId, path });
|
|
2406
|
+
const frame = cacheHit ? cached.bytes : await this.transport.getObject({ repo_id: this.repoId, path, expected_sha256: receipt.ciphertext_sha256 });
|
|
2259
2407
|
const object = this.decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride);
|
|
2260
2408
|
if (cacheable && !cacheHit && frame && typeof object.generation === "string") {
|
|
2261
2409
|
this.keystore.writeCachedCarrier(this.repoId, receipt.object_id, object.generation, receipt.ciphertext_sha256, frame);
|
|
@@ -2285,11 +2433,16 @@ export class GitvaultVault {
|
|
|
2285
2433
|
const preRoots = !rootsHit ? (this.walkPrefetch?.get(rootsPath) ?? null) : null;
|
|
2286
2434
|
const rootsPre = preRoots && sha256Hex(preRoots) === rootsReceipt.ciphertext_sha256 ? preRoots : null;
|
|
2287
2435
|
const missingPaths = [];
|
|
2288
|
-
|
|
2436
|
+
const missingExpected = [];
|
|
2437
|
+
if (!refStateHit && !refStatePre) {
|
|
2289
2438
|
missingPaths.push(refStatePath);
|
|
2290
|
-
|
|
2439
|
+
missingExpected.push(refStateReceipt.ciphertext_sha256);
|
|
2440
|
+
}
|
|
2441
|
+
if (!rootsHit && !rootsPre) {
|
|
2291
2442
|
missingPaths.push(rootsPath);
|
|
2292
|
-
|
|
2443
|
+
missingExpected.push(rootsReceipt.ciphertext_sha256);
|
|
2444
|
+
}
|
|
2445
|
+
const fetched = missingPaths.length > 0 ? await this.transport.getObjects({ repo_id: this.repoId, paths: missingPaths, expected: missingExpected }) : [];
|
|
2293
2446
|
let next = 0;
|
|
2294
2447
|
const refStateFrame = refStateHit ? cachedRefState.bytes : (refStatePre ?? fetched[next++] ?? null);
|
|
2295
2448
|
const rootsFrame = rootsHit ? cachedRoots.bytes : (rootsPre ?? fetched[next++] ?? null);
|
|
@@ -2319,7 +2472,7 @@ export class GitvaultVault {
|
|
|
2319
2472
|
*/
|
|
2320
2473
|
async materialize(options = {}) {
|
|
2321
2474
|
const persist = options.persist ?? true;
|
|
2322
|
-
const state = await this.verifyToNewest({ persist, decryptValidate: true, strict: true, deltaSince: options.deltaSince });
|
|
2475
|
+
const state = await this.verifyToNewest({ persist, decryptValidate: true, strict: true, deltaSince: options.deltaSince, restore: options.restore });
|
|
2323
2476
|
if (!state.head) {
|
|
2324
2477
|
if (persist)
|
|
2325
2478
|
this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
|
|
@@ -2720,7 +2873,7 @@ export class GitvaultVault {
|
|
|
2720
2873
|
fail("CHECKPOINT_INCOMPLETE", `head ${head.generation} carries no checkpoint to verify`, "verifying a stored checkpoint", { generation: head.generation });
|
|
2721
2874
|
const { genesis } = await this.genesis();
|
|
2722
2875
|
const writerKey = genesis.creator_signing_pubkey;
|
|
2723
|
-
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id) });
|
|
2876
|
+
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
|
|
2724
2877
|
if (!claimBytes || sha256Hex(claimBytes) !== block.claim_set.stored_bytes_sha256) {
|
|
2725
2878
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} is absent or does not match the head's receipt`, "verifying a stored checkpoint", { object_id: block.claim_set.object_id });
|
|
2726
2879
|
}
|
|
@@ -2736,7 +2889,7 @@ export class GitvaultVault {
|
|
|
2736
2889
|
try {
|
|
2737
2890
|
await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
|
|
2738
2891
|
for (const p of manifest.packs) {
|
|
2739
|
-
const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.checkpointPack(p.object_id) });
|
|
2892
|
+
const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.checkpointPack(p.object_id), expected_sha256: p.ciphertext_sha256 });
|
|
2740
2893
|
if (!frame)
|
|
2741
2894
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} is absent from storage`, "verifying a stored checkpoint", { object_id: p.object_id });
|
|
2742
2895
|
const plain = openFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: this.epoch(), frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
|
|
@@ -2800,7 +2953,7 @@ export class GitvaultVault {
|
|
|
2800
2953
|
// Plaintext-structured and stored-bytes-receipted: no decryption, but
|
|
2801
2954
|
// the hash and the owner signature are still checked before a single
|
|
2802
2955
|
// pack receipt inside it is believed.
|
|
2803
|
-
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id) });
|
|
2956
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
|
|
2804
2957
|
if (!bytes || sha256Hex(bytes) !== block.claim_set.stored_bytes_sha256) {
|
|
2805
2958
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} (generation ${gen}) is absent or altered`, "walking the gitvault chain", { generation: gen, object_id: block.claim_set.object_id });
|
|
2806
2959
|
}
|
|
@@ -3160,7 +3313,7 @@ export class GitvaultVault {
|
|
|
3160
3313
|
if (known && known.pin_manifest_version === receipt.pin_manifest_version && known.stored_bytes_sha256 === receipt.stored_bytes_sha256) {
|
|
3161
3314
|
return { pinManifestVersion: known.pin_manifest_version, pinManifestSha256: known.stored_bytes_sha256, pinnedFingerprintOf: new Map(known.pins.map((p) => [p.principal_id, p.ek_fingerprint])) };
|
|
3162
3315
|
}
|
|
3163
|
-
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.pinManifest(receipt.pin_manifest_version) });
|
|
3316
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.pinManifest(receipt.pin_manifest_version), expected_sha256: receipt.stored_bytes_sha256 });
|
|
3164
3317
|
if (!bytes || sha256Hex(bytes) !== receipt.stored_bytes_sha256) {
|
|
3165
3318
|
fail("GITVAULT_RECEIPT_MISMATCH", `recipient_pin_manifest ${receipt.pin_manifest_version} is absent or does not match its receipted hash`, "resolving the effective recipient pin manifest", { pin_manifest_version: receipt.pin_manifest_version });
|
|
3166
3319
|
}
|
|
@@ -3740,7 +3893,16 @@ export class GitvaultVault {
|
|
|
3740
3893
|
// reuse therefore costs one extra read, never a wrong result.
|
|
3741
3894
|
const marker = await readGitvaultRestoreMarker(targetRepoDir);
|
|
3742
3895
|
const markerMatches = (a, b) => a === null ? b === null : b !== null && a.generation === b.generation && a.head_sha256 === b.head_sha256;
|
|
3743
|
-
|
|
3896
|
+
// gitvault-restore-recipe design D2/D6: a marker-absent materialize is,
|
|
3897
|
+
// by construction, the wholesale shape (a fresh target has nothing to
|
|
3898
|
+
// replay incrementally) — declare restore intent here so a capable
|
|
3899
|
+
// gateway's plan rides THIS read rather than a second one. Mirrors the
|
|
3900
|
+
// remote-helper session's own `list`-phase decision (`runList` in
|
|
3901
|
+
// `cli/lib/remote-helper-session.mjs`); this branch only fires when
|
|
3902
|
+
// `reuse` was absent or mismatched (no prior `list` in this session, or
|
|
3903
|
+
// a direct SDK caller), so the PRIMARY clone path never pays for this —
|
|
3904
|
+
// it rides the reused state instead.
|
|
3905
|
+
const newest = reuse && markerMatches(reuse.marker, marker) ? reuse.state : await this.materialize({ ...(marker ? { deltaSince: marker.generation } : { restore: true }) });
|
|
3744
3906
|
if (!newest.head) {
|
|
3745
3907
|
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: {}, roots: [], head_target: newest.head_target });
|
|
3746
3908
|
return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
@@ -3758,6 +3920,17 @@ export class GitvaultVault {
|
|
|
3758
3920
|
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
|
|
3759
3921
|
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
3760
3922
|
}
|
|
3923
|
+
// gitvault-restore-recipe (design D1-D6): consume a plan that already
|
|
3924
|
+
// rode this method's OWN materialize() (the `restore: true` request
|
|
3925
|
+
// above, or the reused list-phase read) BEFORE paying for the backward
|
|
3926
|
+
// walk below at all. `tryConsumeRestorePlan` verifies from scratch and
|
|
3927
|
+
// returns `null` on absence or ANY failure — the walk below is the
|
|
3928
|
+
// unconditional fallback, byte-identical to before this change.
|
|
3929
|
+
{
|
|
3930
|
+
const applied = await this.tryConsumeRestorePlan(targetRepoDir, newest, writerKey);
|
|
3931
|
+
if (applied)
|
|
3932
|
+
return applied;
|
|
3933
|
+
}
|
|
3761
3934
|
// Walk back from newest, trying for an INCREMENTAL boundary at the
|
|
3762
3935
|
// marker (a pure local prev_sha256 comparison — no fetch needed to
|
|
3763
3936
|
// CONFIRM the boundary itself); abandon incremental the moment a
|
|
@@ -3832,6 +4005,27 @@ export class GitvaultVault {
|
|
|
3832
4005
|
incremental = false;
|
|
3833
4006
|
heads.length = 0;
|
|
3834
4007
|
cur = newest.head;
|
|
4008
|
+
// gitvault-restore-recipe (design D1/D2, task 2.1's named fallback
|
|
4009
|
+
// shape): the marker WAS present when this method's own materialize()
|
|
4010
|
+
// ran, so `restore` was never declared and no plan rode that read —
|
|
4011
|
+
// fetch one now, dedicated, before falling through to the ordinary
|
|
4012
|
+
// wholesale walk. Best-effort: an older gateway, a network fault, a
|
|
4013
|
+
// disqualification, or a verification failure all just continue the
|
|
4014
|
+
// loop exactly as before this change; nothing here can make the
|
|
4015
|
+
// restore fail that would otherwise have succeeded.
|
|
4016
|
+
if (!this.stateRestorePlan) {
|
|
4017
|
+
try {
|
|
4018
|
+
const resp = await this.transport.getState({ repo_id: this.repoId, restore: true });
|
|
4019
|
+
if (resp.restore_plan)
|
|
4020
|
+
this.stateRestorePlan = resp.restore_plan;
|
|
4021
|
+
}
|
|
4022
|
+
catch {
|
|
4023
|
+
// fall through to the wholesale walk
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
const applied = await this.tryConsumeRestorePlan(targetRepoDir, newest, writerKey);
|
|
4027
|
+
if (applied)
|
|
4028
|
+
return applied;
|
|
3835
4029
|
continue;
|
|
3836
4030
|
}
|
|
3837
4031
|
heads.unshift(cur);
|
|
@@ -3855,6 +4049,32 @@ export class GitvaultVault {
|
|
|
3855
4049
|
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
|
|
3856
4050
|
cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
3857
4051
|
}
|
|
4052
|
+
return this.applyRestoreHeads(targetRepoDir, heads, incremental, newest, writerKey, null, null);
|
|
4053
|
+
}
|
|
4054
|
+
/**
|
|
4055
|
+
* The shared restore tail (gitvault-restore-recipe): apply `heads`'
|
|
4056
|
+
* checkpoint (if any) + WAL packs into `targetRepoDir`, verify §4.7
|
|
4057
|
+
* coverage, reconcile retained refs, and advance the marker — IDENTICALLY
|
|
4058
|
+
* whether `heads` came from {@link restoreObjectsInto}'s own backward walk
|
|
4059
|
+
* or a verified restore plan ({@link tryConsumeRestorePlan}). `incremental`
|
|
4060
|
+
* only affects the checkpoint-coverage LEARNING step (an incremental walk
|
|
4061
|
+
* stops at the marker and learns nothing; a plan-derived call always
|
|
4062
|
+
* passes `false` — a plan is the wholesale shape by construction).
|
|
4063
|
+
*
|
|
4064
|
+
* `precheckedCheckpoint`, when non-null, is a claim set + manifest the
|
|
4065
|
+
* caller ALREADY verified (signature, cross-equality — {@link
|
|
4066
|
+
* verifyRestorePlan}) — skips the network fetch + re-verify this method
|
|
4067
|
+
* would otherwise run for `heads[0].checkpoint`. `planPacks`, when
|
|
4068
|
+
* non-null, is a map of pack bytes the caller already has (untrusted —
|
|
4069
|
+
* only used when its OWN hash matches the carrying head's/manifest's
|
|
4070
|
+
* receipt, exactly `stateDeltaPacks`'s discipline), keyed
|
|
4071
|
+
* `${object_kind}:${object_id}` so it covers BOTH WAL and checkpoint
|
|
4072
|
+
* packs (`stateDeltaPacks` never carries checkpoint packs — a delta span
|
|
4073
|
+
* never crosses a checkpoint boundary). Both are `null` on the ordinary
|
|
4074
|
+
* walk path, in which case every line below is byte-identical to the
|
|
4075
|
+
* pre-gitvault-restore-recipe shape.
|
|
4076
|
+
*/
|
|
4077
|
+
async applyRestoreHeads(targetRepoDir, heads, incremental, newest, writerKey, precheckedCheckpoint, planPacks) {
|
|
3858
4078
|
// gitvault-clone-scaling (P3): a WHOLESALE walk just stopped at the
|
|
3859
4079
|
// newest checkpoint-bearing head, or proved there is none back to
|
|
3860
4080
|
// genesis — that is first-hand checkpoint coverage, and recording it is
|
|
@@ -3880,22 +4100,24 @@ export class GitvaultVault {
|
|
|
3880
4100
|
// map `materialize()` just resolved (throwing `GITVAULT_EPOCH_NOT_OPENABLE`
|
|
3881
4101
|
// fail-closed if any needed epoch could not be opened), so every lookup
|
|
3882
4102
|
// below is guaranteed present.
|
|
3883
|
-
const kRepoForEpoch = (epoch) =>
|
|
3884
|
-
const hex = newest.epoch_keys_hex[epoch];
|
|
3885
|
-
if (!hex)
|
|
3886
|
-
fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring gitvault objects", { epoch });
|
|
3887
|
-
return hexToBytes(hex);
|
|
3888
|
-
};
|
|
4103
|
+
const kRepoForEpoch = (epoch) => this.epochKeyFor(newest, epoch);
|
|
3889
4104
|
const first = heads[0];
|
|
3890
4105
|
// gitvault-pipelined-restore: per-object settlement when the transport
|
|
3891
4106
|
// offers it, the `getObjects` barrier otherwise — pipelining is a
|
|
3892
4107
|
// wall-clock property, never a correctness dependency, so a transport
|
|
3893
4108
|
// without the method reproduces today's serial-after-barrier behavior
|
|
3894
4109
|
// exactly (each per-index promise settles when the whole batch does).
|
|
3895
|
-
|
|
4110
|
+
//
|
|
4111
|
+
// `expected` (gitvault-small-object-inline design D3) is index-aligned
|
|
4112
|
+
// with `paths` — every caller below already knows each object's
|
|
4113
|
+
// receipted ciphertext hash BEFORE fetching it, so it rides along here
|
|
4114
|
+
// to let an `object-reads`-backed transport verify (and, on a lying
|
|
4115
|
+
// reply, discard) an `inline` bytes offer per slot. Not required: an
|
|
4116
|
+
// omitted `expected` is byte-identical to before this change.
|
|
4117
|
+
const settled = async (paths, expected) => {
|
|
3896
4118
|
if (this.transport.getObjectsSettled)
|
|
3897
|
-
return this.transport.getObjectsSettled({ repo_id: this.repoId, paths });
|
|
3898
|
-
const all = this.transport.getObjects({ repo_id: this.repoId, paths });
|
|
4119
|
+
return this.transport.getObjectsSettled({ repo_id: this.repoId, paths, expected });
|
|
4120
|
+
const all = this.transport.getObjects({ repo_id: this.repoId, paths, expected });
|
|
3899
4121
|
const perIndex = paths.map((_, i) => all.then((frames) => frames[i] ?? null));
|
|
3900
4122
|
// Mark every derived promise handled — the consumer awaits them in
|
|
3901
4123
|
// order and stops at the first failure, abandoning the tail.
|
|
@@ -3917,7 +4139,12 @@ export class GitvaultVault {
|
|
|
3917
4139
|
// skip the network ENTIRELY — but only after hashing to their carrying
|
|
3918
4140
|
// head's receipt (a lying delta is a plain miss; the ordinary fetch
|
|
3919
4141
|
// below reproduces the unbatched behavior for that slot). The buffer is
|
|
3920
|
-
// consumed exactly once.
|
|
4142
|
+
// consumed exactly once. gitvault-restore-recipe extends the SAME
|
|
4143
|
+
// short-circuit to `planPacks` (a verified plan's own pack bytes) —
|
|
4144
|
+
// checked SECOND so a delta hit never gets shadowed by a plan hit for
|
|
4145
|
+
// the same slot (the two never coexist in practice, per `getState`'s
|
|
4146
|
+
// mutual-exclusivity between `since` and `restore` on one call, but
|
|
4147
|
+
// checking order stays deterministic either way).
|
|
3921
4148
|
const deltaPacks = this.stateDeltaPacks;
|
|
3922
4149
|
this.stateDeltaPacks = null;
|
|
3923
4150
|
const deltaCovered = new Map();
|
|
@@ -3928,31 +4155,62 @@ export class GitvaultVault {
|
|
|
3928
4155
|
deltaCovered.set(i, bytes);
|
|
3929
4156
|
});
|
|
3930
4157
|
}
|
|
4158
|
+
if (planPacks) {
|
|
4159
|
+
walEntries.forEach(({ w }, i) => {
|
|
4160
|
+
if (deltaCovered.has(i))
|
|
4161
|
+
return;
|
|
4162
|
+
const bytes = planPacks.get(`wal_pack:${w.object_id}`);
|
|
4163
|
+
if (bytes && sha256Hex(bytes) === w.ciphertext_sha256)
|
|
4164
|
+
deltaCovered.set(i, bytes);
|
|
4165
|
+
});
|
|
4166
|
+
}
|
|
3931
4167
|
const uncovered = walEntries.map((_, i) => i).filter((i) => !deltaCovered.has(i));
|
|
3932
4168
|
const walFramesP = (async () => {
|
|
3933
|
-
const fetched = uncovered.length > 0 ? await settled(uncovered.map((i) => gitvaultPaths.wal(walEntries[i].w.object_id))) : [];
|
|
4169
|
+
const fetched = uncovered.length > 0 ? await settled(uncovered.map((i) => gitvaultPaths.wal(walEntries[i].w.object_id)), uncovered.map((i) => walEntries[i].w.ciphertext_sha256)) : [];
|
|
3934
4170
|
return walEntries.map((_, i) => (deltaCovered.has(i) ? Promise.resolve(deltaCovered.get(i)) : fetched[uncovered.indexOf(i)]));
|
|
3935
4171
|
})();
|
|
3936
4172
|
void walFramesP.catch(() => { });
|
|
3937
4173
|
if (first.checkpoint) {
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
4174
|
+
let claimSet;
|
|
4175
|
+
let manifest;
|
|
4176
|
+
if (precheckedCheckpoint) {
|
|
4177
|
+
// gitvault-restore-recipe: already fetched + verified (signature,
|
|
4178
|
+
// cross-equality) by `verifyRestorePlan` against the plan's own
|
|
4179
|
+
// inline bytes — no network read, no re-verify.
|
|
4180
|
+
({ claimSet, manifest } = precheckedCheckpoint);
|
|
4181
|
+
}
|
|
4182
|
+
else {
|
|
4183
|
+
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(first.checkpoint.claim_set.object_id), expected_sha256: first.checkpoint.claim_set.stored_bytes_sha256 });
|
|
4184
|
+
if (!claimBytes || sha256Hex(claimBytes) !== first.checkpoint.claim_set.stored_bytes_sha256)
|
|
4185
|
+
fail("CHECKPOINT_INCOMPLETE", "claim set absent or altered", "restoring gitvault objects");
|
|
4186
|
+
claimSet = parseGitvaultStrict(new TextDecoder().decode(claimBytes));
|
|
4187
|
+
if (!verifyGitvaultObject(claimSet, writerKey))
|
|
4188
|
+
fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring gitvault objects");
|
|
4189
|
+
manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, gitvaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey, { epoch: first.epoch, k_repo: kRepoForEpoch(first.epoch) });
|
|
4190
|
+
checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
|
|
4191
|
+
}
|
|
3946
4192
|
// Design D2 + gitvault-pipelined-restore D1: every checkpoint pack is
|
|
3947
4193
|
// independent — one batched presign for all of them, applied via
|
|
3948
4194
|
// index-pack strictly in manifest order, PIPELINED: apply(i) awaits
|
|
3949
4195
|
// bytes(i), so decrypt/verify/index of an early pack overlaps the
|
|
3950
4196
|
// later packs' downloads. Per-pack verification (AEAD open + plaintext
|
|
3951
|
-
// hash) still completes before any byte reaches git.
|
|
3952
|
-
|
|
4197
|
+
// hash) still completes before any byte reaches git. gitvault-restore-
|
|
4198
|
+
// recipe: a `planPacks` hit skips the fetch for that index entirely —
|
|
4199
|
+
// the SAME short-circuit the WAL loop above runs, extended to the
|
|
4200
|
+
// checkpoint class (never carried by `stateDeltaPacks`).
|
|
4201
|
+
const checkpointCovered = new Map();
|
|
4202
|
+
if (planPacks) {
|
|
4203
|
+
manifest.packs.forEach((p, i) => {
|
|
4204
|
+
const bytes = planPacks.get(`checkpoint_pack:${p.object_id}`);
|
|
4205
|
+
if (bytes && sha256Hex(bytes) === p.ciphertext_sha256)
|
|
4206
|
+
checkpointCovered.set(i, bytes);
|
|
4207
|
+
});
|
|
4208
|
+
}
|
|
4209
|
+
const uncoveredCk = manifest.packs.map((_, i) => i).filter((i) => !checkpointCovered.has(i));
|
|
4210
|
+
const ckFrames = uncoveredCk.length > 0 ? await settled(uncoveredCk.map((i) => gitvaultPaths.checkpointPack(manifest.packs[i].object_id)), uncoveredCk.map((i) => manifest.packs[i].ciphertext_sha256)) : [];
|
|
3953
4211
|
for (let i = 0; i < manifest.packs.length; i++) {
|
|
3954
4212
|
const p = manifest.packs[i];
|
|
3955
|
-
const frame = (await
|
|
4213
|
+
const frame = checkpointCovered.has(i) ? checkpointCovered.get(i) : ((await ckFrames[uncoveredCk.indexOf(i)]) ?? null);
|
|
3956
4214
|
if (!frame)
|
|
3957
4215
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
|
|
3958
4216
|
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(first.epoch), this.repoId, first.epoch, "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: first.epoch, frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
|
|
@@ -4000,6 +4258,152 @@ export class GitvaultVault {
|
|
|
4000
4258
|
await writeGitvaultRestoreMarker(targetRepoDir, newest.generation, newest.head_sha256);
|
|
4001
4259
|
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
4002
4260
|
}
|
|
4261
|
+
/**
|
|
4262
|
+
* `newest.epoch_keys_hex[epoch]`, decoded — the D194 per-carrying-head key
|
|
4263
|
+
* lookup shared by {@link applyRestoreHeads} and {@link verifyRestorePlan}.
|
|
4264
|
+
* `newest.epoch_keys_hex` is the FULL map `materialize()` resolved
|
|
4265
|
+
* (throwing `GITVAULT_EPOCH_NOT_OPENABLE` fail-closed if any needed epoch
|
|
4266
|
+
* could not be opened), so every lookup through this helper is guaranteed
|
|
4267
|
+
* present.
|
|
4268
|
+
*/
|
|
4269
|
+
epochKeyFor(newest, epoch) {
|
|
4270
|
+
const hex = newest.epoch_keys_hex[epoch];
|
|
4271
|
+
if (!hex)
|
|
4272
|
+
fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring gitvault objects", { epoch });
|
|
4273
|
+
return hexToBytes(hex);
|
|
4274
|
+
}
|
|
4275
|
+
/**
|
|
4276
|
+
* Verify a restore plan's heads (self-consistency + backward chain-link +
|
|
4277
|
+
* cross-check against the caller's own already-verified `newest`, plus
|
|
4278
|
+
* genesis/boundary linkage) and, when the boundary carries a checkpoint,
|
|
4279
|
+
* its claim set + manifest (signature, cross-equality) — the SAME
|
|
4280
|
+
* obligations {@link applyRestoreHeads}'s ordinary path runs, against
|
|
4281
|
+
* plan-supplied bytes instead of network-fetched ones (design D5: "the
|
|
4282
|
+
* plan is transport, never trust"). Returns `null` on ANY failure — a
|
|
4283
|
+
* self-inconsistent head, a broken link, a wrong newest, a bad signature,
|
|
4284
|
+
* a cross-equality mismatch, anything the reused `decodeCarrierFrame`/
|
|
4285
|
+
* `checkClaimSetEquality` reject — rather than throwing, so the caller's
|
|
4286
|
+
* fallback to the ordinary walk is unconditional and silent, mirroring
|
|
4287
|
+
* `assembleVaultStateDelta`'s own disqualification posture server-side.
|
|
4288
|
+
*
|
|
4289
|
+
* Heads that verify their OWN chain link (Stage A) are cache-warmed into
|
|
4290
|
+
* {@link walkPrefetch} BEFORE Stage B (the checkpoint) runs, so a
|
|
4291
|
+
* checkpoint failure still leaves the fallback walk with every head this
|
|
4292
|
+
* call already proved — the D3 cache-hit discipline, extended to a failed
|
|
4293
|
+
* plan.
|
|
4294
|
+
*/
|
|
4295
|
+
async verifyRestorePlan(plan, newest, writerKey) {
|
|
4296
|
+
// Every disqualification below is a plain `return null` — never `throw
|
|
4297
|
+
// new Error(...)` (the public-SDK plain-`Error` contract) — so a manual
|
|
4298
|
+
// `for` loop replaces `plan.heads.map(...)`: a `.map()` callback can only
|
|
4299
|
+
// fail its OWN element, never abort the whole computation, and this
|
|
4300
|
+
// function's contract is "any bad element disqualifies the WHOLE plan."
|
|
4301
|
+
// The try/catch below still stands, to catch the genuine exceptions
|
|
4302
|
+
// `parseGitvaultStrict`/`decodeCarrierFrame`/`checkClaimSetEquality`/
|
|
4303
|
+
// `verifyGitvaultObject` can raise (a `fail()`-thrown `Run402Error`, or a
|
|
4304
|
+
// strict-parse rejection) — those are real errors, just ones this method
|
|
4305
|
+
// treats as "the plan didn't verify" rather than propagating.
|
|
4306
|
+
try {
|
|
4307
|
+
if (plan.heads.length === 0)
|
|
4308
|
+
return null;
|
|
4309
|
+
const parsed = [];
|
|
4310
|
+
for (const h of plan.heads) {
|
|
4311
|
+
if (sha256Hex(h.stored_bytes) !== h.stored_bytes_sha256)
|
|
4312
|
+
return null; // self-hash mismatch
|
|
4313
|
+
const head = parseGitvaultStrict(new TextDecoder().decode(h.stored_bytes));
|
|
4314
|
+
if (head.generation !== h.generation)
|
|
4315
|
+
return null; // generation mismatch
|
|
4316
|
+
parsed.push({ generation: h.generation, bytes: h.stored_bytes, head });
|
|
4317
|
+
}
|
|
4318
|
+
for (let i = 1; i < parsed.length; i++) {
|
|
4319
|
+
if (generationToBigInt(parsed[i].generation) !== generationToBigInt(parsed[i - 1].generation) + 1n)
|
|
4320
|
+
return null; // span not contiguous
|
|
4321
|
+
}
|
|
4322
|
+
const last = parsed[parsed.length - 1];
|
|
4323
|
+
if (last.generation !== newest.generation || sha256Hex(last.bytes) !== newest.head_sha256)
|
|
4324
|
+
return null; // does not match the caller's own verified newest
|
|
4325
|
+
// Backward chain-link, newest to boundary — the SAME hash-equality
|
|
4326
|
+
// check `applyRestoreHeads`'s own backward walk runs (no signature
|
|
4327
|
+
// re-check here: the chain was already fully verified FORWARD by
|
|
4328
|
+
// `materialize()`/`verifyToNewest()` before this ever runs — see that
|
|
4329
|
+
// method's own doc comment on why its backward walk is a hash-only
|
|
4330
|
+
// re-confirmation, not a re-verification).
|
|
4331
|
+
for (let i = parsed.length - 1; i >= 1; i--) {
|
|
4332
|
+
if (parsed[i].head.prev_sha256 !== sha256Hex(parsed[i - 1].bytes))
|
|
4333
|
+
return null; // chain link broken
|
|
4334
|
+
}
|
|
4335
|
+
const first = parsed[0];
|
|
4336
|
+
if (plan.boundary_generation === GITVAULT_GENESIS_GENERATION) {
|
|
4337
|
+
if (first.generation !== "0000000000000001")
|
|
4338
|
+
return null; // genesis boundary mismatch
|
|
4339
|
+
const { sha256: genesisSha } = await this.genesis();
|
|
4340
|
+
if (first.head.prev_sha256 !== genesisSha)
|
|
4341
|
+
return null; // generation 1 does not link to genesis
|
|
4342
|
+
}
|
|
4343
|
+
else {
|
|
4344
|
+
if (first.generation !== plan.boundary_generation || !first.head.checkpoint)
|
|
4345
|
+
return null; // boundary does not carry a checkpoint
|
|
4346
|
+
}
|
|
4347
|
+
// Stage A verified — cache-warm every plan head regardless of Stage B's
|
|
4348
|
+
// outcome below (D3: a failed plan still costs the fallback nothing it
|
|
4349
|
+
// already proved).
|
|
4350
|
+
if (!this.walkPrefetch)
|
|
4351
|
+
this.walkPrefetch = new Map();
|
|
4352
|
+
for (const p of parsed)
|
|
4353
|
+
this.walkPrefetch.set(gitvaultPaths.head(p.generation), p.bytes);
|
|
4354
|
+
let checkpoint = null;
|
|
4355
|
+
if (first.head.checkpoint) {
|
|
4356
|
+
if (!plan.checkpoint)
|
|
4357
|
+
return null; // boundary head carries a checkpoint but the plan carries none
|
|
4358
|
+
const claimReceipt = first.head.checkpoint.claim_set;
|
|
4359
|
+
if (plan.checkpoint.claim_set.object_id !== claimReceipt.object_id)
|
|
4360
|
+
return null; // claim set id mismatch
|
|
4361
|
+
if (sha256Hex(plan.checkpoint.claim_set.stored_bytes) !== claimReceipt.stored_bytes_sha256)
|
|
4362
|
+
return null; // claim set hash mismatch
|
|
4363
|
+
const claimSet = parseGitvaultStrict(new TextDecoder().decode(plan.checkpoint.claim_set.stored_bytes));
|
|
4364
|
+
if (!verifyGitvaultObject(claimSet, writerKey))
|
|
4365
|
+
return null; // claim set signature fails
|
|
4366
|
+
if (claimSet.manifest_receipt.object_id !== plan.checkpoint.manifest.object_id)
|
|
4367
|
+
return null; // manifest id mismatch
|
|
4368
|
+
// Reuses `decodeCarrierFrame` verbatim — ciphertext hash, AEAD open,
|
|
4369
|
+
// and identity/signature checks all run exactly as they would for a
|
|
4370
|
+
// network-fetched manifest frame; the only difference is where the
|
|
4371
|
+
// ciphertext bytes came from.
|
|
4372
|
+
const manifest = this.decodeCarrierFrame("checkpoint_manifest", claimSet.manifest_receipt, plan.checkpoint.manifest.stored_bytes, writerKey, { epoch: first.head.epoch, k_repo: this.epochKeyFor(newest, first.head.epoch) });
|
|
4373
|
+
checkClaimSetEquality(claimSet, manifest, first.head.checkpoint.covers_through_generation);
|
|
4374
|
+
checkpoint = { claimSet, manifest };
|
|
4375
|
+
}
|
|
4376
|
+
return { heads: parsed.map((p) => p.head), checkpoint };
|
|
4377
|
+
}
|
|
4378
|
+
catch {
|
|
4379
|
+
return null;
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
/**
|
|
4383
|
+
* Consume a stashed restore plan (gitvault-restore-recipe design D1-D6):
|
|
4384
|
+
* verify it ({@link verifyRestorePlan}) and, on success, apply it via the
|
|
4385
|
+
* SAME shared tail the backward walk uses ({@link applyRestoreHeads}) —
|
|
4386
|
+
* `null` on absence or ANY verification failure, the caller's cue to run
|
|
4387
|
+
* (or continue) the ordinary walk. Clears `this.stateRestorePlan`
|
|
4388
|
+
* unconditionally: a plan is single-use whether it verified or not (a
|
|
4389
|
+
* failed plan's USABLE heads already rode into `walkPrefetch` inside
|
|
4390
|
+
* `verifyRestorePlan` itself).
|
|
4391
|
+
*/
|
|
4392
|
+
async tryConsumeRestorePlan(targetRepoDir, newest, writerKey) {
|
|
4393
|
+
const plan = this.stateRestorePlan;
|
|
4394
|
+
this.stateRestorePlan = null;
|
|
4395
|
+
if (!plan)
|
|
4396
|
+
return null;
|
|
4397
|
+
const verified = await this.verifyRestorePlan(plan, newest, writerKey);
|
|
4398
|
+
if (!verified)
|
|
4399
|
+
return null;
|
|
4400
|
+
const planPacks = new Map();
|
|
4401
|
+
for (const p of plan.packs) {
|
|
4402
|
+
if (p.bytes)
|
|
4403
|
+
planPacks.set(`${p.object_kind}:${p.object_id}`, p.bytes);
|
|
4404
|
+
}
|
|
4405
|
+
return this.applyRestoreHeads(targetRepoDir, verified.heads, false, newest, writerKey, verified.checkpoint, planPacks);
|
|
4406
|
+
}
|
|
4003
4407
|
// ── compaction headroom grant (gitvault-checkpoint-cadence design D3) ──
|
|
4004
4408
|
/** Thin passthrough to the transport — see {@link GitvaultTransport.openCompactionGrant}. */
|
|
4005
4409
|
async openCompactionGrant() {
|