run402 4.63.1 → 4.65.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 +2 -1
- package/lib/doctor.mjs +28 -15
- package/lib/gitvault-capabilities.mjs +8 -0
- package/lib/gitvault-daemon.mjs +30 -1
- package/lib/remote-helper-session.mjs +168 -33
- package/lib/repos.mjs +122 -9
- package/package.json +1 -1
- package/sdk/dist/errors.d.ts +4 -2
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/index.d.ts +1 -1
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +1 -1
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.d.ts +80 -0
- package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.js +80 -0
- package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +219 -3
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +290 -6
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-byo-config.d.ts +29 -0
- package/sdk/dist/node/gitvault-byo-config.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-byo-config.js +100 -0
- package/sdk/dist/node/gitvault-byo-config.js.map +1 -0
- package/sdk/dist/node/gitvault-byo-probe.d.ts +20 -0
- package/sdk/dist/node/gitvault-byo-probe.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-byo-probe.js +57 -0
- package/sdk/dist/node/gitvault-byo-probe.js.map +1 -0
- package/sdk/dist/node/gitvault-creation-journal.d.ts +55 -1
- package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.js +34 -3
- package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
- package/sdk/dist/node/gitvault-degraded-read.d.ts +120 -0
- package/sdk/dist/node/gitvault-degraded-read.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-degraded-read.js +164 -0
- package/sdk/dist/node/gitvault-degraded-read.js.map +1 -0
- package/sdk/dist/node/gitvault-keystore.d.ts +34 -1
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +43 -0
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror-backend.d.ts +73 -0
- package/sdk/dist/node/gitvault-mirror-backend.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-mirror-backend.js +114 -8
- package/sdk/dist/node/gitvault-mirror-backend.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror-config.d.ts +17 -1
- package/sdk/dist/node/gitvault-mirror-config.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-mirror-config.js +21 -0
- package/sdk/dist/node/gitvault-mirror-config.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror.d.ts +42 -0
- package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-mirror.js +178 -2
- 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 +227 -6
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +563 -77
- 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
|
@@ -38,6 +38,7 @@ import { tmpdir } from "node:os";
|
|
|
38
38
|
import { join } from "node:path";
|
|
39
39
|
import { LocalError, isRun402Error } from "../errors.js";
|
|
40
40
|
import { fetchGitvaultObjectBytes } from "./gitvault-edge-fetch.js";
|
|
41
|
+
import { openGitvaultDestinationBackend } from "./gitvault-mirror-backend.js";
|
|
41
42
|
import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
|
|
42
43
|
import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
|
|
43
44
|
import { gitvaultCheckpointStaleness } from "../namespaces/gitvault.js";
|
|
@@ -633,6 +634,35 @@ export function gitvaultLedgerId(read) {
|
|
|
633
634
|
return pinManifestLedgerId(read.pin_manifest_version);
|
|
634
635
|
return String(read.object_id);
|
|
635
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
|
+
}
|
|
636
666
|
function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
|
|
637
667
|
function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
|
|
638
668
|
/**
|
|
@@ -705,8 +735,45 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
705
735
|
}
|
|
706
736
|
return new Uint8Array(await r.arrayBuffer());
|
|
707
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
|
+
}
|
|
708
775
|
/** Presign + fetch one object by its ledger identity (`POST …/object-reads`). */
|
|
709
|
-
async function getObjectBytes(repoId, path) {
|
|
776
|
+
async function getObjectBytes(repoId, path, expectedSha256) {
|
|
710
777
|
const ref = gitvaultWireRefForPath(path);
|
|
711
778
|
if (!ref)
|
|
712
779
|
fail("GITVAULT_OBJECT_READ_FAILED", `${path} has no control-plane wire identity; it is not a readable vault object`, "reading gitvault object", { path });
|
|
@@ -741,12 +808,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
741
808
|
const target = presigned.reads[0];
|
|
742
809
|
if (!target)
|
|
743
810
|
return null;
|
|
744
|
-
|
|
745
|
-
if (r.status === 404)
|
|
746
|
-
return null;
|
|
747
|
-
if (!r.ok)
|
|
748
|
-
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
|
|
749
|
-
return new Uint8Array(await r.arrayBuffer());
|
|
811
|
+
return resolveObjectReadTarget(repoId, target, expectedSha256, path);
|
|
750
812
|
}
|
|
751
813
|
/**
|
|
752
814
|
* Presign + fetch N independent objects (gitvault-client-round-trips
|
|
@@ -758,7 +820,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
758
820
|
* restore's carrier and pack reads, so it fails closed rather than
|
|
759
821
|
* silently costing an extra round trip through `getGenerationBytes`.
|
|
760
822
|
*/
|
|
761
|
-
async function getObjectsBytes(repoId, paths) {
|
|
823
|
+
async function getObjectsBytes(repoId, paths, expected) {
|
|
762
824
|
if (paths.length === 0)
|
|
763
825
|
return [];
|
|
764
826
|
const targets = await presignObjectBatch(repoId, paths);
|
|
@@ -767,12 +829,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
767
829
|
return mapBounded(targets, GITVAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
|
|
768
830
|
if (!target)
|
|
769
831
|
return null;
|
|
770
|
-
|
|
771
|
-
if (r.status === 404)
|
|
772
|
-
return null;
|
|
773
|
-
if (!r.ok)
|
|
774
|
-
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
|
|
775
|
-
return new Uint8Array(await r.arrayBuffer());
|
|
832
|
+
return resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]);
|
|
776
833
|
});
|
|
777
834
|
}
|
|
778
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. */
|
|
@@ -802,10 +859,12 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
802
859
|
* D2): identical presign + bounded GETs, but each index's promise settles
|
|
803
860
|
* when ITS bytes land. Failure semantics per index match `getObjects`'s
|
|
804
861
|
* per-element behavior (absent → null, a failed GET → the same
|
|
805
|
-
* GITVAULT_OBJECT_READ_FAILED)
|
|
806
|
-
*
|
|
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.
|
|
807
866
|
*/
|
|
808
|
-
async function getObjectsSettledBytes(repoId, paths) {
|
|
867
|
+
async function getObjectsSettledBytes(repoId, paths, expected) {
|
|
809
868
|
if (paths.length === 0)
|
|
810
869
|
return [];
|
|
811
870
|
const targets = await presignObjectBatch(repoId, paths);
|
|
@@ -834,14 +893,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
834
893
|
deferreds[i].resolve(null);
|
|
835
894
|
continue;
|
|
836
895
|
}
|
|
837
|
-
|
|
838
|
-
if (r.status === 404) {
|
|
839
|
-
deferreds[i].resolve(null);
|
|
840
|
-
continue;
|
|
841
|
-
}
|
|
842
|
-
if (!r.ok)
|
|
843
|
-
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
|
|
844
|
-
deferreds[i].resolve(new Uint8Array(await r.arrayBuffer()));
|
|
896
|
+
deferreds[i].resolve(await resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]));
|
|
845
897
|
}
|
|
846
898
|
catch (e) {
|
|
847
899
|
deferreds[i].reject(e);
|
|
@@ -907,11 +959,17 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
907
959
|
return null;
|
|
908
960
|
}
|
|
909
961
|
}
|
|
910
|
-
/** 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). */
|
|
911
|
-
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) {
|
|
912
964
|
if ("inline" in carrier)
|
|
913
965
|
return fromBase64url(carrier.inline, "carriers.inline");
|
|
914
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
|
+
}
|
|
915
973
|
if (r.status === 404)
|
|
916
974
|
return null;
|
|
917
975
|
if (!r.ok)
|
|
@@ -924,11 +982,16 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
924
982
|
* resolved to raw bytes here, verified nowhere here (see the interface's
|
|
925
983
|
* own doc comment on {@link GitvaultTransport.getState}).
|
|
926
984
|
*/
|
|
927
|
-
async function getVaultStateOut(repoId, since) {
|
|
928
|
-
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" });
|
|
929
992
|
const head = raw.head ? { stored_bytes: fromBase64url(raw.head.stored_bytes, "head.stored_bytes"), stored_bytes_sha256: raw.head.stored_bytes_sha256 } : null;
|
|
930
993
|
const carriers = raw.carriers
|
|
931
|
-
? { 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) }
|
|
932
995
|
: null;
|
|
933
996
|
// Delta decode is deliberately forgiving: a malformed entry is dropped,
|
|
934
997
|
// never thrown — the delta is an accelerator and the ordinary reads own
|
|
@@ -948,7 +1011,63 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
948
1011
|
delta = null;
|
|
949
1012
|
}
|
|
950
1013
|
}
|
|
951
|
-
|
|
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 } : {}) };
|
|
952
1071
|
}
|
|
953
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. */
|
|
954
1073
|
function receiptsFromFinalize(objects, entries, fin) {
|
|
@@ -961,14 +1080,20 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
961
1080
|
return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
|
|
962
1081
|
});
|
|
963
1082
|
}
|
|
964
|
-
async function upload(repoId, objects, resourceBinding) {
|
|
1083
|
+
async function upload(repoId, objects, resourceBinding, byo) {
|
|
965
1084
|
if (objects.length === 0)
|
|
966
1085
|
return [];
|
|
967
1086
|
// The manifest is closed-key: `path` is client-local and MUST NOT ride the
|
|
968
1087
|
// wire — the control plane derives the bucket key itself and refuses an
|
|
969
1088
|
// entry carrying an unexpected member.
|
|
970
1089
|
const entries = objects.map((o) => gitvaultManifestEntry(o));
|
|
971
|
-
|
|
1090
|
+
// gitvault-byo-primary-bucket task 3.2: the inline (bytes-in-body) fast
|
|
1091
|
+
// path is NEVER used for a BYO vault, even when the payload is small
|
|
1092
|
+
// enough to be eligible — that transport writes bytes into run402's own
|
|
1093
|
+
// bucket, exactly what the credential model forbids (the gateway
|
|
1094
|
+
// refuses it too, VALIDATION_FAILED, so skipping it here is purely a
|
|
1095
|
+
// wasted-round-trip avoidance, not a correctness dependency).
|
|
1096
|
+
if (!byo && gitvaultInlineUploadEligible(objects)) {
|
|
972
1097
|
// gitvault-composite-state-read design D2: every object fits under the
|
|
973
1098
|
// caps — one POST, bytes verified + written server-side, and the
|
|
974
1099
|
// response IS the finalize response (no session, no PUTs, no separate
|
|
@@ -987,6 +1112,14 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
987
1112
|
context: "opening gitvault upload session",
|
|
988
1113
|
});
|
|
989
1114
|
const issued = new Map(session.objects.map((u) => [gitvaultLedgerId(u), u]));
|
|
1115
|
+
// gitvault-byo-primary-bucket task 3.2 — one backend, opened ONCE, for
|
|
1116
|
+
// every BYO payload object this session names; `null` `put` never
|
|
1117
|
+
// appears in the session response unless `byo` was supplied (the
|
|
1118
|
+
// gateway only mints `put: null` for a vault whose storage_profile is
|
|
1119
|
+
// "byo", which is exactly when THIS caller must have supplied `byo`
|
|
1120
|
+
// too — see the resolution in `GitvaultVault.uploadAll`).
|
|
1121
|
+
const byoBackend = byo ? openGitvaultDestinationBackend(byo.destination, byo.credential) : null;
|
|
1122
|
+
const byoAttestations = [];
|
|
990
1123
|
// Independent create-only PUTs within one session (design D2) — bounded
|
|
991
1124
|
// concurrency, same limit as the batched object reads below.
|
|
992
1125
|
await mapBounded(objects, GITVAULT_TRANSPORT_CONCURRENCY, async (o, i) => {
|
|
@@ -994,6 +1127,36 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
994
1127
|
const target = issued.get(id);
|
|
995
1128
|
if (!target)
|
|
996
1129
|
fail("GITVAULT_UPLOAD_SESSION_INVALID", `the session issued no upload for ${id}`, "uploading gitvault objects", { object_id: id, path: o.path });
|
|
1130
|
+
if (target.put === null) {
|
|
1131
|
+
// BYO payload object (task 2.2's GITVAULT_BYO_PAYLOAD_KINDS
|
|
1132
|
+
// partition, mirrored client-side by the server's own `put: null` +
|
|
1133
|
+
// `key` shape) — the client writes DIRECTLY to its own bucket. No
|
|
1134
|
+
// request to run402 ever carries a customer-bucket credential; no
|
|
1135
|
+
// request to run402 ever carries these bytes either.
|
|
1136
|
+
if (!byoBackend || !target.key) {
|
|
1137
|
+
fail("GITVAULT_BYO_BUCKET_WRITE_REFUSED", `the session named ${o.path} as a BYO-written object but no BYO write target was resolved`, "uploading gitvault objects", { path: o.path, object_id: id });
|
|
1138
|
+
}
|
|
1139
|
+
let put;
|
|
1140
|
+
try {
|
|
1141
|
+
put = await byoBackend.putCreateOnly(target.key, o.bytes);
|
|
1142
|
+
}
|
|
1143
|
+
catch (e) {
|
|
1144
|
+
fail("GITVAULT_BYO_BUCKET_WRITE_REFUSED", `writing ${o.path} to the BYO destination failed: ${e instanceof Error ? e.message : String(e)}`, "uploading gitvault objects", { path: o.path, key: target.key });
|
|
1145
|
+
}
|
|
1146
|
+
if (!put.created) {
|
|
1147
|
+
// create-only: the key already exists — legal ONLY if it is
|
|
1148
|
+
// byte-identical (an idempotent retry of THIS SAME write; a
|
|
1149
|
+
// permanent object id is never legitimately reused for
|
|
1150
|
+
// different bytes — matches the managed presigned-PUT read-and-
|
|
1151
|
+
// compare branch below, and the gateway's own attestation rule).
|
|
1152
|
+
const existing = await byoBackend.get(target.key);
|
|
1153
|
+
if (!existing || sha256Hex(existing) !== o.sha256) {
|
|
1154
|
+
fail("GITVAULT_BYO_BUCKET_WRITE_REFUSED", `${o.path} already exists in the BYO destination with different bytes`, "uploading gitvault objects", { path: o.path, key: target.key });
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
byoAttestations.push({ key: target.key, sha256: o.sha256, size_bytes: o.size_bytes, create_only_result: "created" });
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
997
1160
|
// `If-None-Match: *` (create-only) and the FULL_OBJECT `x-amz-checksum-sha256`
|
|
998
1161
|
// are SIGNED INTO the presigned URL, so they must go out exactly as the
|
|
999
1162
|
// server issued them — a dropped or altered header is a signature
|
|
@@ -1015,7 +1178,13 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1015
1178
|
if (!r.ok)
|
|
1016
1179
|
fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
|
|
1017
1180
|
});
|
|
1018
|
-
const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, {
|
|
1181
|
+
const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, {
|
|
1182
|
+
method: "POST",
|
|
1183
|
+
// Only a BYO session ever carries attestations — an ordinary managed
|
|
1184
|
+
// session's finalize body stays `{}`, byte-identical to today.
|
|
1185
|
+
body: byo ? { attestations: byoAttestations } : {},
|
|
1186
|
+
context: "finalizing gitvault upload session",
|
|
1187
|
+
});
|
|
1019
1188
|
return receiptsFromFinalize(objects, entries, fin);
|
|
1020
1189
|
}
|
|
1021
1190
|
async function admit(repoId, generation, bytes, hash, extra = {}) {
|
|
@@ -1046,12 +1215,12 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1046
1215
|
return res.allocation ?? res;
|
|
1047
1216
|
},
|
|
1048
1217
|
async putObject(request) {
|
|
1049
|
-
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 }]);
|
|
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);
|
|
1050
1219
|
return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
|
|
1051
1220
|
},
|
|
1052
|
-
getObject: ({ repo_id, path }) => getObjectBytes(repo_id, path),
|
|
1053
|
-
getObjects: ({ repo_id, paths }) => getObjectsBytes(repo_id, paths),
|
|
1054
|
-
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),
|
|
1055
1224
|
getHeads: ({ repo_id, generations }) => getHeadsBytes(repo_id, generations),
|
|
1056
1225
|
async admitGenesis(request) {
|
|
1057
1226
|
try {
|
|
@@ -1075,7 +1244,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1075
1244
|
qs.set("cursor", request.cursor);
|
|
1076
1245
|
return client.request(`${base(request.repo_id)}/heads?${qs.toString()}`, { context: "listing gitvault heads" });
|
|
1077
1246
|
},
|
|
1078
|
-
uploadObjects: ({ repo_id, objects, resource_binding }) => upload(repo_id, objects, resource_binding),
|
|
1247
|
+
uploadObjects: ({ repo_id, objects, resource_binding, byo }) => upload(repo_id, objects, resource_binding, byo),
|
|
1079
1248
|
admitHead: (r) => admit(r.repo_id, r.generation, r.stored_bytes, r.stored_bytes_sha256),
|
|
1080
1249
|
// Both routes SHIPPED (`routes/gitvault-admission.ts`). `retention-cutoffs`
|
|
1081
1250
|
// answers `{ticket, receipt, next_actions}`, which IS
|
|
@@ -1100,7 +1269,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1100
1269
|
return { cleared: r.advisory_cleared ?? r.cleared ?? false };
|
|
1101
1270
|
},
|
|
1102
1271
|
getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the gitvault record" }),
|
|
1103
|
-
getState: ({ repo_id, since }) => getVaultStateOut(repo_id, since),
|
|
1272
|
+
getState: ({ repo_id, since, restore }) => getVaultStateOut(repo_id, since, restore),
|
|
1104
1273
|
openCompactionGrant: ({ repo_id }) => client.request(`${base(repo_id)}/compaction-grant`, { method: "POST", context: "opening the gitvault compaction headroom grant" }),
|
|
1105
1274
|
closeCompactionGrant: async ({ repo_id }) => {
|
|
1106
1275
|
const r = await client.request(`${base(repo_id)}/compaction-grant`, { method: "DELETE", context: "closing the gitvault compaction headroom grant" });
|
|
@@ -1410,9 +1579,31 @@ export class GitvaultVault {
|
|
|
1410
1579
|
* consumed and cleared by the next restore).
|
|
1411
1580
|
*/
|
|
1412
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;
|
|
1413
1594
|
retries;
|
|
1414
1595
|
servicePublicKey;
|
|
1415
1596
|
genesisCache = null;
|
|
1597
|
+
/**
|
|
1598
|
+
* gitvault-byo-primary-bucket task 3.2 — this vault's resolved BYO write
|
|
1599
|
+
* target, cached after the first resolution (storage_profile/
|
|
1600
|
+
* byo_destination are immutable-at-allocation in v1, so a single
|
|
1601
|
+
* read-once-per-instance is safe). `undefined` = not yet resolved;
|
|
1602
|
+
* `null` = this machine has no LOCAL BYO write config for this repo
|
|
1603
|
+
* (either an ordinary managed vault, or a BYO vault this machine has not
|
|
1604
|
+
* been configured to write — see {@link resolveByoWriteTarget}).
|
|
1605
|
+
*/
|
|
1606
|
+
byoResolution = undefined;
|
|
1416
1607
|
constructor(options) {
|
|
1417
1608
|
this.keystore = options.keystore;
|
|
1418
1609
|
this.transport = options.transport;
|
|
@@ -1588,7 +1779,7 @@ export class GitvaultVault {
|
|
|
1588
1779
|
* comment). A wrong or absent byte this method wrote is therefore just a
|
|
1589
1780
|
* cache MISS on the next read, never a verification bypass.
|
|
1590
1781
|
*/
|
|
1591
|
-
async tryStateFastPath(pin, deltaSince) {
|
|
1782
|
+
async tryStateFastPath(pin, deltaSince, restore) {
|
|
1592
1783
|
// gitvault-delta-fetch: carry the caller's MATERIALIZED position as
|
|
1593
1784
|
// `since` — a capable gateway answers small spans with the heads +
|
|
1594
1785
|
// inline WAL packs in this same response; every other gateway/span
|
|
@@ -1598,7 +1789,15 @@ export class GitvaultVault {
|
|
|
1598
1789
|
// keystore, so pin-as-since would report "current" for a standing clone
|
|
1599
1790
|
// that is generations behind — exactly the multi-checkout shape the
|
|
1600
1791
|
// bench runs.
|
|
1601
|
-
|
|
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 });
|
|
1602
1801
|
// §6.4: the vault's newest generation may never fall below the
|
|
1603
1802
|
// authenticated pin — checked here regardless of eligibility below, so
|
|
1604
1803
|
// a regressed vault is caught exactly as loudly as it always was, even
|
|
@@ -1618,6 +1817,13 @@ export class GitvaultVault {
|
|
|
1618
1817
|
// is actually genesis's bytes.
|
|
1619
1818
|
if (state.delta)
|
|
1620
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;
|
|
1621
1827
|
const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation === GITVAULT_GENESIS_GENERATION;
|
|
1622
1828
|
const pinBig = generationToBigInt(pin.generation);
|
|
1623
1829
|
const newestBig = noOrdinaryHeadYet ? 0n : generationToBigInt(state.newest_generation);
|
|
@@ -1750,7 +1956,10 @@ export class GitvaultVault {
|
|
|
1750
1956
|
// own epoch could not be resolved without the network read this path
|
|
1751
1957
|
// exists to avoid) — the caller falls straight through to the UNCHANGED
|
|
1752
1958
|
// readHead + listHeads flow below, byte-identical to before this change.
|
|
1753
|
-
|
|
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);
|
|
1754
1963
|
let lastHead = fastPath ? fastPath.pinnedHead : pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
|
|
1755
1964
|
const anchor = pin.generation;
|
|
1756
1965
|
let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH);
|
|
@@ -1928,7 +2137,7 @@ export class GitvaultVault {
|
|
|
1928
2137
|
}
|
|
1929
2138
|
}
|
|
1930
2139
|
if (carriers.length > 1) {
|
|
1931
|
-
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) });
|
|
1932
2141
|
for (let i = 0; i < carriers.length; i++) {
|
|
1933
2142
|
const bytes = fetched[i] ?? null;
|
|
1934
2143
|
if (bytes && sha256Hex(bytes) === carriers[i].sha)
|
|
@@ -2194,7 +2403,7 @@ export class GitvaultVault {
|
|
|
2194
2403
|
const cacheable = kind === "ref_state" || kind === "retention_roots";
|
|
2195
2404
|
const cached = cacheable ? this.keystore.readCachedCarrier(this.repoId, receipt.object_id) : null;
|
|
2196
2405
|
const cacheHit = cached && sha256Hex(cached.bytes) === receipt.ciphertext_sha256;
|
|
2197
|
-
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 });
|
|
2198
2407
|
const object = this.decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride);
|
|
2199
2408
|
if (cacheable && !cacheHit && frame && typeof object.generation === "string") {
|
|
2200
2409
|
this.keystore.writeCachedCarrier(this.repoId, receipt.object_id, object.generation, receipt.ciphertext_sha256, frame);
|
|
@@ -2224,11 +2433,16 @@ export class GitvaultVault {
|
|
|
2224
2433
|
const preRoots = !rootsHit ? (this.walkPrefetch?.get(rootsPath) ?? null) : null;
|
|
2225
2434
|
const rootsPre = preRoots && sha256Hex(preRoots) === rootsReceipt.ciphertext_sha256 ? preRoots : null;
|
|
2226
2435
|
const missingPaths = [];
|
|
2227
|
-
|
|
2436
|
+
const missingExpected = [];
|
|
2437
|
+
if (!refStateHit && !refStatePre) {
|
|
2228
2438
|
missingPaths.push(refStatePath);
|
|
2229
|
-
|
|
2439
|
+
missingExpected.push(refStateReceipt.ciphertext_sha256);
|
|
2440
|
+
}
|
|
2441
|
+
if (!rootsHit && !rootsPre) {
|
|
2230
2442
|
missingPaths.push(rootsPath);
|
|
2231
|
-
|
|
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 }) : [];
|
|
2232
2446
|
let next = 0;
|
|
2233
2447
|
const refStateFrame = refStateHit ? cachedRefState.bytes : (refStatePre ?? fetched[next++] ?? null);
|
|
2234
2448
|
const rootsFrame = rootsHit ? cachedRoots.bytes : (rootsPre ?? fetched[next++] ?? null);
|
|
@@ -2258,7 +2472,7 @@ export class GitvaultVault {
|
|
|
2258
2472
|
*/
|
|
2259
2473
|
async materialize(options = {}) {
|
|
2260
2474
|
const persist = options.persist ?? true;
|
|
2261
|
-
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 });
|
|
2262
2476
|
if (!state.head) {
|
|
2263
2477
|
if (persist)
|
|
2264
2478
|
this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
|
|
@@ -2379,12 +2593,14 @@ export class GitvaultVault {
|
|
|
2379
2593
|
created_at: formatGitvaultTimestamp(this.now()),
|
|
2380
2594
|
});
|
|
2381
2595
|
try {
|
|
2596
|
+
const byo = await this.resolveByoWriteTarget();
|
|
2382
2597
|
await this.transport.putObject({
|
|
2383
2598
|
repo_id: this.repoId,
|
|
2384
2599
|
path: gitvaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint),
|
|
2385
2600
|
bytes: sealed.stored_bytes,
|
|
2386
2601
|
expected_sha256: sealed.stored_bytes_sha256,
|
|
2387
2602
|
expected_size_bytes: sealed.size_bytes,
|
|
2603
|
+
...(byo ? { byo } : {}),
|
|
2388
2604
|
});
|
|
2389
2605
|
wrapped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint });
|
|
2390
2606
|
}
|
|
@@ -2657,7 +2873,7 @@ export class GitvaultVault {
|
|
|
2657
2873
|
fail("CHECKPOINT_INCOMPLETE", `head ${head.generation} carries no checkpoint to verify`, "verifying a stored checkpoint", { generation: head.generation });
|
|
2658
2874
|
const { genesis } = await this.genesis();
|
|
2659
2875
|
const writerKey = genesis.creator_signing_pubkey;
|
|
2660
|
-
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 });
|
|
2661
2877
|
if (!claimBytes || sha256Hex(claimBytes) !== block.claim_set.stored_bytes_sha256) {
|
|
2662
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 });
|
|
2663
2879
|
}
|
|
@@ -2673,7 +2889,7 @@ export class GitvaultVault {
|
|
|
2673
2889
|
try {
|
|
2674
2890
|
await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
|
|
2675
2891
|
for (const p of manifest.packs) {
|
|
2676
|
-
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 });
|
|
2677
2893
|
if (!frame)
|
|
2678
2894
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} is absent from storage`, "verifying a stored checkpoint", { object_id: p.object_id });
|
|
2679
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 });
|
|
@@ -2737,7 +2953,7 @@ export class GitvaultVault {
|
|
|
2737
2953
|
// Plaintext-structured and stored-bytes-receipted: no decryption, but
|
|
2738
2954
|
// the hash and the owner signature are still checked before a single
|
|
2739
2955
|
// pack receipt inside it is believed.
|
|
2740
|
-
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 });
|
|
2741
2957
|
if (!bytes || sha256Hex(bytes) !== block.claim_set.stored_bytes_sha256) {
|
|
2742
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 });
|
|
2743
2959
|
}
|
|
@@ -2751,8 +2967,27 @@ export class GitvaultVault {
|
|
|
2751
2967
|
return out;
|
|
2752
2968
|
}
|
|
2753
2969
|
// ── upload with receipt-compare ──
|
|
2970
|
+
/**
|
|
2971
|
+
* gitvault-byo-primary-bucket task 3.2 — resolve (once, cached) this
|
|
2972
|
+
* machine's local BYO write config for THIS vault. `null` when none is
|
|
2973
|
+
* configured locally — either because this is an ordinary managed vault
|
|
2974
|
+
* (the common case; deliberately never confirmed with a network call, so
|
|
2975
|
+
* a managed vault's push pays zero extra round trips), or because this
|
|
2976
|
+
* machine has not been configured to write a BYO vault it nonetheless
|
|
2977
|
+
* belongs to (surfaced downstream as `GITVAULT_BYO_BUCKET_WRITE_REFUSED`
|
|
2978
|
+
* when the session actually names a `put: null` object with no target).
|
|
2979
|
+
*/
|
|
2980
|
+
async resolveByoWriteTarget() {
|
|
2981
|
+
if (this.byoResolution !== undefined)
|
|
2982
|
+
return this.byoResolution;
|
|
2983
|
+
const { readByoConfig } = await import("./gitvault-byo-config.js");
|
|
2984
|
+
const local = readByoConfig(this.keystore, this.repoId);
|
|
2985
|
+
this.byoResolution = local ? { destination: local.destination, ...(local.credential ? { credential: local.credential } : {}) } : null;
|
|
2986
|
+
return this.byoResolution;
|
|
2987
|
+
}
|
|
2754
2988
|
async uploadAll(objects) {
|
|
2755
|
-
const
|
|
2989
|
+
const byo = await this.resolveByoWriteTarget();
|
|
2990
|
+
const receipts = await this.transport.uploadObjects({ repo_id: this.repoId, objects, ...(byo ? { byo } : {}) });
|
|
2756
2991
|
if (receipts.length !== objects.length)
|
|
2757
2992
|
fail("GITVAULT_RECEIPT_MISMATCH", `${receipts.length} receipts for ${objects.length} objects`, "comparing finalization receipts");
|
|
2758
2993
|
const mismatches = objects.filter((o, i) => receipts[i].path !== o.path || receipts[i].sha256 !== o.sha256 || receipts[i].size_bytes !== o.size_bytes);
|
|
@@ -3078,7 +3313,7 @@ export class GitvaultVault {
|
|
|
3078
3313
|
if (known && known.pin_manifest_version === receipt.pin_manifest_version && known.stored_bytes_sha256 === receipt.stored_bytes_sha256) {
|
|
3079
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])) };
|
|
3080
3315
|
}
|
|
3081
|
-
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 });
|
|
3082
3317
|
if (!bytes || sha256Hex(bytes) !== receipt.stored_bytes_sha256) {
|
|
3083
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 });
|
|
3084
3319
|
}
|
|
@@ -3658,7 +3893,16 @@ export class GitvaultVault {
|
|
|
3658
3893
|
// reuse therefore costs one extra read, never a wrong result.
|
|
3659
3894
|
const marker = await readGitvaultRestoreMarker(targetRepoDir);
|
|
3660
3895
|
const markerMatches = (a, b) => a === null ? b === null : b !== null && a.generation === b.generation && a.head_sha256 === b.head_sha256;
|
|
3661
|
-
|
|
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 }) });
|
|
3662
3906
|
if (!newest.head) {
|
|
3663
3907
|
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: {}, roots: [], head_target: newest.head_target });
|
|
3664
3908
|
return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
@@ -3676,6 +3920,17 @@ export class GitvaultVault {
|
|
|
3676
3920
|
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
|
|
3677
3921
|
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
3678
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
|
+
}
|
|
3679
3934
|
// Walk back from newest, trying for an INCREMENTAL boundary at the
|
|
3680
3935
|
// marker (a pure local prev_sha256 comparison — no fetch needed to
|
|
3681
3936
|
// CONFIRM the boundary itself); abandon incremental the moment a
|
|
@@ -3750,6 +4005,27 @@ export class GitvaultVault {
|
|
|
3750
4005
|
incremental = false;
|
|
3751
4006
|
heads.length = 0;
|
|
3752
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;
|
|
3753
4029
|
continue;
|
|
3754
4030
|
}
|
|
3755
4031
|
heads.unshift(cur);
|
|
@@ -3773,6 +4049,32 @@ export class GitvaultVault {
|
|
|
3773
4049
|
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
|
|
3774
4050
|
cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
3775
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) {
|
|
3776
4078
|
// gitvault-clone-scaling (P3): a WHOLESALE walk just stopped at the
|
|
3777
4079
|
// newest checkpoint-bearing head, or proved there is none back to
|
|
3778
4080
|
// genesis — that is first-hand checkpoint coverage, and recording it is
|
|
@@ -3798,22 +4100,24 @@ export class GitvaultVault {
|
|
|
3798
4100
|
// map `materialize()` just resolved (throwing `GITVAULT_EPOCH_NOT_OPENABLE`
|
|
3799
4101
|
// fail-closed if any needed epoch could not be opened), so every lookup
|
|
3800
4102
|
// below is guaranteed present.
|
|
3801
|
-
const kRepoForEpoch = (epoch) =>
|
|
3802
|
-
const hex = newest.epoch_keys_hex[epoch];
|
|
3803
|
-
if (!hex)
|
|
3804
|
-
fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring gitvault objects", { epoch });
|
|
3805
|
-
return hexToBytes(hex);
|
|
3806
|
-
};
|
|
4103
|
+
const kRepoForEpoch = (epoch) => this.epochKeyFor(newest, epoch);
|
|
3807
4104
|
const first = heads[0];
|
|
3808
4105
|
// gitvault-pipelined-restore: per-object settlement when the transport
|
|
3809
4106
|
// offers it, the `getObjects` barrier otherwise — pipelining is a
|
|
3810
4107
|
// wall-clock property, never a correctness dependency, so a transport
|
|
3811
4108
|
// without the method reproduces today's serial-after-barrier behavior
|
|
3812
4109
|
// exactly (each per-index promise settles when the whole batch does).
|
|
3813
|
-
|
|
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) => {
|
|
3814
4118
|
if (this.transport.getObjectsSettled)
|
|
3815
|
-
return this.transport.getObjectsSettled({ repo_id: this.repoId, paths });
|
|
3816
|
-
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 });
|
|
3817
4121
|
const perIndex = paths.map((_, i) => all.then((frames) => frames[i] ?? null));
|
|
3818
4122
|
// Mark every derived promise handled — the consumer awaits them in
|
|
3819
4123
|
// order and stops at the first failure, abandoning the tail.
|
|
@@ -3835,7 +4139,12 @@ export class GitvaultVault {
|
|
|
3835
4139
|
// skip the network ENTIRELY — but only after hashing to their carrying
|
|
3836
4140
|
// head's receipt (a lying delta is a plain miss; the ordinary fetch
|
|
3837
4141
|
// below reproduces the unbatched behavior for that slot). The buffer is
|
|
3838
|
-
// 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).
|
|
3839
4148
|
const deltaPacks = this.stateDeltaPacks;
|
|
3840
4149
|
this.stateDeltaPacks = null;
|
|
3841
4150
|
const deltaCovered = new Map();
|
|
@@ -3846,31 +4155,62 @@ export class GitvaultVault {
|
|
|
3846
4155
|
deltaCovered.set(i, bytes);
|
|
3847
4156
|
});
|
|
3848
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
|
+
}
|
|
3849
4167
|
const uncovered = walEntries.map((_, i) => i).filter((i) => !deltaCovered.has(i));
|
|
3850
4168
|
const walFramesP = (async () => {
|
|
3851
|
-
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)) : [];
|
|
3852
4170
|
return walEntries.map((_, i) => (deltaCovered.has(i) ? Promise.resolve(deltaCovered.get(i)) : fetched[uncovered.indexOf(i)]));
|
|
3853
4171
|
})();
|
|
3854
4172
|
void walFramesP.catch(() => { });
|
|
3855
4173
|
if (first.checkpoint) {
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
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
|
+
}
|
|
3864
4192
|
// Design D2 + gitvault-pipelined-restore D1: every checkpoint pack is
|
|
3865
4193
|
// independent — one batched presign for all of them, applied via
|
|
3866
4194
|
// index-pack strictly in manifest order, PIPELINED: apply(i) awaits
|
|
3867
4195
|
// bytes(i), so decrypt/verify/index of an early pack overlaps the
|
|
3868
4196
|
// later packs' downloads. Per-pack verification (AEAD open + plaintext
|
|
3869
|
-
// hash) still completes before any byte reaches git.
|
|
3870
|
-
|
|
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)) : [];
|
|
3871
4211
|
for (let i = 0; i < manifest.packs.length; i++) {
|
|
3872
4212
|
const p = manifest.packs[i];
|
|
3873
|
-
const frame = (await
|
|
4213
|
+
const frame = checkpointCovered.has(i) ? checkpointCovered.get(i) : ((await ckFrames[uncoveredCk.indexOf(i)]) ?? null);
|
|
3874
4214
|
if (!frame)
|
|
3875
4215
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
|
|
3876
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 });
|
|
@@ -3918,6 +4258,152 @@ export class GitvaultVault {
|
|
|
3918
4258
|
await writeGitvaultRestoreMarker(targetRepoDir, newest.generation, newest.head_sha256);
|
|
3919
4259
|
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
3920
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
|
+
}
|
|
3921
4407
|
// ── compaction headroom grant (gitvault-checkpoint-cadence design D3) ──
|
|
3922
4408
|
/** Thin passthrough to the transport — see {@link GitvaultTransport.openCompactionGrant}. */
|
|
3923
4409
|
async openCompactionGrant() {
|