vana-cli 0.24.4 → 0.25.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/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +20 -1
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +98 -14
- package/dist/cli/index.js.map +1 -1
- package/dist/connectors/registry.d.ts +2 -0
- package/dist/connectors/registry.d.ts.map +1 -1
- package/dist/connectors/registry.js +18 -2
- package/dist/connectors/registry.js.map +1 -1
- package/dist/core/cli-types.d.ts +44 -0
- package/dist/core/cli-types.d.ts.map +1 -1
- package/dist/core/cli-types.js +14 -0
- package/dist/core/cli-types.js.map +1 -1
- package/dist/core/state-store.d.ts +6 -0
- package/dist/core/state-store.d.ts.map +1 -1
- package/dist/core/state-store.js.map +1 -1
- package/dist/pdpp/host.d.ts +66 -0
- package/dist/pdpp/host.d.ts.map +1 -0
- package/dist/pdpp/host.js +283 -0
- package/dist/pdpp/host.js.map +1 -0
- package/dist/pdpp/installer.d.ts +17 -0
- package/dist/pdpp/installer.d.ts.map +1 -0
- package/dist/pdpp/installer.js +117 -0
- package/dist/pdpp/installer.js.map +1 -0
- package/dist/pdpp/local.d.ts +14 -0
- package/dist/pdpp/local.d.ts.map +1 -0
- package/dist/pdpp/local.js +76 -0
- package/dist/pdpp/local.js.map +1 -0
- package/dist/pdpp/pins.d.ts +23 -0
- package/dist/pdpp/pins.d.ts.map +1 -0
- package/dist/pdpp/pins.js +18 -0
- package/dist/pdpp/pins.js.map +1 -0
- package/dist/pdpp/profile.d.ts +49 -0
- package/dist/pdpp/profile.d.ts.map +1 -0
- package/dist/pdpp/profile.js +17 -0
- package/dist/pdpp/profile.js.map +1 -0
- package/dist/pdpp/protocol.d.ts +79 -0
- package/dist/pdpp/protocol.d.ts.map +1 -0
- package/dist/pdpp/protocol.js +372 -0
- package/dist/pdpp/protocol.js.map +1 -0
- package/dist/pdpp/runtime.d.ts +43 -0
- package/dist/pdpp/runtime.d.ts.map +1 -0
- package/dist/pdpp/runtime.js +387 -0
- package/dist/pdpp/runtime.js.map +1 -0
- package/dist/pdpp/store.d.ts +43 -0
- package/dist/pdpp/store.d.ts.map +1 -0
- package/dist/pdpp/store.js +100 -0
- package/dist/pdpp/store.js.map +1 -0
- package/dist/runtime/core/contracts.d.ts +2 -0
- package/dist/runtime/core/contracts.d.ts.map +1 -1
- package/dist/runtime/managed-playwright.d.ts +7 -0
- package/dist/runtime/managed-playwright.d.ts.map +1 -1
- package/dist/runtime/managed-playwright.js +7 -0
- package/dist/runtime/managed-playwright.js.map +1 -1
- package/dist/vendor/pdpp-connector-manager/LICENSE +202 -0
- package/dist/vendor/pdpp-connector-manager/NOTICE +2 -0
- package/dist/vendor/pdpp-connector-manager/VENDOR.md +27 -0
- package/dist/vendor/pdpp-connector-manager/catalog-schema-data.mjs +168 -0
- package/dist/vendor/pdpp-connector-manager/catalog-schema.mjs +62 -0
- package/dist/vendor/pdpp-connector-manager/index.d.mts +49 -0
- package/dist/vendor/pdpp-connector-manager/index.mjs +1646 -0
- package/dist/vendor/pdpp-connector-manager/oci-catalog.mjs +93 -0
- package/dist/vendor/pdpp-connector-manager/oci-registry.mjs +1002 -0
- package/dist/vendor/pdpp-connector-manager/oci-verify.mjs +515 -0
- package/dist/vendor/pdpp-connector-manager/package.json +51 -0
- package/dist/vendor/pdpp-connector-manager/retry.mjs +142 -0
- package/dist/vendor/pdpp-connector-manager/tar-stream.mjs +685 -0
- package/package.json +7 -2
|
@@ -0,0 +1,1646 @@
|
|
|
1
|
+
// Copyright The PDP-Connect Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import {
|
|
18
|
+
dirname,
|
|
19
|
+
join,
|
|
20
|
+
normalize,
|
|
21
|
+
relative,
|
|
22
|
+
resolve as resolvePath,
|
|
23
|
+
sep,
|
|
24
|
+
} from "node:path";
|
|
25
|
+
import { verify as verifySigstoreBundle } from "sigstore";
|
|
26
|
+
|
|
27
|
+
import { readTarGzEntries } from "./tar-stream.mjs";
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_RETRY_ATTEMPTS,
|
|
30
|
+
DEFAULT_RETRY_BASE_DELAY_MS,
|
|
31
|
+
DEFAULT_RETRY_MAX_DELAY_MS,
|
|
32
|
+
fetchWithRetry,
|
|
33
|
+
} from "./retry.mjs";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_OCI_REGISTRY as DEFAULT_OCI_REGISTRY_NAME,
|
|
37
|
+
OciRegistryError,
|
|
38
|
+
fetchBlob,
|
|
39
|
+
fetchManifestByDigest,
|
|
40
|
+
isValidConnectorKey,
|
|
41
|
+
parseOciReference,
|
|
42
|
+
resolveVersionToDigest,
|
|
43
|
+
sha256Digest,
|
|
44
|
+
} from "./oci-registry.mjs";
|
|
45
|
+
import {
|
|
46
|
+
OCI_CONFIG_MEDIA_TYPE,
|
|
47
|
+
assertConfigMatchesProfile,
|
|
48
|
+
defaultOciCertificateIdentityResolver,
|
|
49
|
+
indexLayersByMediaType,
|
|
50
|
+
toAnchoredIdentityPattern,
|
|
51
|
+
verifyOciSignature,
|
|
52
|
+
} from "./oci-verify.mjs";
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
DEFAULT_OCI_SIGSTORE_CERTIFICATE_IDENTITY,
|
|
56
|
+
DEFAULT_OCI_SIGSTORE_CERTIFICATE_ISSUER,
|
|
57
|
+
defaultOciCertificateIdentityResolver,
|
|
58
|
+
// One definition, used by both paths: the tarball pin and the OCI pin have
|
|
59
|
+
// the same unanchored-regex hazard and must not drift apart.
|
|
60
|
+
toAnchoredIdentityPattern,
|
|
61
|
+
} from "./oci-verify.mjs";
|
|
62
|
+
export {
|
|
63
|
+
OciRegistryError,
|
|
64
|
+
DEFAULT_OCI_REGISTRY,
|
|
65
|
+
parseConnectorOciReference,
|
|
66
|
+
} from "./oci-registry.mjs";
|
|
67
|
+
|
|
68
|
+
export { fetchCatalog } from "./oci-catalog.mjs";
|
|
69
|
+
|
|
70
|
+
export const DEFAULT_CONNECTOR_INDEX_URL =
|
|
71
|
+
"https://github.com/PDP-Connect/data-connectors/releases/download/connectors-latest/connector-index.json";
|
|
72
|
+
export const DEFAULT_SIGSTORE_CERTIFICATE_ISSUER =
|
|
73
|
+
"https://token.actions.githubusercontent.com";
|
|
74
|
+
export const DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY =
|
|
75
|
+
"https://github.com/PDP-Connect/data-connectors/.github/workflows/publish-connector-release-index.yml@refs/heads/main";
|
|
76
|
+
|
|
77
|
+
export function defaultArtifactCertificateIdentityResolver() {
|
|
78
|
+
return DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function defaultIndexCertificateIdentityResolver() {
|
|
82
|
+
return DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readJson(path) {
|
|
86
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function sha256Buffer(buffer) {
|
|
90
|
+
return `sha256:${createHash("sha256").update(buffer).digest("hex")}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseVersion(version) {
|
|
94
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
|
95
|
+
if (!match) {
|
|
96
|
+
throw new Error(`Unsupported version format "${version}"`);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
major: Number(match[1]),
|
|
100
|
+
minor: Number(match[2]),
|
|
101
|
+
patch: Number(match[3]),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function compareVersions(a, b) {
|
|
106
|
+
const av = typeof a === "string" ? parseVersion(a) : a;
|
|
107
|
+
const bv = typeof b === "string" ? parseVersion(b) : b;
|
|
108
|
+
if (av.major !== bv.major) return av.major - bv.major;
|
|
109
|
+
if (av.minor !== bv.minor) return av.minor - bv.minor;
|
|
110
|
+
return av.patch - bv.patch;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function evaluateComparator(version, comparator) {
|
|
114
|
+
if (comparator === "*" || comparator === "") {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const match = /^(>=|<=|>|<|=|\^|~)?\s*(\d+\.\d+\.\d+)$/.exec(comparator);
|
|
119
|
+
if (!match) {
|
|
120
|
+
throw new Error(`Unsupported comparator "${comparator}"`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const operator = match[1] ?? "=";
|
|
124
|
+
const target = parseVersion(match[2]);
|
|
125
|
+
const cmp = compareVersions(version, target);
|
|
126
|
+
|
|
127
|
+
switch (operator) {
|
|
128
|
+
case "=":
|
|
129
|
+
return cmp === 0;
|
|
130
|
+
case ">":
|
|
131
|
+
return cmp > 0;
|
|
132
|
+
case ">=":
|
|
133
|
+
return cmp >= 0;
|
|
134
|
+
case "<":
|
|
135
|
+
return cmp < 0;
|
|
136
|
+
case "<=":
|
|
137
|
+
return cmp <= 0;
|
|
138
|
+
case "^":
|
|
139
|
+
return (
|
|
140
|
+
cmp >= 0 &&
|
|
141
|
+
compareVersions(version, {
|
|
142
|
+
major: target.major + 1,
|
|
143
|
+
minor: 0,
|
|
144
|
+
patch: 0,
|
|
145
|
+
}) < 0
|
|
146
|
+
);
|
|
147
|
+
case "~":
|
|
148
|
+
return (
|
|
149
|
+
cmp >= 0 &&
|
|
150
|
+
compareVersions(version, {
|
|
151
|
+
major: target.major,
|
|
152
|
+
minor: target.minor + 1,
|
|
153
|
+
patch: 0,
|
|
154
|
+
}) < 0
|
|
155
|
+
);
|
|
156
|
+
default:
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function satisfies(versionString, range) {
|
|
162
|
+
const version = parseVersion(versionString);
|
|
163
|
+
const normalized = range.trim();
|
|
164
|
+
if (normalized === "" || normalized === "*") {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return normalized
|
|
168
|
+
.split(/\s+/)
|
|
169
|
+
.filter(Boolean)
|
|
170
|
+
.every((token) => evaluateComparator(version, token));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function selectResolvedEntry(entries, constraint, connectorId) {
|
|
174
|
+
const matches = entries.filter((entry) => satisfies(entry.version, constraint));
|
|
175
|
+
if (matches.length === 0) {
|
|
176
|
+
const available = entries.map((entry) => entry.version).join(", ");
|
|
177
|
+
throw new Error(
|
|
178
|
+
`No published version for ${connectorId} satisfies "${constraint}". Available: ${available || "(none)"}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return matches.sort((a, b) => compareVersions(b.version, a.version))[0];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function extractAvailableVersions(indexDoc, connectorId) {
|
|
185
|
+
if (!indexDoc.connectors || typeof indexDoc.connectors !== "object") {
|
|
186
|
+
throw new Error("Unsupported connector index shape");
|
|
187
|
+
}
|
|
188
|
+
const entries = indexDoc.connectors[connectorId];
|
|
189
|
+
return Array.isArray(entries) ? entries : [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function findMatchingIndexEntry(indexSource, entry) {
|
|
193
|
+
const entries = extractAvailableVersions(indexSource?.doc ?? {}, entry.connectorId);
|
|
194
|
+
return entries.find((candidate) => candidate.version === entry.version) ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function enrichRemoteEntry(indexSource, entry) {
|
|
198
|
+
if (indexSource?.mode !== "remote") {
|
|
199
|
+
return entry;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const matched = findMatchingIndexEntry(indexSource, entry);
|
|
203
|
+
if (!matched) {
|
|
204
|
+
return entry;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
...entry,
|
|
209
|
+
artifactUrl: matched.artifactUrl ?? entry.artifactUrl,
|
|
210
|
+
artifactSignature:
|
|
211
|
+
normalizeSignature(entry.artifactSignature) ??
|
|
212
|
+
normalizeSignature(matched.artifactSignature ?? null),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const ARTIFACT_FETCH_ATTEMPTS = DEFAULT_RETRY_ATTEMPTS;
|
|
217
|
+
export const ARTIFACT_FETCH_BASE_DELAY_MS = DEFAULT_RETRY_BASE_DELAY_MS;
|
|
218
|
+
export const ARTIFACT_FETCH_MAX_DELAY_MS = DEFAULT_RETRY_MAX_DELAY_MS;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Downloads a release asset, retrying transient failures.
|
|
222
|
+
*
|
|
223
|
+
* GitHub's release-asset CDN intermittently answers a valid, immutable URL with
|
|
224
|
+
* a 5xx that succeeds seconds later. A single attempt turns that blip into a
|
|
225
|
+
* failed `npm ci` for every consumer, so 5xx responses and network errors are
|
|
226
|
+
* retried with exponential backoff.
|
|
227
|
+
*
|
|
228
|
+
* Retries cover only failures a later attempt can plausibly fix. A 4xx is a
|
|
229
|
+
* statement about the request itself — a wrong or withdrawn URL — and repeating
|
|
230
|
+
* it just delays the same error, so it fails immediately. Digest and signature
|
|
231
|
+
* mismatches are likewise never retried: they are verification failures raised
|
|
232
|
+
* by the caller, and bytes that fail a hash check are not made trustworthy by
|
|
233
|
+
* downloading them again.
|
|
234
|
+
*/
|
|
235
|
+
export async function fetchBinary(
|
|
236
|
+
url,
|
|
237
|
+
{
|
|
238
|
+
fetchImpl = fetch,
|
|
239
|
+
attempts = ARTIFACT_FETCH_ATTEMPTS,
|
|
240
|
+
baseDelayMs = ARTIFACT_FETCH_BASE_DELAY_MS,
|
|
241
|
+
maxDelayMs = ARTIFACT_FETCH_MAX_DELAY_MS,
|
|
242
|
+
jitter = true,
|
|
243
|
+
random = Math.random,
|
|
244
|
+
sleep,
|
|
245
|
+
now,
|
|
246
|
+
onRetry,
|
|
247
|
+
} = {}
|
|
248
|
+
) {
|
|
249
|
+
const response = await fetchWithRetry(url, {
|
|
250
|
+
fetchImpl,
|
|
251
|
+
attempts,
|
|
252
|
+
baseDelayMs,
|
|
253
|
+
maxDelayMs,
|
|
254
|
+
jitter,
|
|
255
|
+
random,
|
|
256
|
+
...(sleep ? { sleep } : {}),
|
|
257
|
+
...(now ? { now } : {}),
|
|
258
|
+
...(onRetry ? { onRetry } : {}),
|
|
259
|
+
});
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
262
|
+
}
|
|
263
|
+
return Buffer.from(await response.arrayBuffer());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeSignature(signature) {
|
|
267
|
+
if (!signature || typeof signature !== "object") {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
type: signature.type ?? null,
|
|
273
|
+
bundlePath: signature.bundlePath ?? signature.bundle_path ?? null,
|
|
274
|
+
bundleUrl: signature.bundleUrl ?? signature.bundle_url ?? null,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function resolveBundleUrl(subjectUrl, signature) {
|
|
279
|
+
if (signature?.bundleUrl) {
|
|
280
|
+
return signature.bundleUrl;
|
|
281
|
+
}
|
|
282
|
+
if (signature?.bundlePath) {
|
|
283
|
+
return new URL(signature.bundlePath, subjectUrl).toString();
|
|
284
|
+
}
|
|
285
|
+
return `${subjectUrl}.sigstore.json`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function verifyRemoteSignature({
|
|
289
|
+
payloadBuffer,
|
|
290
|
+
subjectLabel,
|
|
291
|
+
subjectUrl,
|
|
292
|
+
signature,
|
|
293
|
+
allowUnsignedRemote = false,
|
|
294
|
+
certificateIdentityURI = DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY,
|
|
295
|
+
sigstoreVerifier = verifySigstoreBundle,
|
|
296
|
+
fetchImpl = fetch,
|
|
297
|
+
retryOptions = {},
|
|
298
|
+
}) {
|
|
299
|
+
const normalizedSignature = normalizeSignature(signature);
|
|
300
|
+
if (!normalizedSignature) {
|
|
301
|
+
if (allowUnsignedRemote) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
throw new Error(`${subjectLabel} is missing Sigstore bundle metadata`);
|
|
305
|
+
}
|
|
306
|
+
if (
|
|
307
|
+
normalizedSignature.type &&
|
|
308
|
+
normalizedSignature.type !== "sigstoreBundle"
|
|
309
|
+
) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`${subjectLabel} uses unsupported signature type "${normalizedSignature.type}"`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const bundleUrl = resolveBundleUrl(subjectUrl, normalizedSignature);
|
|
316
|
+
const bundleBuffer = await fetchBinary(bundleUrl, {
|
|
317
|
+
fetchImpl,
|
|
318
|
+
...retryOptions,
|
|
319
|
+
});
|
|
320
|
+
const bundle = JSON.parse(bundleBuffer.toString("utf8"));
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
await sigstoreVerifier(bundle, payloadBuffer, {
|
|
324
|
+
certificateIssuer: DEFAULT_SIGSTORE_CERTIFICATE_ISSUER,
|
|
325
|
+
certificateIdentityURI: toAnchoredIdentityPattern(certificateIdentityURI),
|
|
326
|
+
});
|
|
327
|
+
} catch (error) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`${subjectLabel} signature verification failed: ${error instanceof Error ? error.message : String(error)}`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function resolveArtifactCertificateIdentity({
|
|
337
|
+
artifactCertificateIdentityResolver = defaultArtifactCertificateIdentityResolver,
|
|
338
|
+
artifactUrl,
|
|
339
|
+
entry,
|
|
340
|
+
}) {
|
|
341
|
+
const certificateIdentityURI = await artifactCertificateIdentityResolver({
|
|
342
|
+
artifactUrl,
|
|
343
|
+
entry,
|
|
344
|
+
});
|
|
345
|
+
if (typeof certificateIdentityURI !== "string" || certificateIdentityURI.length === 0) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`No trusted Sigstore certificate identity configured for connector artifact ${entry.connectorId}@${entry.version}`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return certificateIdentityURI;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function resolveIndexCertificateIdentity({
|
|
354
|
+
indexCertificateIdentityResolver = defaultIndexCertificateIdentityResolver,
|
|
355
|
+
indexUrl,
|
|
356
|
+
}) {
|
|
357
|
+
const certificateIdentityURI = await indexCertificateIdentityResolver({
|
|
358
|
+
indexUrl,
|
|
359
|
+
});
|
|
360
|
+
if (
|
|
361
|
+
typeof certificateIdentityURI !== "string" ||
|
|
362
|
+
certificateIdentityURI.length === 0
|
|
363
|
+
) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`No trusted Sigstore certificate identity configured for connector index ${indexUrl}`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return certificateIdentityURI;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function validateRelativeArtifactPath(relativePath, label = "Artifact path") {
|
|
372
|
+
if (
|
|
373
|
+
typeof relativePath !== "string" ||
|
|
374
|
+
relativePath === "" ||
|
|
375
|
+
relativePath === "." ||
|
|
376
|
+
relativePath.startsWith("/") ||
|
|
377
|
+
/^[A-Za-z]:/.test(relativePath) ||
|
|
378
|
+
relativePath.includes("\\") ||
|
|
379
|
+
relativePath.includes("\0") ||
|
|
380
|
+
relativePath.split("/").includes("..")
|
|
381
|
+
) {
|
|
382
|
+
throw new Error(`Invalid ${label.toLowerCase()} "${relativePath}"`);
|
|
383
|
+
}
|
|
384
|
+
return relativePath;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function ensureInside(baseDir, relativePath) {
|
|
388
|
+
const validPath = validateRelativeArtifactPath(relativePath);
|
|
389
|
+
return join(baseDir, normalize(validPath));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function toPortableArtifactPath(relativePath, pathSeparator = sep) {
|
|
393
|
+
return relativePath.split(pathSeparator).join("/");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function walkArtifactFiles(dir, root = dir) {
|
|
397
|
+
if (!existsSync(dir)) {
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const out = [];
|
|
402
|
+
for (const entry of readdirSync(dir)) {
|
|
403
|
+
if (entry.startsWith(".")) continue;
|
|
404
|
+
const full = join(dir, entry);
|
|
405
|
+
const relativePath = toPortableArtifactPath(relative(root, full));
|
|
406
|
+
const st = lstatSync(full);
|
|
407
|
+
if (st.isSymbolicLink()) {
|
|
408
|
+
throw new Error(`Artifact contains unsupported link "${relativePath}"`);
|
|
409
|
+
}
|
|
410
|
+
if (st.isDirectory()) {
|
|
411
|
+
out.push(...walkArtifactFiles(full, root));
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (!st.isFile()) {
|
|
415
|
+
throw new Error(`Artifact contains unsupported entry "${relativePath}"`);
|
|
416
|
+
}
|
|
417
|
+
out.push({
|
|
418
|
+
path: full,
|
|
419
|
+
relativePath,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function validateArchiveMemberPath(memberPath) {
|
|
426
|
+
const trimmedPath = memberPath.replace(/^\.\//, "").replace(/\/$/, "");
|
|
427
|
+
if (trimmedPath === "") {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
validateRelativeArtifactPath(trimmedPath, "archive member path");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function assertSafeArchive(tarPath) {
|
|
434
|
+
const members = execFileSync("tar", ["-tzf", tarPath], { encoding: "utf8" })
|
|
435
|
+
.split("\n")
|
|
436
|
+
.filter(Boolean);
|
|
437
|
+
for (const memberPath of members) {
|
|
438
|
+
validateArchiveMemberPath(memberPath);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const verboseMembers = execFileSync("tar", ["-tvzf", tarPath], { encoding: "utf8" })
|
|
442
|
+
.split("\n")
|
|
443
|
+
.filter(Boolean);
|
|
444
|
+
for (const member of verboseMembers) {
|
|
445
|
+
const type = member[0];
|
|
446
|
+
if (type !== "-" && type !== "d") {
|
|
447
|
+
throw new Error(`Artifact contains unsupported archive entry type "${type}"`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function artifactContract(entry) {
|
|
453
|
+
const artifactKind = entry.artifactKind ?? "legacy";
|
|
454
|
+
|
|
455
|
+
if (artifactKind === "legacy") {
|
|
456
|
+
return {
|
|
457
|
+
manifestPath: "manifest.json",
|
|
458
|
+
entrypointPath: "script.js",
|
|
459
|
+
entrypointChecksum: entry.scriptSha256,
|
|
460
|
+
entrypointLabel: "script",
|
|
461
|
+
provenancePath: null,
|
|
462
|
+
provenanceChecksum: null,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (artifactKind === "pdpp-collection-profile") {
|
|
467
|
+
const manifestPath = validateRelativeArtifactPath(
|
|
468
|
+
entry.manifestPath,
|
|
469
|
+
"PDPP manifest path"
|
|
470
|
+
);
|
|
471
|
+
const entrypointPath = validateRelativeArtifactPath(
|
|
472
|
+
entry.entrypointPath,
|
|
473
|
+
"PDPP entrypoint path"
|
|
474
|
+
);
|
|
475
|
+
const provenancePath = validateRelativeArtifactPath(
|
|
476
|
+
entry.provenancePath,
|
|
477
|
+
"PDPP provenance path"
|
|
478
|
+
);
|
|
479
|
+
for (const [label, checksum] of Object.entries({
|
|
480
|
+
artifact: entry.artifactSha256,
|
|
481
|
+
manifest: entry.manifestSha256,
|
|
482
|
+
entrypoint: entry.entrypointSha256,
|
|
483
|
+
provenance: entry.provenanceSha256,
|
|
484
|
+
})) {
|
|
485
|
+
if (typeof checksum !== "string" || !/^sha256:[0-9a-f]{64}$/.test(checksum)) {
|
|
486
|
+
throw new Error(`${entry.connectorId} PDPP ${label} checksum is required`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
manifestPath,
|
|
491
|
+
entrypointPath,
|
|
492
|
+
entrypointChecksum: entry.entrypointSha256,
|
|
493
|
+
entrypointLabel: "entrypoint",
|
|
494
|
+
provenancePath,
|
|
495
|
+
provenanceChecksum: entry.provenanceSha256,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
throw new Error(`Unsupported artifact kind "${artifactKind}"`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function unpackArtifactBuffer(entry, buffer) {
|
|
503
|
+
const contract = artifactContract(entry);
|
|
504
|
+
const tempRoot = mkdtempSync(join(tmpdir(), "connector-artifact-"));
|
|
505
|
+
const tarPath = join(tempRoot, "artifact.tgz");
|
|
506
|
+
const unpackDir = join(tempRoot, "bundle");
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
mkdirSync(unpackDir, { recursive: true });
|
|
510
|
+
writeFileSync(tarPath, buffer);
|
|
511
|
+
assertSafeArchive(tarPath);
|
|
512
|
+
execFileSync("tar", ["-xzf", tarPath, "-C", unpackDir]);
|
|
513
|
+
|
|
514
|
+
const files = walkArtifactFiles(unpackDir);
|
|
515
|
+
const manifestFile = files.find((file) => file.relativePath === contract.manifestPath);
|
|
516
|
+
const entrypointFile = files.find((file) => file.relativePath === contract.entrypointPath);
|
|
517
|
+
const provenanceFile = contract.provenancePath
|
|
518
|
+
? files.find((file) => file.relativePath === contract.provenancePath)
|
|
519
|
+
: null;
|
|
520
|
+
if (!manifestFile || !entrypointFile || (contract.provenancePath && !provenanceFile)) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`Artifact missing ${!manifestFile ? contract.manifestPath : !entrypointFile ? contract.entrypointPath : contract.provenancePath}`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const schemaFiles = [];
|
|
527
|
+
const assetFiles = [];
|
|
528
|
+
let readme = null;
|
|
529
|
+
|
|
530
|
+
for (const file of files) {
|
|
531
|
+
if (
|
|
532
|
+
file.relativePath === contract.manifestPath ||
|
|
533
|
+
file.relativePath === contract.entrypointPath ||
|
|
534
|
+
file.relativePath === contract.provenancePath
|
|
535
|
+
) {
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (file.relativePath === "README.md") {
|
|
539
|
+
readme = {
|
|
540
|
+
path: file.relativePath,
|
|
541
|
+
buffer: readFileSync(file.path),
|
|
542
|
+
};
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (file.relativePath.startsWith("schemas/")) {
|
|
546
|
+
schemaFiles.push({
|
|
547
|
+
path: file.relativePath,
|
|
548
|
+
buffer: readFileSync(file.path),
|
|
549
|
+
});
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
assetFiles.push({
|
|
553
|
+
path: file.relativePath,
|
|
554
|
+
buffer: readFileSync(file.path),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
manifestBuffer: readFileSync(manifestFile.path),
|
|
560
|
+
entrypointBuffer: readFileSync(entrypointFile.path),
|
|
561
|
+
entrypointPath: contract.entrypointPath,
|
|
562
|
+
entrypointChecksum: contract.entrypointChecksum,
|
|
563
|
+
entrypointLabel: contract.entrypointLabel,
|
|
564
|
+
provenanceBuffer: provenanceFile ? readFileSync(provenanceFile.path) : null,
|
|
565
|
+
provenancePath: contract.provenancePath,
|
|
566
|
+
provenanceChecksum: contract.provenanceChecksum,
|
|
567
|
+
schemaFiles,
|
|
568
|
+
assetFiles,
|
|
569
|
+
readme,
|
|
570
|
+
};
|
|
571
|
+
} finally {
|
|
572
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function resolveIndexSourcePath(rootDir) {
|
|
577
|
+
const candidates = [
|
|
578
|
+
join(rootDir, "connector-index.json"),
|
|
579
|
+
join(rootDir, "dist", "connector-index.json"),
|
|
580
|
+
];
|
|
581
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export async function loadConnectorIndex({
|
|
585
|
+
fromLocal = null,
|
|
586
|
+
indexUrl = null,
|
|
587
|
+
defaultLocalSource = null,
|
|
588
|
+
defaultIndexUrl = DEFAULT_CONNECTOR_INDEX_URL,
|
|
589
|
+
preferDefaultLocal = false,
|
|
590
|
+
allowUnsignedRemote = false,
|
|
591
|
+
indexCertificateIdentityResolver = defaultIndexCertificateIdentityResolver,
|
|
592
|
+
sigstoreVerifier = undefined,
|
|
593
|
+
fetchImpl = fetch,
|
|
594
|
+
retryOptions = {},
|
|
595
|
+
}) {
|
|
596
|
+
const resolvedLocal = fromLocal
|
|
597
|
+
? resolvePath(fromLocal)
|
|
598
|
+
: preferDefaultLocal && defaultLocalSource
|
|
599
|
+
? resolvePath(defaultLocalSource)
|
|
600
|
+
: null;
|
|
601
|
+
|
|
602
|
+
if (resolvedLocal && existsSync(resolvedLocal)) {
|
|
603
|
+
const indexPath = resolveIndexSourcePath(resolvedLocal);
|
|
604
|
+
if (!indexPath) {
|
|
605
|
+
throw new Error(`No connector-index.json found under ${resolvedLocal}`);
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
mode: "local",
|
|
609
|
+
rootDir: resolvedLocal,
|
|
610
|
+
indexUrl: null,
|
|
611
|
+
indexPath,
|
|
612
|
+
doc: readJson(indexPath),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const url = indexUrl ?? defaultIndexUrl;
|
|
617
|
+
if (!url) {
|
|
618
|
+
throw new Error("No connector index source configured");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const indexBuffer = await fetchBinary(url, { fetchImpl, ...retryOptions });
|
|
622
|
+
const doc = JSON.parse(indexBuffer.toString("utf8"));
|
|
623
|
+
const certificateIdentityURI = await resolveIndexCertificateIdentity({
|
|
624
|
+
indexCertificateIdentityResolver,
|
|
625
|
+
indexUrl: url,
|
|
626
|
+
});
|
|
627
|
+
const signatureVerified = await verifyRemoteSignature({
|
|
628
|
+
payloadBuffer: indexBuffer,
|
|
629
|
+
subjectLabel: "Connector index",
|
|
630
|
+
subjectUrl: url,
|
|
631
|
+
signature: doc.signature,
|
|
632
|
+
allowUnsignedRemote,
|
|
633
|
+
certificateIdentityURI,
|
|
634
|
+
fetchImpl,
|
|
635
|
+
retryOptions,
|
|
636
|
+
...(sigstoreVerifier ? { sigstoreVerifier } : {}),
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
mode: "remote",
|
|
641
|
+
rootDir: null,
|
|
642
|
+
indexUrl: url,
|
|
643
|
+
indexPath: null,
|
|
644
|
+
doc,
|
|
645
|
+
signatureVerified,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function resolveArtifactLocalPath(rootDir, artifactPath, connectorId) {
|
|
650
|
+
if (!artifactPath) {
|
|
651
|
+
throw new Error(`Local connector index entry for ${connectorId} is missing artifactPath`);
|
|
652
|
+
}
|
|
653
|
+
const resolvedPath = ensureInside(rootDir, artifactPath);
|
|
654
|
+
if (!existsSync(resolvedPath)) {
|
|
655
|
+
throw new Error(`Local artifact not found: ${resolvedPath}`);
|
|
656
|
+
}
|
|
657
|
+
return resolvedPath;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function deriveSourceMeta(indexSource, connectors = []) {
|
|
661
|
+
if (indexSource?.mode === "local" && indexSource.rootDir) {
|
|
662
|
+
try {
|
|
663
|
+
const sourceTag = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
664
|
+
cwd: indexSource.rootDir,
|
|
665
|
+
encoding: "utf8",
|
|
666
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
667
|
+
}).trim();
|
|
668
|
+
const sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
669
|
+
cwd: indexSource.rootDir,
|
|
670
|
+
encoding: "utf8",
|
|
671
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
672
|
+
}).trim();
|
|
673
|
+
return { sourceTag, sourceCommit };
|
|
674
|
+
} catch {
|
|
675
|
+
return {
|
|
676
|
+
sourceTag: resolvePath(indexSource.rootDir),
|
|
677
|
+
sourceCommit: "unknown",
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const sourceTags = [...new Set(connectors.map((entry) => entry.sourceTag).filter(Boolean))];
|
|
683
|
+
const sourceCommits = [...new Set(connectors.map((entry) => entry.sourceCommit).filter(Boolean))];
|
|
684
|
+
return {
|
|
685
|
+
sourceTag: sourceTags.length === 1 ? sourceTags[0] : "mixed",
|
|
686
|
+
sourceCommit: sourceCommits.length === 1 ? sourceCommits[0] : "mixed",
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Read an `oci` block off a lock entry, or null when the entry is a tarball one.
|
|
692
|
+
*
|
|
693
|
+
* `connectorKey` is carried separately from `connectorId` because they are
|
|
694
|
+
* genuinely different strings — `chatgpt-pdpp` is installed at
|
|
695
|
+
* `collection-profiles/chatgpt-pdpp/`, but its artifact lives at
|
|
696
|
+
* `.../connector/chatgpt`. The repository path is a function of the key and the
|
|
697
|
+
* install path is a function of the id, so a consumer that conflates them
|
|
698
|
+
* either fetches the wrong repository or moves every install root.
|
|
699
|
+
*/
|
|
700
|
+
function normalizeOciBlock(entry) {
|
|
701
|
+
const oci = entry.oci ?? null;
|
|
702
|
+
if (!oci || typeof oci !== "object") return null;
|
|
703
|
+
return {
|
|
704
|
+
registry: oci.registry ?? DEFAULT_OCI_REGISTRY_NAME,
|
|
705
|
+
repository: oci.repository ?? null,
|
|
706
|
+
digest: oci.digest ?? null,
|
|
707
|
+
configDigest: oci.configDigest ?? oci.config_digest ?? null,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Which entries in a lock need the signed index, and which do not.
|
|
713
|
+
*
|
|
714
|
+
* The transport is a property of each ENTRY — `fetchEntryArtifact` dispatches on
|
|
715
|
+
* `entry.oci` and never consults `source` for an OCI entry — so whether an index
|
|
716
|
+
* is needed is a question about the lock, not about which flag the operator
|
|
717
|
+
* typed. Deciding it from `--oci` made a lock of digest-pinned OCI entries
|
|
718
|
+
* depend on the old index service anyway, and fail when it was down, while the
|
|
719
|
+
* identical reference passed as `--oci` did not.
|
|
720
|
+
*
|
|
721
|
+
* Exported so the CLI asks this module rather than re-deriving it: a second
|
|
722
|
+
* reading of "is this entry OCI" is a second thing to keep in step with the
|
|
723
|
+
* dispatch, and the two going out of step is exactly the defect.
|
|
724
|
+
*
|
|
725
|
+
* The mixed case is real and stays supported: the twelve legacy `*-playwright`
|
|
726
|
+
* connectors keep the tarball path until they are ported or retired, so a lock
|
|
727
|
+
* naming both transports must load the index for the entries that need it.
|
|
728
|
+
*/
|
|
729
|
+
export function lockNeedsIndexSource(lock) {
|
|
730
|
+
return (lock?.connectors ?? []).some((entry) => !normalizeOciBlock(entry));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function normalizeLockEntry(entry) {
|
|
734
|
+
const artifactKind = entry.artifactKind ?? entry.artifact_kind ?? "legacy";
|
|
735
|
+
return {
|
|
736
|
+
connectorId: entry.connectorId ?? entry.id,
|
|
737
|
+
connectorKey: entry.connectorKey ?? entry.connector_key ?? null,
|
|
738
|
+
oci: normalizeOciBlock(entry),
|
|
739
|
+
company: entry.company,
|
|
740
|
+
version: entry.version,
|
|
741
|
+
resolvedFrom: entry.resolvedFrom ?? entry.resolved_from ?? entry.version,
|
|
742
|
+
sourceFiles: entry.sourceFiles ?? entry.source_files ?? entry.files,
|
|
743
|
+
artifactUrl: entry.artifactUrl ?? entry.artifact_url ?? null,
|
|
744
|
+
artifactPath: entry.artifactPath ?? entry.artifact_path ?? null,
|
|
745
|
+
artifactSha256: entry.artifactSha256 ?? entry.artifact_sha256 ?? entry.checksums?.artifact,
|
|
746
|
+
artifactSignature: normalizeSignature(
|
|
747
|
+
entry.artifactSignature ?? entry.artifact_signature ?? null
|
|
748
|
+
),
|
|
749
|
+
manifestSha256:
|
|
750
|
+
entry.manifestSha256 ?? entry.manifest_sha256 ?? entry.checksums?.metadata,
|
|
751
|
+
scriptSha256: entry.scriptSha256 ?? entry.script_sha256 ?? entry.checksums?.script,
|
|
752
|
+
artifactKind,
|
|
753
|
+
manifestPath: entry.manifestPath ?? entry.manifest_path ?? null,
|
|
754
|
+
entrypointPath: entry.entrypointPath ?? entry.entrypoint_path ?? null,
|
|
755
|
+
entrypointSha256:
|
|
756
|
+
entry.entrypointSha256 ?? entry.entrypoint_sha256 ?? entry.checksums?.entrypoint,
|
|
757
|
+
provenancePath: entry.provenancePath ?? entry.provenance_path ?? null,
|
|
758
|
+
provenanceSha256:
|
|
759
|
+
entry.provenanceSha256 ?? entry.provenance_sha256 ?? entry.checksums?.provenance,
|
|
760
|
+
sourceTag: entry.sourceTag ?? entry.source_tag ?? entry.gitRef ?? entry.git_ref ?? null,
|
|
761
|
+
sourceCommit:
|
|
762
|
+
entry.sourceCommit ?? entry.source_commit ?? entry.gitRef ?? entry.git_ref ?? null,
|
|
763
|
+
releaseId: entry.releaseId ?? entry.release_id ?? null,
|
|
764
|
+
publishedAt: entry.publishedAt ?? entry.published_at ?? null,
|
|
765
|
+
name: entry.name ?? null,
|
|
766
|
+
description: entry.description ?? null,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Read one `.tar.gz` layer, refusing unsafe members and oversized content.
|
|
772
|
+
*
|
|
773
|
+
* Member type and path are checked from the archive's own headers, so a
|
|
774
|
+
* symlink, hardlink or FIFO is refused as it is read rather than after a tree
|
|
775
|
+
* exists (C4.5). Nothing is written to a temp directory at all, so a refusal
|
|
776
|
+
* has nothing to clean up (C6.1).
|
|
777
|
+
*
|
|
778
|
+
* `MAX_BLOB_BYTES` limits compressed input. The tar reader separately limits
|
|
779
|
+
* regular-file bytes, decompressed input consumed, and entry count. It checks
|
|
780
|
+
* effective member sizes before collecting bodies and stops at the archive
|
|
781
|
+
* terminator. These are byte/count limits, not a measured peak-memory bound.
|
|
782
|
+
* The returned regular-file buffers are measured again here.
|
|
783
|
+
*/
|
|
784
|
+
export const MAX_LAYER_UNPACKED_BYTES = 64 * 1024 * 1024;
|
|
785
|
+
|
|
786
|
+
async function readLayerArchive(
|
|
787
|
+
buffer,
|
|
788
|
+
label,
|
|
789
|
+
{ maxUnpackedBytes = MAX_LAYER_UNPACKED_BYTES } = {}
|
|
790
|
+
) {
|
|
791
|
+
try {
|
|
792
|
+
const entries = await readTarGzEntries(buffer, {
|
|
793
|
+
maxUnpackedBytes,
|
|
794
|
+
validateMemberPath: validateArchiveMemberPath,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
let unpacked = 0;
|
|
798
|
+
for (const entry of entries) {
|
|
799
|
+
unpacked += entry.buffer.length;
|
|
800
|
+
if (unpacked > maxUnpackedBytes) {
|
|
801
|
+
throw new Error(
|
|
802
|
+
`archive unpacked to more than the ${maxUnpackedBytes}-byte ceiling`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return entries.map((entry) => ({
|
|
808
|
+
path: toPortableArtifactPath(entry.path.replace(/^\.\//, ""), "/"),
|
|
809
|
+
buffer: entry.buffer,
|
|
810
|
+
}));
|
|
811
|
+
} catch (error) {
|
|
812
|
+
throw new OciRegistryError(
|
|
813
|
+
`Refusing ${label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
814
|
+
"unsafe-archive"
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Resolve, verify and unpack one OCI connector artifact.
|
|
821
|
+
*
|
|
822
|
+
* The ORDER here is the contract, and it is the reason this is one function
|
|
823
|
+
* rather than a pipeline a caller assembles: nothing is written, and no layer
|
|
824
|
+
* is even fetched, until the manifest has been proven to be what the lock
|
|
825
|
+
* pinned AND signed by the pinned identity. Every step refuses rather than
|
|
826
|
+
* degrades (C6.1), and because the only writes happen after all of them, a
|
|
827
|
+
* failure at any point leaves the install tree untouched.
|
|
828
|
+
*
|
|
829
|
+
* 1. coordinates are validated, and a non-GHCR registry is refused (C1.1, C1.3)
|
|
830
|
+
* 2. a version is resolved to a digest ONLY when the lock has none (C2.1, C2.3)
|
|
831
|
+
* 3. the manifest is fetched by digest and self-verified (C2.1)
|
|
832
|
+
* 4. the cosign signature is verified against the pinned identity (C3)
|
|
833
|
+
* 5. layers are selected by media type, unknown types refuse (C4.2, C4.3)
|
|
834
|
+
* 6. every blob is verified against its descriptor digest (C4.1)
|
|
835
|
+
* 7. config and profile are cross-checked (C4.4)
|
|
836
|
+
*
|
|
837
|
+
* Returns the same shape `unpackAndVerifyArtifact` returns for a tarball, so
|
|
838
|
+
* everything downstream — install writes, prune, verify — is untouched.
|
|
839
|
+
*/
|
|
840
|
+
async function fetchOciArtifact(entry, options = {}) {
|
|
841
|
+
const connectorKey = entry.connectorKey;
|
|
842
|
+
// Checked before any network call: a key that could never have been
|
|
843
|
+
// published is a lock defect, not a registry question (C1.1).
|
|
844
|
+
if (!isValidConnectorKey(connectorKey)) {
|
|
845
|
+
throw new OciRegistryError(
|
|
846
|
+
`Connector ${entry.connectorId} has an invalid connectorKey "${connectorKey}"`,
|
|
847
|
+
"invalid-reference"
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const reference = parseOciReference({
|
|
852
|
+
registry: entry.oci.registry,
|
|
853
|
+
repository: entry.oci.repository,
|
|
854
|
+
digest: entry.oci.digest,
|
|
855
|
+
version: entry.version,
|
|
856
|
+
...(options.allowedOciRegistries ? { allowedRegistries: options.allowedOciRegistries } : {}),
|
|
857
|
+
});
|
|
858
|
+
const transport = {
|
|
859
|
+
registry: reference.registry,
|
|
860
|
+
repository: reference.repository,
|
|
861
|
+
scheme: options.ociScheme ?? "https",
|
|
862
|
+
timeoutMs: options.ociTimeoutMs,
|
|
863
|
+
fetchImpl: options.fetchImpl,
|
|
864
|
+
retryOptions: options.retryOptions,
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
// A pinned digest is used as-is, and an unpinned entry is REFUSED rather
|
|
868
|
+
// than resolved — unless the caller is the first-pin entrypoint and says so.
|
|
869
|
+
//
|
|
870
|
+
// Resolving a tag at install time is the defect C1.2 and C2.3 name: a tag is
|
|
871
|
+
// a mutable name, so an entry carrying only a version installs whatever that
|
|
872
|
+
// name points at today, and the lock records nothing that would detect the
|
|
873
|
+
// change. This was previously only a comment; `fetchOciArtifact` serves both
|
|
874
|
+
// the lock-driven path and the CLI's first pin, so the comment described an
|
|
875
|
+
// intent the code did not enforce and every digest-less lock entry silently
|
|
876
|
+
// re-resolved. `allowTagResolution` is set by the first-pin path alone, which
|
|
877
|
+
// is the one operation whose whole purpose is to turn a tag into a digest.
|
|
878
|
+
if (!reference.digest && !options.allowTagResolution) {
|
|
879
|
+
throw new OciRegistryError(
|
|
880
|
+
`OCI entry for ${entry.connectorId} carries no digest; ` +
|
|
881
|
+
`a lock entry must be pinned by digest and is never resolved from its version at install time`,
|
|
882
|
+
"invalid-reference"
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
const digest = reference.digest ?? (await resolveVersionToDigest({ ...transport, version: entry.version }));
|
|
886
|
+
|
|
887
|
+
const { manifest } = await fetchManifestByDigest({ ...transport, digest });
|
|
888
|
+
|
|
889
|
+
// Before any layer is written — or fetched (C3.1).
|
|
890
|
+
const trust = await verifyOciSignature({
|
|
891
|
+
...transport,
|
|
892
|
+
digest,
|
|
893
|
+
certificateIdentityResolver:
|
|
894
|
+
options.ociCertificateIdentityResolver ?? defaultOciCertificateIdentityResolver,
|
|
895
|
+
sigstoreVerifier: options.sigstoreVerifier,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
const layers = indexLayersByMediaType(manifest, { repository: reference.repository });
|
|
899
|
+
|
|
900
|
+
if (manifest?.config?.mediaType !== OCI_CONFIG_MEDIA_TYPE) {
|
|
901
|
+
throw new OciRegistryError(
|
|
902
|
+
`Refusing ${reference.repository}: unexpected config media type "${manifest?.config?.mediaType}"`,
|
|
903
|
+
"unsupported-layer"
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Every one of these is digest-verified inside `fetchBlob` against the
|
|
908
|
+
// descriptor that named it (C4.1).
|
|
909
|
+
const configBytes = await fetchBlob({ ...transport, digest: manifest.config.digest });
|
|
910
|
+
const profileBytes = await fetchBlob({ ...transport, digest: layers.profile.digest });
|
|
911
|
+
const codeBytes = await fetchBlob({ ...transport, digest: layers.code.digest });
|
|
912
|
+
const provenanceBytes = await fetchBlob({ ...transport, digest: layers.provenance.digest });
|
|
913
|
+
const licensesBytes = await fetchBlob({ ...transport, digest: layers.licenses.digest });
|
|
914
|
+
const assetsBytes = layers.assets
|
|
915
|
+
? await fetchBlob({ ...transport, digest: layers.assets.digest })
|
|
916
|
+
: null;
|
|
917
|
+
|
|
918
|
+
let config;
|
|
919
|
+
let profile;
|
|
920
|
+
try {
|
|
921
|
+
config = JSON.parse(configBytes.toString("utf8"));
|
|
922
|
+
profile = JSON.parse(profileBytes.toString("utf8"));
|
|
923
|
+
} catch (error) {
|
|
924
|
+
throw new OciRegistryError(
|
|
925
|
+
`Refusing ${reference.repository}: config or profile is not JSON (${error.message})`,
|
|
926
|
+
"tampered"
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
assertConfigMatchesProfile({
|
|
931
|
+
config,
|
|
932
|
+
profileBytes,
|
|
933
|
+
profile,
|
|
934
|
+
repository: reference.repository,
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
// The key the lock used to build the repository path must be the key the
|
|
938
|
+
// artifact declares, or the lock fetched from a repository that does not
|
|
939
|
+
// belong to the connector it is installing.
|
|
940
|
+
if (config.connector_key !== connectorKey) {
|
|
941
|
+
throw new OciRegistryError(
|
|
942
|
+
`Refusing ${reference.repository}: artifact declares connector_key ` +
|
|
943
|
+
`"${config.connector_key}" but the lock names "${connectorKey}"`,
|
|
944
|
+
"misidentified"
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// C4.7: the version installed is the version the lock claims.
|
|
949
|
+
if (entry.version && profile.version !== entry.version) {
|
|
950
|
+
throw new OciRegistryError(
|
|
951
|
+
`${entry.connectorId} version mismatch: lock says ${entry.version} ` +
|
|
952
|
+
`but the artifact profile declares ${profile.version}`,
|
|
953
|
+
"tampered"
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const codeFiles = await readLayerArchive(codeBytes, `${reference.repository} code layer`);
|
|
958
|
+
const entrypointInArtifact = config.entrypoint;
|
|
959
|
+
let entrypointSegments;
|
|
960
|
+
try {
|
|
961
|
+
const validPath = validateRelativeArtifactPath(entrypointInArtifact, "config.entrypoint");
|
|
962
|
+
entrypointSegments = validPath.split("/");
|
|
963
|
+
if (entrypointSegments.some((segment) => segment === "" || segment === ".")) {
|
|
964
|
+
throw new Error(`Invalid config.entrypoint "${entrypointInArtifact}"`);
|
|
965
|
+
}
|
|
966
|
+
} catch (error) {
|
|
967
|
+
throw new OciRegistryError(
|
|
968
|
+
`Refusing ${reference.repository}: ${error.message}`,
|
|
969
|
+
"tampered"
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
const [entrypointLayer, ...memberSegments] = entrypointSegments;
|
|
973
|
+
if (entrypointLayer !== "code" || memberSegments.length === 0) {
|
|
974
|
+
throw new OciRegistryError(
|
|
975
|
+
`Refusing ${reference.repository}: config.entrypoint "${entrypointInArtifact}" ` +
|
|
976
|
+
`must name a member of the code layer`,
|
|
977
|
+
"tampered"
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
const codeEntrypointMember = memberSegments.join("/");
|
|
981
|
+
const entrypointFile = codeFiles.find((file) => file.path === codeEntrypointMember);
|
|
982
|
+
// C4.6: the entrypoint the config declares must actually be in the code layer.
|
|
983
|
+
if (!entrypointFile) {
|
|
984
|
+
throw new OciRegistryError(
|
|
985
|
+
`Refusing ${reference.repository}: config.entrypoint "${entrypointInArtifact}" ` +
|
|
986
|
+
`is not present in the code layer`,
|
|
987
|
+
"tampered"
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const licenseFiles = await readLayerArchive(
|
|
992
|
+
licensesBytes,
|
|
993
|
+
`${reference.repository} licenses layer`
|
|
994
|
+
);
|
|
995
|
+
const assetFiles = assetsBytes
|
|
996
|
+
? await readLayerArchive(assetsBytes, `${reference.repository} assets layer`)
|
|
997
|
+
: [];
|
|
998
|
+
|
|
999
|
+
// Translated into the layout the installed tree already has (C5.1): the
|
|
1000
|
+
// artifact stores its entrypoint at `code/collection-profile.mjs`, the
|
|
1001
|
+
// installed tree expects it at the lock's `entrypointPath`. Licences and
|
|
1002
|
+
// assets get their own subtrees, and licences are written rather than
|
|
1003
|
+
// dropped because distribution requires shipping them (C5.4).
|
|
1004
|
+
return {
|
|
1005
|
+
manifest: profile,
|
|
1006
|
+
manifestBuffer: profileBytes,
|
|
1007
|
+
entrypointBuffer: entrypointFile.buffer,
|
|
1008
|
+
entrypointPath: entry.entrypointPath,
|
|
1009
|
+
provenanceBuffer: provenanceBytes,
|
|
1010
|
+
provenancePath: entry.provenancePath,
|
|
1011
|
+
artifactKind: entry.artifactKind,
|
|
1012
|
+
schemaFiles: [],
|
|
1013
|
+
assetFiles: [
|
|
1014
|
+
...licenseFiles.map((file) => ({ path: `licenses/${file.path}`, buffer: file.buffer })),
|
|
1015
|
+
...assetFiles.map((file) => ({ path: `assets/${file.path}`, buffer: file.buffer })),
|
|
1016
|
+
],
|
|
1017
|
+
readme: null,
|
|
1018
|
+
oci: {
|
|
1019
|
+
registry: reference.registry,
|
|
1020
|
+
repository: reference.repository,
|
|
1021
|
+
digest,
|
|
1022
|
+
configDigest: manifest.config.digest,
|
|
1023
|
+
certificateIdentityURI: trust.certificateIdentityURI,
|
|
1024
|
+
},
|
|
1025
|
+
checksums: {
|
|
1026
|
+
artifact: digest,
|
|
1027
|
+
manifest: sha256Digest(profileBytes),
|
|
1028
|
+
entrypoint: sha256Digest(entrypointFile.buffer),
|
|
1029
|
+
provenance: sha256Digest(provenanceBytes),
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function fetchArtifactForEntry(indexSource, entry, options = {}) {
|
|
1035
|
+
const resolvedEntry = enrichRemoteEntry(indexSource, entry);
|
|
1036
|
+
|
|
1037
|
+
if (indexSource?.mode === "local") {
|
|
1038
|
+
const artifactPath = resolveArtifactLocalPath(
|
|
1039
|
+
indexSource.rootDir,
|
|
1040
|
+
resolvedEntry.artifactPath,
|
|
1041
|
+
resolvedEntry.connectorId
|
|
1042
|
+
);
|
|
1043
|
+
return readFileSync(artifactPath);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (!resolvedEntry.artifactUrl) {
|
|
1047
|
+
throw new Error(`Connector ${resolvedEntry.connectorId} is missing artifactUrl`);
|
|
1048
|
+
}
|
|
1049
|
+
const artifactBuffer = await fetchBinary(resolvedEntry.artifactUrl, {
|
|
1050
|
+
fetchImpl: options.fetchImpl,
|
|
1051
|
+
...options.retryOptions,
|
|
1052
|
+
});
|
|
1053
|
+
const certificateIdentityURI = await resolveArtifactCertificateIdentity({
|
|
1054
|
+
artifactCertificateIdentityResolver: options.artifactCertificateIdentityResolver,
|
|
1055
|
+
artifactUrl: resolvedEntry.artifactUrl,
|
|
1056
|
+
entry: resolvedEntry,
|
|
1057
|
+
});
|
|
1058
|
+
await verifyRemoteSignature({
|
|
1059
|
+
payloadBuffer: artifactBuffer,
|
|
1060
|
+
subjectLabel: `Connector artifact ${resolvedEntry.connectorId}@${resolvedEntry.version}`,
|
|
1061
|
+
subjectUrl: resolvedEntry.artifactUrl,
|
|
1062
|
+
signature: resolvedEntry.artifactSignature,
|
|
1063
|
+
certificateIdentityURI,
|
|
1064
|
+
sigstoreVerifier: options.sigstoreVerifier,
|
|
1065
|
+
fetchImpl: options.fetchImpl,
|
|
1066
|
+
retryOptions: options.retryOptions,
|
|
1067
|
+
});
|
|
1068
|
+
return artifactBuffer;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function unpackAndVerifyArtifact(entry, artifactBuffer) {
|
|
1072
|
+
const artifactChecksum = sha256Buffer(artifactBuffer);
|
|
1073
|
+
if (entry.artifactSha256 && entry.artifactSha256 !== artifactChecksum) {
|
|
1074
|
+
throw new Error(
|
|
1075
|
+
`${entry.connectorId} artifact checksum mismatch: expected ${entry.artifactSha256}, got ${artifactChecksum}`
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const unpacked = unpackArtifactBuffer(entry, artifactBuffer);
|
|
1080
|
+
const manifest = JSON.parse(unpacked.manifestBuffer.toString("utf8"));
|
|
1081
|
+
const manifestChecksum = sha256Buffer(unpacked.manifestBuffer);
|
|
1082
|
+
const entrypointChecksum = sha256Buffer(unpacked.entrypointBuffer);
|
|
1083
|
+
const provenanceChecksum = unpacked.provenanceBuffer
|
|
1084
|
+
? sha256Buffer(unpacked.provenanceBuffer)
|
|
1085
|
+
: null;
|
|
1086
|
+
|
|
1087
|
+
if (entry.manifestSha256 && entry.manifestSha256 !== manifestChecksum) {
|
|
1088
|
+
throw new Error(
|
|
1089
|
+
`${entry.connectorId} manifest checksum mismatch: expected ${entry.manifestSha256}, got ${manifestChecksum}`
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
if (unpacked.entrypointChecksum && unpacked.entrypointChecksum !== entrypointChecksum) {
|
|
1093
|
+
throw new Error(
|
|
1094
|
+
`${entry.connectorId} ${unpacked.entrypointLabel} checksum mismatch: expected ${unpacked.entrypointChecksum}, got ${entrypointChecksum}`
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
if (unpacked.provenanceChecksum && unpacked.provenanceChecksum !== provenanceChecksum) {
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
`${entry.connectorId} provenance checksum mismatch: expected ${unpacked.provenanceChecksum}, got ${provenanceChecksum}`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (entry.version && manifest.version !== entry.version) {
|
|
1104
|
+
throw new Error(
|
|
1105
|
+
`${entry.connectorId} version mismatch: index says ${entry.version} but artifact manifest declares ${manifest.version}`
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (entry.artifactKind === "legacy" && manifest.connector_id && manifest.connector_id !== entry.connectorId) {
|
|
1110
|
+
throw new Error(
|
|
1111
|
+
`${entry.connectorId} artifact manifest declares connector_id ${manifest.connector_id}`
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
return {
|
|
1116
|
+
manifest,
|
|
1117
|
+
manifestBuffer: unpacked.manifestBuffer,
|
|
1118
|
+
entrypointBuffer: unpacked.entrypointBuffer,
|
|
1119
|
+
entrypointPath: unpacked.entrypointPath,
|
|
1120
|
+
provenanceBuffer: unpacked.provenanceBuffer,
|
|
1121
|
+
provenancePath: unpacked.provenancePath,
|
|
1122
|
+
artifactKind: entry.artifactKind,
|
|
1123
|
+
...(entry.artifactKind === "legacy" ? { scriptBuffer: unpacked.entrypointBuffer } : {}),
|
|
1124
|
+
schemaFiles: unpacked.schemaFiles,
|
|
1125
|
+
assetFiles: unpacked.assetFiles,
|
|
1126
|
+
readme: unpacked.readme,
|
|
1127
|
+
checksums: {
|
|
1128
|
+
artifact: artifactChecksum,
|
|
1129
|
+
manifest: manifestChecksum,
|
|
1130
|
+
entrypoint: entrypointChecksum,
|
|
1131
|
+
...(provenanceChecksum ? { provenance: provenanceChecksum } : {}),
|
|
1132
|
+
...(entry.artifactKind === "legacy" ? { script: entrypointChecksum } : {}),
|
|
1133
|
+
},
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function normalizeFetchedArtifact(entry, artifact) {
|
|
1138
|
+
return {
|
|
1139
|
+
connectorId: entry.connectorId,
|
|
1140
|
+
company: entry.company,
|
|
1141
|
+
version: entry.version,
|
|
1142
|
+
resolvedFrom: entry.resolvedFrom,
|
|
1143
|
+
entry,
|
|
1144
|
+
...artifact,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function projectFetchedArtifact(entry, artifact) {
|
|
1149
|
+
if (entry.artifactKind === "legacy") {
|
|
1150
|
+
return {
|
|
1151
|
+
manifest: artifact.manifest,
|
|
1152
|
+
manifestBuffer: artifact.manifestBuffer,
|
|
1153
|
+
scriptBuffer: artifact.entrypointBuffer,
|
|
1154
|
+
schemaFiles: artifact.schemaFiles,
|
|
1155
|
+
assetFiles: artifact.assetFiles,
|
|
1156
|
+
readme: artifact.readme,
|
|
1157
|
+
checksums: {
|
|
1158
|
+
artifact: artifact.checksums.artifact,
|
|
1159
|
+
manifest: artifact.checksums.manifest,
|
|
1160
|
+
script: artifact.checksums.script,
|
|
1161
|
+
},
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
return artifact;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function metadataDirFromSourceFiles(entry) {
|
|
1168
|
+
const sourceFiles = entry.sourceFiles;
|
|
1169
|
+
if (!sourceFiles?.metadata || !sourceFiles?.script) {
|
|
1170
|
+
throw new Error(`Connector ${entry.connectorId} is missing sourceFiles metadata/script`);
|
|
1171
|
+
}
|
|
1172
|
+
return dirname(sourceFiles.metadata);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function buildSnapshotWrites(installRoot, resolved) {
|
|
1176
|
+
const writes = [
|
|
1177
|
+
{
|
|
1178
|
+
relativePath: `manifests/${resolved.connectorId}.json`,
|
|
1179
|
+
buffer: resolved.manifestBuffer,
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
relativePath: `scripts/${resolved.connectorId}.js`,
|
|
1183
|
+
buffer: resolved.entrypointBuffer,
|
|
1184
|
+
},
|
|
1185
|
+
];
|
|
1186
|
+
|
|
1187
|
+
for (const schemaFile of resolved.schemaFiles) {
|
|
1188
|
+
const fileName = schemaFile.path.split("/").at(-1);
|
|
1189
|
+
if (!fileName) continue;
|
|
1190
|
+
const relativePath = `schemas/${fileName}`;
|
|
1191
|
+
writes.push({
|
|
1192
|
+
relativePath,
|
|
1193
|
+
buffer: schemaFile.buffer,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
for (const assetFile of resolved.assetFiles) {
|
|
1198
|
+
writes.push({
|
|
1199
|
+
relativePath: `assets/${resolved.connectorId}/${assetFile.path}`,
|
|
1200
|
+
buffer: assetFile.buffer,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return writes.map((write) => ({
|
|
1205
|
+
...write,
|
|
1206
|
+
absolutePath: join(installRoot, write.relativePath),
|
|
1207
|
+
}));
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function buildSourceWrites(installRoot, resolved) {
|
|
1211
|
+
const metadataDir = metadataDirFromSourceFiles(resolved.entry);
|
|
1212
|
+
const writes = [
|
|
1213
|
+
{
|
|
1214
|
+
relativePath: resolved.entry.sourceFiles.metadata,
|
|
1215
|
+
buffer: resolved.manifestBuffer,
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
relativePath: resolved.entry.sourceFiles.script,
|
|
1219
|
+
buffer: resolved.entrypointBuffer,
|
|
1220
|
+
},
|
|
1221
|
+
];
|
|
1222
|
+
|
|
1223
|
+
for (const schemaFile of resolved.schemaFiles) {
|
|
1224
|
+
const fileName = schemaFile.path.split("/").at(-1);
|
|
1225
|
+
if (!fileName || fileName === "manifest.schema.json") continue;
|
|
1226
|
+
writes.push({
|
|
1227
|
+
relativePath: join(metadataDir, "schemas", fileName),
|
|
1228
|
+
buffer: schemaFile.buffer,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
for (const assetFile of resolved.assetFiles) {
|
|
1233
|
+
writes.push({
|
|
1234
|
+
relativePath: join(metadataDir, assetFile.path),
|
|
1235
|
+
buffer: assetFile.buffer,
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (resolved.readme) {
|
|
1240
|
+
writes.push({
|
|
1241
|
+
relativePath: join(metadataDir, resolved.readme.path),
|
|
1242
|
+
buffer: resolved.readme.buffer,
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
return writes.map((write) => ({
|
|
1247
|
+
...write,
|
|
1248
|
+
absolutePath: join(installRoot, write.relativePath),
|
|
1249
|
+
}));
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function buildPdppCollectionProfileWrites(installRoot, resolved) {
|
|
1253
|
+
const artifactRoot = `collection-profiles/${resolved.connectorId}`;
|
|
1254
|
+
const writes = [
|
|
1255
|
+
{
|
|
1256
|
+
relativePath: `${artifactRoot}/${resolved.entry.manifestPath}`,
|
|
1257
|
+
buffer: resolved.manifestBuffer,
|
|
1258
|
+
},
|
|
1259
|
+
{
|
|
1260
|
+
relativePath: `${artifactRoot}/${resolved.entry.entrypointPath}`,
|
|
1261
|
+
buffer: resolved.entrypointBuffer,
|
|
1262
|
+
},
|
|
1263
|
+
{
|
|
1264
|
+
relativePath: `${artifactRoot}/${resolved.entry.provenancePath}`,
|
|
1265
|
+
buffer: resolved.provenanceBuffer,
|
|
1266
|
+
},
|
|
1267
|
+
// Licences and brand assets, which the publisher ships unconditionally
|
|
1268
|
+
// because distributing the code requires distributing them (C5.4), so they
|
|
1269
|
+
// are written rather than dropped on the floor.
|
|
1270
|
+
//
|
|
1271
|
+
// Gated on `resolved.oci` rather than on `assetFiles` being non-empty,
|
|
1272
|
+
// and the difference is not cosmetic. A TARBALL collection-profile
|
|
1273
|
+
// artifact also populates `assetFiles` — `unpackArtifactBuffer` puts every
|
|
1274
|
+
// file that is not the manifest, entrypoint, provenance, a schema or the
|
|
1275
|
+
// README there. Spreading it unconditionally therefore starts installing
|
|
1276
|
+
// stray files from tarball artifacts that were previously ignored, which
|
|
1277
|
+
// is a behaviour change to the path this PR is supposed to leave alone.
|
|
1278
|
+
// Measured, not assumed: an unconditional spread wrote a fourth file for a
|
|
1279
|
+
// tarball artifact carrying one extra member. Today's two published
|
|
1280
|
+
// collection profiles happen to carry no such member, so no existing test
|
|
1281
|
+
// fails either way — which is exactly why the gate is on the transport and
|
|
1282
|
+
// not on whether the list happens to be empty.
|
|
1283
|
+
...(resolved.oci
|
|
1284
|
+
? resolved.assetFiles.map((file) => ({
|
|
1285
|
+
relativePath: `${artifactRoot}/${validateRelativeArtifactPath(file.path, "artifact asset path")}`,
|
|
1286
|
+
buffer: file.buffer,
|
|
1287
|
+
}))
|
|
1288
|
+
: []),
|
|
1289
|
+
];
|
|
1290
|
+
|
|
1291
|
+
return writes.map((write) => ({
|
|
1292
|
+
...write,
|
|
1293
|
+
absolutePath: join(installRoot, write.relativePath),
|
|
1294
|
+
}));
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function buildInstallWrites(layout, installRoot, resolved) {
|
|
1298
|
+
if (resolved.artifactKind === "pdpp-collection-profile") {
|
|
1299
|
+
if (layout === "snapshot" || layout === "source") {
|
|
1300
|
+
return buildPdppCollectionProfileWrites(installRoot, resolved);
|
|
1301
|
+
}
|
|
1302
|
+
throw new Error(`Unsupported install layout "${layout}"`);
|
|
1303
|
+
}
|
|
1304
|
+
if (layout === "snapshot") {
|
|
1305
|
+
return buildSnapshotWrites(installRoot, resolved);
|
|
1306
|
+
}
|
|
1307
|
+
if (layout === "source") {
|
|
1308
|
+
return buildSourceWrites(installRoot, resolved);
|
|
1309
|
+
}
|
|
1310
|
+
throw new Error(`Unsupported install layout "${layout}"`);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async function fetchLockArtifacts({
|
|
1314
|
+
lock,
|
|
1315
|
+
source,
|
|
1316
|
+
artifactCertificateIdentityResolver,
|
|
1317
|
+
...options
|
|
1318
|
+
}) {
|
|
1319
|
+
const normalizedEntries = (lock.connectors ?? []).map(normalizeLockEntry);
|
|
1320
|
+
const resolved = [];
|
|
1321
|
+
|
|
1322
|
+
for (const entry of normalizedEntries) {
|
|
1323
|
+
const artifact = await fetchEntryArtifact(source, entry, {
|
|
1324
|
+
artifactCertificateIdentityResolver,
|
|
1325
|
+
...options,
|
|
1326
|
+
});
|
|
1327
|
+
resolved.push(normalizeFetchedArtifact(entry, artifact));
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
return resolved;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* The transport dispatch: an entry carrying an `oci` block is pulled from a
|
|
1335
|
+
* registry, everything else keeps the tarball path byte for byte.
|
|
1336
|
+
*
|
|
1337
|
+
* The lock is the switch. There is no feature flag and no environment
|
|
1338
|
+
* variable, because the presence of `oci` on an entry already says which
|
|
1339
|
+
* transport that entry uses, and a flag would be a second place for the answer
|
|
1340
|
+
* to live — one that CI cannot diff.
|
|
1341
|
+
*/
|
|
1342
|
+
async function fetchEntryArtifact(source, entry, options = {}) {
|
|
1343
|
+
if (entry.oci) {
|
|
1344
|
+
return fetchOciArtifact(entry, options);
|
|
1345
|
+
}
|
|
1346
|
+
const artifactBuffer = await fetchArtifactForEntry(source, entry, options);
|
|
1347
|
+
return unpackAndVerifyArtifact(entry, artifactBuffer);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function expectedWritesForLock({ installRoot, layout, resolved }) {
|
|
1351
|
+
return resolved.flatMap((entry) => buildInstallWrites(layout, installRoot, entry));
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function ensureParentDir(path) {
|
|
1355
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function removeUnexpectedEntries(installRoot, expectedPaths, preserveTopLevel = []) {
|
|
1359
|
+
const expected = new Set(expectedPaths);
|
|
1360
|
+
const preserve = new Set(preserveTopLevel);
|
|
1361
|
+
|
|
1362
|
+
function pruneDirectory(dir, relativeDir = "") {
|
|
1363
|
+
for (const entry of readdirSync(dir)) {
|
|
1364
|
+
if (entry.startsWith(".")) {
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
const relativePath = relativeDir ? `${relativeDir}/${entry}` : entry;
|
|
1368
|
+
const topLevel = relativePath.split("/")[0];
|
|
1369
|
+
if (preserve.has(topLevel)) {
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const path = join(dir, entry);
|
|
1374
|
+
const stat = lstatSync(path);
|
|
1375
|
+
if (stat.isDirectory()) {
|
|
1376
|
+
pruneDirectory(path, relativePath);
|
|
1377
|
+
if (readdirSync(path).length === 0) {
|
|
1378
|
+
rmSync(path, { recursive: true, force: true });
|
|
1379
|
+
}
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
if (!expected.has(relativePath)) {
|
|
1383
|
+
rmSync(path, { force: true });
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if (existsSync(installRoot)) {
|
|
1389
|
+
pruneDirectory(installRoot);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
export async function fetchResolvedArtifact(indexSource, entry, options = {}) {
|
|
1394
|
+
const normalizedEntry = normalizeLockEntry(entry);
|
|
1395
|
+
return projectFetchedArtifact(
|
|
1396
|
+
normalizedEntry,
|
|
1397
|
+
await fetchEntryArtifact(indexSource, normalizedEntry, options)
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
export async function resolveConnectorArtifacts({
|
|
1402
|
+
dependencies,
|
|
1403
|
+
requestedConnectorIds,
|
|
1404
|
+
source,
|
|
1405
|
+
artifactCertificateIdentityResolver,
|
|
1406
|
+
}) {
|
|
1407
|
+
const connectorIds = requestedConnectorIds ?? Object.keys(dependencies.connectors);
|
|
1408
|
+
const resolved = [];
|
|
1409
|
+
|
|
1410
|
+
for (const connectorId of connectorIds) {
|
|
1411
|
+
const constraint = dependencies.connectors[connectorId];
|
|
1412
|
+
if (!constraint) {
|
|
1413
|
+
throw new Error(`Missing version constraint for ${connectorId}`);
|
|
1414
|
+
}
|
|
1415
|
+
const availableEntries = extractAvailableVersions(source.doc, connectorId);
|
|
1416
|
+
const selected = selectResolvedEntry(availableEntries, constraint, connectorId);
|
|
1417
|
+
const artifact = await fetchResolvedArtifact(source, selected, {
|
|
1418
|
+
artifactCertificateIdentityResolver,
|
|
1419
|
+
});
|
|
1420
|
+
resolved.push({
|
|
1421
|
+
connectorId,
|
|
1422
|
+
constraint,
|
|
1423
|
+
entry: selected,
|
|
1424
|
+
...artifact,
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
return {
|
|
1429
|
+
source,
|
|
1430
|
+
resolved,
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
export async function generateLock({
|
|
1435
|
+
dependencies,
|
|
1436
|
+
source,
|
|
1437
|
+
dependencyFile = null,
|
|
1438
|
+
lockVersion = "1.0",
|
|
1439
|
+
generatedAt = new Date().toISOString(),
|
|
1440
|
+
requestedConnectorIds,
|
|
1441
|
+
artifactCertificateIdentityResolver,
|
|
1442
|
+
}) {
|
|
1443
|
+
const resolution = await resolveConnectorArtifacts({
|
|
1444
|
+
dependencies,
|
|
1445
|
+
source,
|
|
1446
|
+
requestedConnectorIds,
|
|
1447
|
+
artifactCertificateIdentityResolver,
|
|
1448
|
+
});
|
|
1449
|
+
const sourceMeta = deriveSourceMeta(
|
|
1450
|
+
source,
|
|
1451
|
+
resolution.resolved.map((entry) => entry.entry)
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
return {
|
|
1455
|
+
lockVersion,
|
|
1456
|
+
dependencyFile,
|
|
1457
|
+
generatedAt,
|
|
1458
|
+
sourceRepo:
|
|
1459
|
+
source.doc.sourceRepo ??
|
|
1460
|
+
dependencies.source_repo ??
|
|
1461
|
+
"https://github.com/PDP-Connect/data-connectors",
|
|
1462
|
+
sourceTag: sourceMeta.sourceTag,
|
|
1463
|
+
sourceCommit: sourceMeta.sourceCommit,
|
|
1464
|
+
index: {
|
|
1465
|
+
mode: source.mode,
|
|
1466
|
+
path: source.indexPath,
|
|
1467
|
+
url: source.indexUrl,
|
|
1468
|
+
version: source.doc.indexVersion ?? "unknown",
|
|
1469
|
+
signatureVerified: source.signatureVerified ?? false,
|
|
1470
|
+
},
|
|
1471
|
+
dependencies: dependencies.connectors,
|
|
1472
|
+
connectors: resolution.resolved
|
|
1473
|
+
.map((resolved) => ({
|
|
1474
|
+
connectorId: resolved.connectorId,
|
|
1475
|
+
company: resolved.entry.company,
|
|
1476
|
+
version: resolved.entry.version,
|
|
1477
|
+
resolvedFrom: resolved.constraint,
|
|
1478
|
+
sourceFiles: resolved.entry.sourceFiles,
|
|
1479
|
+
artifactUrl: resolved.entry.artifactUrl ?? null,
|
|
1480
|
+
artifactPath: resolved.entry.artifactPath ?? null,
|
|
1481
|
+
artifactSha256: resolved.checksums.artifact,
|
|
1482
|
+
artifactSignature: resolved.entry.artifactSignature ?? null,
|
|
1483
|
+
manifestSha256: resolved.checksums.manifest,
|
|
1484
|
+
...(resolved.entry.artifactKind === "pdpp-collection-profile"
|
|
1485
|
+
? {
|
|
1486
|
+
artifactKind: resolved.entry.artifactKind,
|
|
1487
|
+
manifestPath: resolved.entry.manifestPath,
|
|
1488
|
+
entrypointPath: resolved.entry.entrypointPath,
|
|
1489
|
+
entrypointSha256: resolved.checksums.entrypoint,
|
|
1490
|
+
provenancePath: resolved.entry.provenancePath,
|
|
1491
|
+
provenanceSha256: resolved.checksums.provenance,
|
|
1492
|
+
}
|
|
1493
|
+
: { scriptSha256: resolved.checksums.script }),
|
|
1494
|
+
sourceTag: resolved.entry.sourceTag ?? resolved.entry.gitRef ?? sourceMeta.sourceTag,
|
|
1495
|
+
sourceCommit:
|
|
1496
|
+
resolved.entry.sourceCommit ?? resolved.entry.gitRef ?? sourceMeta.sourceCommit,
|
|
1497
|
+
releaseId: resolved.entry.releaseId ?? null,
|
|
1498
|
+
publishedAt: resolved.entry.publishedAt ?? null,
|
|
1499
|
+
name: resolved.entry.name ?? resolved.manifest.name,
|
|
1500
|
+
description: resolved.entry.description ?? resolved.manifest.description,
|
|
1501
|
+
}))
|
|
1502
|
+
.sort((a, b) => a.connectorId.localeCompare(b.connectorId)),
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
export async function checkForUpdates({ lock, indexDoc }) {
|
|
1507
|
+
const updates = [];
|
|
1508
|
+
|
|
1509
|
+
for (const rawLockEntry of lock.connectors ?? []) {
|
|
1510
|
+
const lockEntry = normalizeLockEntry(rawLockEntry);
|
|
1511
|
+
const availableEntries = extractAvailableVersions(indexDoc, lockEntry.connectorId);
|
|
1512
|
+
if (availableEntries.length === 0) {
|
|
1513
|
+
updates.push({
|
|
1514
|
+
connectorId: lockEntry.connectorId,
|
|
1515
|
+
status: "missing_from_index",
|
|
1516
|
+
currentVersion: lockEntry.version,
|
|
1517
|
+
latestVersion: null,
|
|
1518
|
+
});
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const latest = availableEntries.sort((a, b) => compareVersions(b.version, a.version))[0];
|
|
1523
|
+
if (compareVersions(latest.version, lockEntry.version) > 0) {
|
|
1524
|
+
updates.push({
|
|
1525
|
+
connectorId: lockEntry.connectorId,
|
|
1526
|
+
status: "update_available",
|
|
1527
|
+
currentVersion: lockEntry.version,
|
|
1528
|
+
latestVersion: latest.version,
|
|
1529
|
+
artifactSha256: latest.artifactSha256,
|
|
1530
|
+
artifactSignature: normalizeSignature(latest.artifactSignature ?? null),
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
return {
|
|
1536
|
+
hasUpdates: updates.length > 0,
|
|
1537
|
+
updates,
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
export function pruneInstalled({
|
|
1542
|
+
installRoot,
|
|
1543
|
+
expectedPaths,
|
|
1544
|
+
preserveTopLevel = [],
|
|
1545
|
+
}) {
|
|
1546
|
+
removeUnexpectedEntries(installRoot, expectedPaths, preserveTopLevel);
|
|
1547
|
+
return {
|
|
1548
|
+
installRoot,
|
|
1549
|
+
expectedCount: expectedPaths.length,
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
export async function installFromLock({
|
|
1554
|
+
lock,
|
|
1555
|
+
source,
|
|
1556
|
+
installRoot,
|
|
1557
|
+
layout,
|
|
1558
|
+
prune = false,
|
|
1559
|
+
preserveTopLevel = [],
|
|
1560
|
+
artifactCertificateIdentityResolver,
|
|
1561
|
+
...options
|
|
1562
|
+
}) {
|
|
1563
|
+
const resolved = await fetchLockArtifacts({
|
|
1564
|
+
lock,
|
|
1565
|
+
source,
|
|
1566
|
+
artifactCertificateIdentityResolver,
|
|
1567
|
+
...options,
|
|
1568
|
+
});
|
|
1569
|
+
const writes = expectedWritesForLock({ installRoot, layout, resolved });
|
|
1570
|
+
const expectedPaths = writes.map((write) => write.relativePath);
|
|
1571
|
+
|
|
1572
|
+
for (const write of writes) {
|
|
1573
|
+
ensureParentDir(write.absolutePath);
|
|
1574
|
+
writeFileSync(write.absolutePath, write.buffer);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
if (prune) {
|
|
1578
|
+
pruneInstalled({
|
|
1579
|
+
installRoot,
|
|
1580
|
+
expectedPaths,
|
|
1581
|
+
preserveTopLevel,
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
return {
|
|
1586
|
+
installRoot,
|
|
1587
|
+
layout,
|
|
1588
|
+
connectorCount: resolved.length,
|
|
1589
|
+
filesWritten: writes.length,
|
|
1590
|
+
expectedPaths,
|
|
1591
|
+
// What was actually installed, by digest. A first pin resolves a tag, and
|
|
1592
|
+
// the digest it resolved to is the thing the caller needs in order to write
|
|
1593
|
+
// a lock entry that will not re-resolve (C1.2); printing it is how a
|
|
1594
|
+
// one-off `--oci ref:version` pull becomes a pinned one.
|
|
1595
|
+
pinned: resolved
|
|
1596
|
+
.filter((artifact) => artifact.oci)
|
|
1597
|
+
.map((artifact) => ({
|
|
1598
|
+
connectorId: artifact.connectorId,
|
|
1599
|
+
version: artifact.version,
|
|
1600
|
+
registry: artifact.oci.registry,
|
|
1601
|
+
repository: artifact.oci.repository,
|
|
1602
|
+
digest: artifact.oci.digest,
|
|
1603
|
+
})),
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
export async function verifyInstalled({
|
|
1608
|
+
lock,
|
|
1609
|
+
source,
|
|
1610
|
+
installRoot,
|
|
1611
|
+
layout,
|
|
1612
|
+
artifactCertificateIdentityResolver,
|
|
1613
|
+
...options
|
|
1614
|
+
}) {
|
|
1615
|
+
const resolved = await fetchLockArtifacts({
|
|
1616
|
+
lock,
|
|
1617
|
+
source,
|
|
1618
|
+
artifactCertificateIdentityResolver,
|
|
1619
|
+
...options,
|
|
1620
|
+
});
|
|
1621
|
+
const writes = expectedWritesForLock({ installRoot, layout, resolved });
|
|
1622
|
+
const missing = [];
|
|
1623
|
+
const mismatched = [];
|
|
1624
|
+
|
|
1625
|
+
for (const write of writes) {
|
|
1626
|
+
if (!existsSync(write.absolutePath)) {
|
|
1627
|
+
missing.push(write.relativePath);
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const currentChecksum = sha256Buffer(readFileSync(write.absolutePath));
|
|
1632
|
+
const expectedChecksum = sha256Buffer(write.buffer);
|
|
1633
|
+
if (currentChecksum !== expectedChecksum) {
|
|
1634
|
+
mismatched.push(write.relativePath);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
return {
|
|
1639
|
+
ok: missing.length === 0 && mismatched.length === 0,
|
|
1640
|
+
installRoot,
|
|
1641
|
+
layout,
|
|
1642
|
+
expectedCount: writes.length,
|
|
1643
|
+
missing,
|
|
1644
|
+
mismatched,
|
|
1645
|
+
};
|
|
1646
|
+
}
|