knodin 0.8.4 → 0.8.6

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.
Files changed (34) hide show
  1. package/dist/bin/cli.js +272 -24
  2. package/dist/src/cli-model.js +26 -0
  3. package/dist/src/engine/candidate-database.js +167 -0
  4. package/dist/src/engine/embeddings.js +8 -5
  5. package/dist/src/engine/index.js +1244 -305
  6. package/dist/src/engine/prune.js +8 -0
  7. package/dist/src/file-metrics.js +153 -0
  8. package/dist/src/repair-lease.js +8 -5
  9. package/dist/src/response-budget.js +2 -1
  10. package/dist/src/shared-index/artifact.js +65 -0
  11. package/dist/src/shared-index/cache.js +267 -0
  12. package/dist/src/shared-index/compatibility.js +70 -0
  13. package/dist/src/shared-index/config.js +224 -0
  14. package/dist/src/shared-index/contract.js +117 -0
  15. package/dist/src/shared-index/manifest.js +71 -0
  16. package/dist/src/shared-index/opportunistic.js +37 -0
  17. package/dist/src/shared-index/overlay.js +98 -0
  18. package/dist/src/shared-index/provenance.js +149 -0
  19. package/dist/src/shared-index/publisher.js +198 -0
  20. package/dist/src/shared-index/restore.js +209 -0
  21. package/dist/src/shared-index/s3-client.js +124 -0
  22. package/dist/src/shared-index/schemas.js +86 -0
  23. package/dist/src/shared-index/selection.js +111 -0
  24. package/dist/src/tools/knodin-tools.js +70 -3
  25. package/docs/CLI.md +39 -0
  26. package/docs/MCP.md +12 -0
  27. package/docs/SHARED-INDEX-CONTRACT.md +132 -0
  28. package/docs/releases/0.8.5.md +25 -0
  29. package/docs/releases/0.8.6.md +10 -0
  30. package/package.json +12 -3
  31. package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
  32. package/schemas/shared-index-config-v1.schema.json +97 -0
  33. package/schemas/shared-index-manifest-v1.schema.json +104 -0
  34. package/schemas/shared-index-provenance-v1.schema.json +100 -0
@@ -0,0 +1,224 @@
1
+ import child_process from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { parseDocument, stringify } from "yaml";
6
+ import { z } from "zod";
7
+ import { canonicalRepositoryIdentity, sharedBranchMatches, } from "./contract.js";
8
+ export const SHARED_INDEX_CONFIG_PATH = ".knodin/shared-index.yaml";
9
+ const repositorySchema = z
10
+ .object({
11
+ host: z.string().min(1).max(253),
12
+ owner: z.string().min(1).max(255),
13
+ name: z.string().min(1).max(255),
14
+ })
15
+ .strict();
16
+ const canonicalHost = /^(?!.*\.\.)(?!.*(?:^|\.)-)(?!.*-(?:\.|$))[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/;
17
+ const canonicalOwner = /^[a-z0-9](?:[a-z0-9-]{0,253}[a-z0-9])?$/;
18
+ const canonicalName = /^[a-z0-9](?:[a-z0-9._-]{0,253}[a-z0-9])?$/;
19
+ const configSchema = z
20
+ .object({
21
+ schemaVersion: z.literal(1),
22
+ enabled: z.boolean(),
23
+ registrationId: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,127}$/),
24
+ aws: z
25
+ .object({
26
+ region: z.string().regex(/^[a-z]{2}(?:-gov)?-[a-z]+-\d$/),
27
+ bucket: z
28
+ .string()
29
+ .regex(/^(?!xn--)(?!.*\.\.)(?!.*\.-)(?!.*-\.)(?!\d{1,3}(?:\.\d{1,3}){3}$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/),
30
+ })
31
+ .strict(),
32
+ repository: repositorySchema,
33
+ branches: z.array(z.string().min(1).max(255)).min(1).max(64),
34
+ signing: z
35
+ .object({
36
+ algorithm: z.literal("RSASSA_PSS_SHA_256"),
37
+ keyId: z
38
+ .string()
39
+ .max(2048)
40
+ .regex(/^(?:arn:(?:aws|aws-us-gov|aws-cn):kms:[a-z0-9-]+:\d{12}:(?:key\/[A-Za-z0-9-]+|alias\/[A-Za-z0-9/_+=,.@-]+)|alias\/[A-Za-z0-9/_+=,.@-]+)$/),
41
+ publicKeySha256: z.string().regex(/^[a-f0-9]{64}$/),
42
+ publicKeyPem: z.string().min(1).max(16_384),
43
+ })
44
+ .strict(),
45
+ })
46
+ .strict();
47
+ function boundedError(error) {
48
+ if (error instanceof z.ZodError)
49
+ return error.issues
50
+ .slice(0, 16)
51
+ .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
52
+ .join("; ");
53
+ if (error instanceof Error)
54
+ return error.message.slice(0, 512);
55
+ return "invalid shared-index configuration";
56
+ }
57
+ function validateBranchPattern(pattern) {
58
+ if (pattern.startsWith("/") ||
59
+ pattern.endsWith("/") ||
60
+ pattern.includes("//") ||
61
+ pattern.includes("..") ||
62
+ pattern.includes("@{") ||
63
+ /[~^:?[\\]/.test(pattern))
64
+ return false;
65
+ const stars = [...pattern].filter((character) => character === "*").length;
66
+ return stars === 0 || (stars === 1 && pattern.endsWith("/*"));
67
+ }
68
+ export function publicKeyFingerprint(publicKeyPem) {
69
+ const key = crypto.createPublicKey(publicKeyPem);
70
+ if (key.asymmetricKeyType !== "rsa" && key.asymmetricKeyType !== "rsa-pss")
71
+ throw new Error("signing public key must be RSA");
72
+ const bits = key.asymmetricKeyDetails?.modulusLength;
73
+ if (!bits || bits < 2048 || bits > 8192)
74
+ throw new Error("signing RSA public key must have a 2048-8192 bit modulus");
75
+ const der = key.export({ type: "spki", format: "der" });
76
+ return crypto.createHash("sha256").update(der).digest("hex");
77
+ }
78
+ function parseRemote(remote) {
79
+ const trimmed = remote.trim();
80
+ let host;
81
+ let pathname;
82
+ try {
83
+ if (/^[^@/\s]+@[^:/\s]+:.+$/.test(trimmed)) {
84
+ const match = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(trimmed);
85
+ if (!match)
86
+ return null;
87
+ host = match[1];
88
+ pathname = match[2];
89
+ }
90
+ else {
91
+ const parsed = new URL(trimmed);
92
+ host = parsed.hostname;
93
+ pathname = parsed.pathname.replace(/^\//, "");
94
+ }
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ const segments = pathname.split("/").filter(Boolean);
100
+ if (segments.length !== 2)
101
+ return null;
102
+ return canonicalRepositoryIdentity({ host, owner: segments[0], name: segments[1] });
103
+ }
104
+ export function repositoryRemoteIdentities(repo) {
105
+ let output;
106
+ try {
107
+ output = child_process.execFileSync("git", ["remote", "-v"], {
108
+ cwd: repo,
109
+ encoding: "utf8",
110
+ stdio: ["ignore", "pipe", "ignore"],
111
+ });
112
+ }
113
+ catch {
114
+ return [];
115
+ }
116
+ const identities = new Map();
117
+ for (const line of output.split("\n")) {
118
+ const remote = line.trim().split(/\s+/)[1];
119
+ if (!remote)
120
+ continue;
121
+ const identity = parseRemote(remote);
122
+ if (!identity)
123
+ continue;
124
+ identities.set(`${identity.host}/${identity.owner}/${identity.name}`, identity);
125
+ }
126
+ return [...identities.values()];
127
+ }
128
+ export function validateSharedIndexConfig(value, repo) {
129
+ const parsed = configSchema.parse(value);
130
+ const repository = canonicalRepositoryIdentity(parsed.repository);
131
+ if (!canonicalHost.test(repository.host))
132
+ throw new Error("repository host is not canonical DNS syntax");
133
+ if (!canonicalOwner.test(repository.owner))
134
+ throw new Error("repository owner is invalid");
135
+ if (!canonicalName.test(repository.name))
136
+ throw new Error("repository name is invalid");
137
+ if (new Set(parsed.branches).size !== parsed.branches.length)
138
+ throw new Error("branches must not contain duplicates");
139
+ if (parsed.branches.some((branch) => !validateBranchPattern(branch)))
140
+ throw new Error("branch must be an exact Git ref or a terminal slash-star prefix pattern");
141
+ // Exercise the frozen matcher for every accepted pattern so validation and
142
+ // selection cannot silently drift into two different glob languages.
143
+ for (const branch of parsed.branches)
144
+ if (!branch.endsWith("/*") && !sharedBranchMatches(branch, branch))
145
+ throw new Error("invalid exact branch pattern");
146
+ const fingerprint = publicKeyFingerprint(parsed.signing.publicKeyPem);
147
+ if (!crypto.timingSafeEqual(Buffer.from(fingerprint, "hex"), Buffer.from(parsed.signing.publicKeySha256, "hex")))
148
+ throw new Error("signing public-key fingerprint does not match reviewed configuration");
149
+ if (repo) {
150
+ const remotes = repositoryRemoteIdentities(repo);
151
+ if (remotes.length === 0)
152
+ throw new Error("repository identity cannot be verified because no supported Git remote exists");
153
+ if (!remotes.some((remote) => JSON.stringify(remote) === JSON.stringify(repository)))
154
+ throw new Error("configured repository identity does not match any local Git remote");
155
+ }
156
+ return { ...parsed, repository };
157
+ }
158
+ export function loadSharedIndexConfig(repo) {
159
+ const target = path.join(path.resolve(repo), SHARED_INDEX_CONFIG_PATH);
160
+ if (!fs.existsSync(target))
161
+ return { state: "not-configured", path: target };
162
+ try {
163
+ const document = parseDocument(fs.readFileSync(target, "utf8"), {
164
+ strict: true,
165
+ uniqueKeys: true,
166
+ });
167
+ if (document.errors.length > 0)
168
+ throw new Error(document.errors.map((error) => error.message).join("; "));
169
+ const raw = document.toJS({ maxAliasCount: 0 });
170
+ return { state: "configured", path: target, config: validateSharedIndexConfig(raw, repo) };
171
+ }
172
+ catch (error) {
173
+ return { state: "invalid", path: target, errors: [boundedError(error)] };
174
+ }
175
+ }
176
+ function assertWritableTarget(repo, target) {
177
+ const state = path.dirname(target);
178
+ if (fs.existsSync(state) && fs.lstatSync(state).isSymbolicLink())
179
+ throw new Error("shared-index configuration directory must not be a symbolic link");
180
+ if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink())
181
+ throw new Error("shared-index configuration must not be a symbolic link");
182
+ const relative = path.relative(path.resolve(repo), path.resolve(target));
183
+ if (relative.startsWith("..") || path.isAbsolute(relative))
184
+ throw new Error("shared-index configuration must remain inside the repository");
185
+ }
186
+ export function writeSharedIndexConfig(repo, config) {
187
+ const resolved = path.resolve(repo);
188
+ const validated = validateSharedIndexConfig(config, resolved);
189
+ const target = path.join(resolved, SHARED_INDEX_CONFIG_PATH);
190
+ assertWritableTarget(resolved, target);
191
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
192
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
193
+ try {
194
+ fs.writeFileSync(temporary, stringify(validated, { lineWidth: 0 }), {
195
+ mode: 0o600,
196
+ flag: "wx",
197
+ });
198
+ const descriptor = fs.openSync(temporary, "r");
199
+ try {
200
+ fs.fsyncSync(descriptor);
201
+ }
202
+ finally {
203
+ fs.closeSync(descriptor);
204
+ }
205
+ fs.renameSync(temporary, target);
206
+ try {
207
+ const directory = fs.openSync(path.dirname(target), "r");
208
+ try {
209
+ fs.fsyncSync(directory);
210
+ }
211
+ finally {
212
+ fs.closeSync(directory);
213
+ }
214
+ }
215
+ catch (error) {
216
+ if (process.platform !== "win32")
217
+ throw error;
218
+ }
219
+ }
220
+ finally {
221
+ fs.rmSync(temporary, { force: true });
222
+ }
223
+ return target;
224
+ }
@@ -0,0 +1,117 @@
1
+ import { createHash } from "node:crypto";
2
+ export const SHARED_INDEX_SCHEMA_VERSION = 1;
3
+ export const SHARED_INDEX_SIGNATURE = {
4
+ algorithm: "RSA-PSS",
5
+ hash: "sha256",
6
+ mgf1Hash: "sha256",
7
+ saltLength: 32,
8
+ input: "canonical-manifest-bytes",
9
+ };
10
+ export const SHARED_INDEX_LIMITS = {
11
+ pointerBytes: 64 * 1024,
12
+ manifestBytes: 256 * 1024,
13
+ signatureBytes: 16 * 1024,
14
+ compressedBytes: 8 * 1024 ** 3,
15
+ uncompressedBytes: 32 * 1024 ** 3,
16
+ };
17
+ function normalizedComponent(value) {
18
+ return value.normalize("NFC").toLocaleLowerCase("en-US");
19
+ }
20
+ /** Canonical identity used for remote comparison, signatures, and S3 prefixes. */
21
+ export function canonicalRepositoryIdentity(identity) {
22
+ const host = identity.host
23
+ .replace(/^https?:\/\//i, "")
24
+ .replace(/^ssh:\/\/git@/i, "")
25
+ .replace(/\/$/, "")
26
+ .replace(/\.$/, "");
27
+ return {
28
+ host: normalizedComponent(host),
29
+ owner: normalizedComponent(identity.owner),
30
+ name: normalizedComponent(identity.name.replace(/\.git$/i, "")),
31
+ };
32
+ }
33
+ /** RFC 3986 component encoding (encodeURIComponent leaves five extra characters). */
34
+ export function encodeSharedIndexComponent(value) {
35
+ return encodeURIComponent(value.normalize("NFC")).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
36
+ }
37
+ export function sharedRepositoryPrefix(identity) {
38
+ const canonical = canonicalRepositoryIdentity(identity);
39
+ return `v1/${encodeSharedIndexComponent(canonical.host)}/${encodeSharedIndexComponent(canonical.owner)}/${encodeSharedIndexComponent(canonical.name)}/`;
40
+ }
41
+ function parsedVersion(version) {
42
+ const match = /^(\d+)\.(\d+)\.\d+(?:-([0-9A-Za-z.-]+))?$/.exec(version);
43
+ if (!match)
44
+ return null;
45
+ return { major: Number(match[1]), minor: Number(match[2]), prerelease: match[3] };
46
+ }
47
+ /** Exact v1 compatibility decision; every rejection is bounded and source-free. */
48
+ export function sharedIndexCompatibility(client, manifest) {
49
+ const reasons = [];
50
+ const clientVersion = parsedVersion(client.knodinVersion);
51
+ const manifestVersion = parsedVersion(manifest.knodinVersion);
52
+ if (!clientVersion || !manifestVersion)
53
+ reasons.push("invalid-knodin-version");
54
+ else if (clientVersion.prerelease || manifestVersion.prerelease) {
55
+ if (client.knodinVersion !== manifest.knodinVersion)
56
+ reasons.push("knodin-prerelease-mismatch");
57
+ }
58
+ else if (clientVersion.major !== manifestVersion.major ||
59
+ clientVersion.minor < manifestVersion.minor) {
60
+ reasons.push("knodin-version-incompatible");
61
+ }
62
+ const exactFields = [
63
+ "databaseSchemaVersion",
64
+ "modelId",
65
+ "modelDigest",
66
+ "parserBundleDigest",
67
+ "indexConfigDigest",
68
+ "prunePolicyDigest",
69
+ ];
70
+ for (const field of exactFields) {
71
+ if (client[field] !== manifest[field])
72
+ reasons.push(`${field}-mismatch`);
73
+ }
74
+ return { compatible: reasons.length === 0, reasons };
75
+ }
76
+ /** Exact v1 ref matching, including the terminal slash-star prefix form. */
77
+ export function sharedBranchMatches(pattern, branch) {
78
+ const normalizedPattern = pattern.normalize("NFC");
79
+ const normalizedBranch = branch.normalize("NFC");
80
+ return normalizedPattern.endsWith("/*")
81
+ ? normalizedBranch.startsWith(normalizedPattern.slice(0, -1)) &&
82
+ normalizedBranch.length > normalizedPattern.length - 1
83
+ : normalizedBranch === normalizedPattern;
84
+ }
85
+ function canonicalValue(value) {
86
+ if (typeof value === "string" &&
87
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value))
88
+ throw new TypeError("canonical JSON forbids unpaired Unicode surrogates");
89
+ if (value === null || typeof value === "boolean" || typeof value === "string")
90
+ return JSON.stringify(value);
91
+ if (typeof value === "number") {
92
+ if (!Number.isFinite(value))
93
+ throw new TypeError("canonical JSON forbids non-finite numbers");
94
+ return JSON.stringify(value);
95
+ }
96
+ if (Array.isArray(value))
97
+ return `[${value.map(canonicalValue).join(",")}]`;
98
+ if (typeof value === "object") {
99
+ const record = value;
100
+ const entries = Object.keys(record)
101
+ .sort((left, right) => left.localeCompare(right))
102
+ .map((key) => {
103
+ if (record[key] === undefined)
104
+ throw new TypeError("canonical JSON forbids undefined object values");
105
+ return `${JSON.stringify(key)}:${canonicalValue(record[key])}`;
106
+ });
107
+ return `{${entries.join(",")}}`;
108
+ }
109
+ throw new TypeError(`canonical JSON forbids ${typeof value} values`);
110
+ }
111
+ /** Exact UTF-8 bytes hashed and signed by shared-index v1. */
112
+ export function canonicalManifestBytes(manifest) {
113
+ return Buffer.from(canonicalValue(manifest), "utf8");
114
+ }
115
+ export function sha256Hex(bytes) {
116
+ return createHash("sha256").update(bytes).digest("hex");
117
+ }
@@ -0,0 +1,71 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { publicKeyFingerprint } from "./config.js";
4
+ import { canonicalManifestBytes, canonicalRepositoryIdentity, encodeSharedIndexComponent, SHARED_INDEX_LIMITS, sha256Hex, sharedIndexCompatibility, sharedRepositoryPrefix, } from "./contract.js";
5
+ import { SharedIndexError } from "./s3-client.js";
6
+ function sameIdentity(left, right) {
7
+ return (JSON.stringify(canonicalRepositoryIdentity(left)) ===
8
+ JSON.stringify(canonicalRepositoryIdentity(right)));
9
+ }
10
+ export function branchPointerKey(config, branch) {
11
+ return `${sharedRepositoryPrefix(config.repository)}branches/${encodeSharedIndexComponent(branch)}.json`;
12
+ }
13
+ export function signatureKey(manifestKey) {
14
+ if (!manifestKey.endsWith("/manifest.json"))
15
+ throw new SharedIndexError("integrity", "shared-index manifest key has an invalid filename");
16
+ return `${manifestKey.slice(0, -"manifest.json".length)}manifest.sig`;
17
+ }
18
+ export function compatibilityFingerprint(compatibility) {
19
+ return sha256Hex(canonicalManifestBytes(compatibility));
20
+ }
21
+ export function validatePointerBinding(config, pointer) {
22
+ if (pointer.registrationId !== config.registrationId)
23
+ throw new SharedIndexError("integrity", "shared-index pointer registration does not match configuration");
24
+ if (!sameIdentity(pointer.repository, config.repository))
25
+ throw new SharedIndexError("integrity", "shared-index pointer repository does not match configuration");
26
+ const directory = `${sharedRepositoryPrefix(config.repository)}snapshots/${pointer.commit}/${pointer.inputFingerprint}/`;
27
+ if (pointer.manifestKey !== `${directory}manifest.json`)
28
+ throw new SharedIndexError("integrity", "shared-index manifest key escaped its immutable snapshot prefix");
29
+ }
30
+ function verifyConfiguredTrustRoot(config) {
31
+ let fingerprint;
32
+ try {
33
+ fingerprint = publicKeyFingerprint(config.signing.publicKeyPem);
34
+ }
35
+ catch {
36
+ throw new SharedIndexError("integrity", "shared-index configured signing key is invalid");
37
+ }
38
+ const expected = Buffer.from(config.signing.publicKeySha256, "hex");
39
+ const actual = Buffer.from(fingerprint, "hex");
40
+ if (expected.byteLength !== actual.byteLength || !crypto.timingSafeEqual(expected, actual))
41
+ throw new SharedIndexError("integrity", "shared-index configured signing-key fingerprint does not match");
42
+ }
43
+ export function verifyManifest(config, pointer, rawManifest, manifest, signature, clientCompatibility) {
44
+ if (signature.byteLength === 0 || signature.byteLength > SHARED_INDEX_LIMITS.signatureBytes)
45
+ throw new SharedIndexError("integrity", "shared-index signature exceeds its byte limit");
46
+ verifyConfiguredTrustRoot(config);
47
+ if (sha256Hex(rawManifest) !== pointer.manifestSha256)
48
+ throw new SharedIndexError("integrity", "shared-index manifest digest does not match pointer");
49
+ if (!Buffer.from(rawManifest).equals(canonicalManifestBytes(manifest)))
50
+ throw new SharedIndexError("integrity", "shared-index manifest bytes are not canonical JSON v1");
51
+ const verified = crypto.verify("sha256", rawManifest, {
52
+ key: config.signing.publicKeyPem,
53
+ padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
54
+ saltLength: 32,
55
+ }, signature);
56
+ if (!verified)
57
+ throw new SharedIndexError("integrity", "shared-index manifest signature is invalid");
58
+ if (manifest.registrationId !== config.registrationId ||
59
+ !sameIdentity(manifest.repository, config.repository))
60
+ throw new SharedIndexError("integrity", "shared-index signed identity does not match configuration");
61
+ if (manifest.commit !== pointer.commit)
62
+ throw new SharedIndexError("integrity", "shared-index signed commit does not match pointer");
63
+ if (compatibilityFingerprint(manifest.compatibility) !== pointer.inputFingerprint)
64
+ throw new SharedIndexError("integrity", "shared-index compatibility fingerprint does not match pointer");
65
+ const immutableDirectory = path.posix.dirname(pointer.manifestKey);
66
+ if (manifest.graph.key !== `${immutableDirectory}/graph.sqlite.zst`)
67
+ throw new SharedIndexError("integrity", "shared-index graph key escaped its immutable snapshot prefix");
68
+ const compatibility = sharedIndexCompatibility(clientCompatibility, manifest.compatibility);
69
+ if (!compatibility.compatible)
70
+ throw new SharedIndexError("compatibility", `shared-index snapshot is incompatible (${compatibility.reasons.join(",")})`);
71
+ }
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs";
2
+ import { resolveDbPath } from "../engine/state-paths.js";
3
+ import { currentSharedIndexCompatibility } from "./compatibility.js";
4
+ import { loadSharedIndexConfig } from "./config.js";
5
+ import { readSharedIndexProvenance, readSharedRestoreAttempt } from "./provenance.js";
6
+ import { restoreSharedIndex } from "./restore.js";
7
+ import { createSharedObjectStore } from "./s3-client.js";
8
+ const FAILURE_BACKOFF_MS = 15 * 60 * 1000;
9
+ export function shouldAttemptOpportunisticRestore(repo, now = Date.now()) {
10
+ const configuration = loadSharedIndexConfig(repo);
11
+ if (configuration.state !== "configured" || !configuration.config.enabled)
12
+ return false;
13
+ if (fs.existsSync(resolveDbPath(repo)) && readSharedIndexProvenance(repo))
14
+ return false;
15
+ const last = readSharedRestoreAttempt(repo);
16
+ if (last?.outcome === "failed" && now - Date.parse(last.attemptedAt) < FAILURE_BACKOFF_MS)
17
+ return false;
18
+ return true;
19
+ }
20
+ /** Safe cold-start optimization: every failure returns to the caller for local fallback. */
21
+ export async function attemptOpportunisticSharedRestore(repo, engine) {
22
+ if (!shouldAttemptOpportunisticRestore(repo))
23
+ return null;
24
+ const configuration = loadSharedIndexConfig(repo);
25
+ if (configuration.state !== "configured")
26
+ return null;
27
+ const store = createSharedObjectStore(configuration.config);
28
+ if (!store)
29
+ return null;
30
+ return restoreSharedIndex({
31
+ repo,
32
+ config: configuration.config,
33
+ store,
34
+ engine,
35
+ clientCompatibility: currentSharedIndexCompatibility(),
36
+ });
37
+ }
@@ -0,0 +1,98 @@
1
+ import child_process from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { compareBytes } from "../compare.js";
6
+ import { isIndexableSourcePath } from "../engine/source-policy.js";
7
+ import { SharedIndexError } from "./s3-client.js";
8
+ function split(output) {
9
+ return output.split("\0").filter(Boolean);
10
+ }
11
+ function gitPaths(repo, args) {
12
+ try {
13
+ return split(child_process.execFileSync("git", args, {
14
+ cwd: repo,
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "ignore"],
17
+ maxBuffer: 64 * 1024 * 1024,
18
+ }));
19
+ }
20
+ catch {
21
+ throw new SharedIndexError("unavailable", "shared-index could not derive the local overlay");
22
+ }
23
+ }
24
+ function safeRelativePath(repo, value) {
25
+ const normalized = value.replaceAll("\\", "/").normalize("NFC");
26
+ const resolved = path.resolve(repo, normalized);
27
+ const relative = path.relative(path.resolve(repo), resolved);
28
+ if (!relative ||
29
+ relative.startsWith("..") ||
30
+ path.isAbsolute(relative) ||
31
+ normalized.includes("\0"))
32
+ throw new SharedIndexError("integrity", "shared-index Git overlay contains an unsafe path");
33
+ return relative.replaceAll("\\", "/");
34
+ }
35
+ function digestFile(target) {
36
+ const hash = crypto.createHash("sha256");
37
+ const descriptor = fs.openSync(target, "r");
38
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
39
+ try {
40
+ for (;;) {
41
+ const bytes = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null);
42
+ if (bytes === 0)
43
+ break;
44
+ hash.update(buffer.subarray(0, bytes));
45
+ }
46
+ }
47
+ finally {
48
+ fs.closeSync(descriptor);
49
+ }
50
+ return hash.digest("hex");
51
+ }
52
+ export function deriveSharedOverlay(repo, snapshotCommit) {
53
+ const resolved = path.resolve(repo);
54
+ const committed = new Set(gitPaths(resolved, [
55
+ "diff",
56
+ "--name-only",
57
+ "--no-renames",
58
+ "-z",
59
+ snapshotCommit,
60
+ "HEAD",
61
+ "--",
62
+ ]).map((file) => safeRelativePath(resolved, file)));
63
+ const staged = new Set(gitPaths(resolved, ["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD", "--"]).map((file) => safeRelativePath(resolved, file)));
64
+ const working = new Set(gitPaths(resolved, ["diff", "--name-only", "--no-renames", "-z", "--"]).map((file) => safeRelativePath(resolved, file)));
65
+ const untracked = new Set(gitPaths(resolved, ["ls-files", "--others", "--exclude-standard", "-z", "--"])
66
+ .map((file) => safeRelativePath(resolved, file))
67
+ .filter((file) => isIndexableSourcePath(file)));
68
+ const changedPaths = [...new Set([...committed, ...staged, ...working, ...untracked])].sort(compareBytes);
69
+ const deletedPaths = changedPaths.filter((file) => !fs.existsSync(path.join(resolved, file))).length;
70
+ const fingerprint = crypto.createHash("sha256");
71
+ fingerprint.update(`snapshot\0${snapshotCommit}\0`);
72
+ for (const file of changedPaths) {
73
+ const absolute = path.join(resolved, file);
74
+ let digest = "deleted";
75
+ try {
76
+ const stat = fs.lstatSync(absolute);
77
+ if (stat.isFile() && !stat.isSymbolicLink())
78
+ digest = digestFile(absolute);
79
+ else
80
+ digest = "ineligible";
81
+ }
82
+ catch {
83
+ // Final disk truth is deletion even when a path changed in several layers.
84
+ }
85
+ fingerprint.update(`${Buffer.byteLength(file)}:${file}\0${digest}\0`);
86
+ }
87
+ return {
88
+ changedPaths,
89
+ counts: {
90
+ committedPaths: committed.size,
91
+ stagedPaths: staged.size,
92
+ workingTreePaths: working.size,
93
+ deletedPaths,
94
+ untrackedPaths: untracked.size,
95
+ },
96
+ effectiveTreeFingerprint: fingerprint.digest("hex"),
97
+ };
98
+ }