wyvrnpm 2.12.3 → 2.13.12
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/README.md +162 -1
- package/bin/wyvrnpm.js +167 -11
- package/package.json +1 -1
- package/src/build/recipe.js +25 -3
- package/src/commands/configure.js +61 -1
- package/src/commands/install.js +76 -1
- package/src/commands/key.js +112 -0
- package/src/commands/publish.js +402 -49
- package/src/commands/show.js +23 -0
- package/src/commands/trust.js +136 -0
- package/src/conf/index.js +131 -11
- package/src/config.js +35 -4
- package/src/download.js +408 -11
- package/src/providers/base.js +110 -15
- package/src/providers/file.js +90 -29
- package/src/providers/http.js +109 -32
- package/src/providers/s3.js +103 -31
- package/src/publish/slice.js +414 -0
- package/src/resolve.js +16 -2
- package/src/signing/attestation.js +301 -0
- package/src/signing/canonical.js +80 -0
- package/src/signing/ed25519.js +123 -0
- package/src/signing/index.js +201 -0
- package/src/signing/resolve-context.js +73 -0
- package/src/signing/trust.js +215 -0
- package/src/upload-built.js +88 -14
- package/src/v2-meta.js +98 -0
package/src/download.js
CHANGED
|
@@ -13,6 +13,9 @@ const { evaluateCompat, pickBestCandidate, summarizeReasons } = require('./compa
|
|
|
13
13
|
const { buildFromSource } = require('./build');
|
|
14
14
|
const { uploadSourceBuiltArtefact } = require('./upload-built');
|
|
15
15
|
const { assertAllSafeZipEntryNames } = require('./zip-safe');
|
|
16
|
+
const { sha256OfCanonical } = require('./signing/canonical');
|
|
17
|
+
const { verifyAttestation, parseSigEnvelope } = require('./signing');
|
|
18
|
+
const { assertBoundHashes } = require('./signing/attestation');
|
|
16
19
|
const log = require('./logger');
|
|
17
20
|
|
|
18
21
|
// ---------------------------------------------------------------------------
|
|
@@ -104,6 +107,117 @@ async function tryDownloadFromSources(name, version, packageSources, platform, h
|
|
|
104
107
|
* @param {string[]} packageSources
|
|
105
108
|
* @param {object} authOptions { awsProfile?, token? }
|
|
106
109
|
*/
|
|
110
|
+
/**
|
|
111
|
+
* Run S1 signature verification on a successfully-downloaded v2 artefact.
|
|
112
|
+
*
|
|
113
|
+
* Honours per-registry `mode.json`:
|
|
114
|
+
* - `off` → skip entirely (returns null).
|
|
115
|
+
* - `warn` → att+sig present and valid, log identity; missing → warn + null;
|
|
116
|
+
* invalid → throw (bad sig is stricter than missing).
|
|
117
|
+
* - `require` → att+sig must be present AND valid; otherwise throw.
|
|
118
|
+
*
|
|
119
|
+
* `requireSignatures` (from CLI `--require-signatures`) escalates whatever
|
|
120
|
+
* the registry says to `require` for this run.
|
|
121
|
+
*
|
|
122
|
+
* Throws on any failure that should block the install. Returns
|
|
123
|
+
* `{ keyId, identityHint, alg }` on a successful verify, `null` when mode
|
|
124
|
+
* is off or warn-and-missing.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} args
|
|
127
|
+
* @param {string} args.source registry source URL the zip came from
|
|
128
|
+
* @param {string} args.name
|
|
129
|
+
* @param {string} args.version
|
|
130
|
+
* @param {string} args.profileHash
|
|
131
|
+
* @param {string} [args.contentSha256] SHA256 of the local zip — fat-zip bound check (v1)
|
|
132
|
+
* @param {Object<string, string>} [args.sliceShaMap]
|
|
133
|
+
* `{ config → sha256 }` of locally-computed per-slice hashes — slice
|
|
134
|
+
* bound check (v2). Pass exactly one of `contentSha256` or `sliceShaMap`.
|
|
135
|
+
* @param {object} [args.authOptions] { awsProfile?, token? }
|
|
136
|
+
* @param {boolean} [args.requireSignatures]
|
|
137
|
+
* @returns {Promise<{keyId: string, identityHint: string|null, alg: string}|null>}
|
|
138
|
+
*/
|
|
139
|
+
async function verifyDownloadIfRequired({
|
|
140
|
+
source, name, version, profileHash, contentSha256, sliceShaMap,
|
|
141
|
+
authOptions = {}, requireSignatures = false,
|
|
142
|
+
}) {
|
|
143
|
+
let provider;
|
|
144
|
+
try { provider = getProvider(source); }
|
|
145
|
+
catch { return null; }
|
|
146
|
+
|
|
147
|
+
const trust = await provider.v2GetTrustAnchor({ source, ...authOptions });
|
|
148
|
+
let mode = trust?.mode?.mode ?? 'off';
|
|
149
|
+
if (requireSignatures) mode = 'require';
|
|
150
|
+
if (mode === 'off') return null;
|
|
151
|
+
|
|
152
|
+
const [attBytes, sigBytes] = await Promise.all([
|
|
153
|
+
provider.v2GetAttestation({ source, name, version, profileHash, ...authOptions }),
|
|
154
|
+
provider.v2GetSignature ({ source, name, version, profileHash, ...authOptions }),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
if (!attBytes || !sigBytes) {
|
|
158
|
+
if (mode === 'require') {
|
|
159
|
+
throw new Error(`registry mode=require but signature files missing on ${source}`);
|
|
160
|
+
}
|
|
161
|
+
log.warn(`unsigned artefact (mode=warn): ${name}@${version} [${profileHash}]`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (!trust?.keys) {
|
|
165
|
+
throw new Error(`registry has signature files but no .trust/keys.json — mode=${mode}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Re-fetch the manifest as the bytes the publisher canonicalised.
|
|
169
|
+
// Providers no longer mutate the manifest, so the v2GetMeta result is
|
|
170
|
+
// exactly what was uploaded → the same canonical SHA the attestation
|
|
171
|
+
// signed over.
|
|
172
|
+
const meta = await provider.v2GetMeta({ source, name, version, profileHash, ...authOptions });
|
|
173
|
+
if (!meta) throw new Error(`manifest not available for verification: ${name}@${version}`);
|
|
174
|
+
const manifestSha256 = sha256OfCanonical(meta);
|
|
175
|
+
|
|
176
|
+
const sigEnvelope = parseSigEnvelope(sigBytes.toString('utf8'));
|
|
177
|
+
let result;
|
|
178
|
+
if (sliceShaMap && Object.keys(sliceShaMap).length > 0) {
|
|
179
|
+
// v2 / slice path: cryptographic verify happens against the parsed
|
|
180
|
+
// canonical bytes once. The bound-hash check runs once per downloaded
|
|
181
|
+
// slice — the canonical body covers every slice's hash, so a tampered
|
|
182
|
+
// or omitted slice changes the signed bytes and the sig fails before
|
|
183
|
+
// we even reach assertBoundHashes.
|
|
184
|
+
const sliceConfigs = Object.keys(sliceShaMap);
|
|
185
|
+
const firstConfig = sliceConfigs[0];
|
|
186
|
+
result = verifyAttestation({
|
|
187
|
+
attBytes,
|
|
188
|
+
sigEnvelope,
|
|
189
|
+
trustAnchors: trust.keys,
|
|
190
|
+
expected: {
|
|
191
|
+
name, version, profileHash, manifestSha256,
|
|
192
|
+
sliceConfig: firstConfig,
|
|
193
|
+
sliceSha256: sliceShaMap[firstConfig],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
for (const config of sliceConfigs.slice(1)) {
|
|
197
|
+
// Cheap shape + value check against the already-verified attestation.
|
|
198
|
+
assertBoundHashes(result.attestation, {
|
|
199
|
+
manifestSha256,
|
|
200
|
+
sliceConfig: config,
|
|
201
|
+
sliceSha256: sliceShaMap[config],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
// v1 / fat-zip path (today).
|
|
206
|
+
result = verifyAttestation({
|
|
207
|
+
attBytes,
|
|
208
|
+
sigEnvelope,
|
|
209
|
+
trustAnchors: trust.keys,
|
|
210
|
+
expected: { name, version, profileHash, contentSha256, manifestSha256 },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
log.info(
|
|
214
|
+
`signed by ${result.keyId}` +
|
|
215
|
+
(result.identityHint ? ` (${result.identityHint})` : '') +
|
|
216
|
+
` [${result.kind}]`,
|
|
217
|
+
);
|
|
218
|
+
return { keyId: result.keyId, identityHint: result.identityHint, alg: result.kind };
|
|
219
|
+
}
|
|
220
|
+
|
|
107
221
|
async function findV2Index(dep, packageSources, authOptions = {}) {
|
|
108
222
|
const { name, version } = dep;
|
|
109
223
|
const { awsProfile, token } = authOptions;
|
|
@@ -126,9 +240,106 @@ async function findV2Index(dep, packageSources, authOptions = {}) {
|
|
|
126
240
|
return { found: false, indexFoundInSource: null, versionsIndex: null, availableProfiles: [] };
|
|
127
241
|
}
|
|
128
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Per-config slice download. Called by tryV2Exact when the exact-match
|
|
245
|
+
* manifest carries an `artefactConfigs` block. Resolves the consumer's
|
|
246
|
+
* requested configs against the publisher's available configs, downloads
|
|
247
|
+
* each slice into a per-package staging path, and SHA-verifies on the fly.
|
|
248
|
+
*
|
|
249
|
+
* Empty intersection → fail-fast (CLAUDE.md §2.8 silent fallback is a
|
|
250
|
+
* bug). Partial intersection → warn about missing slices, proceed with
|
|
251
|
+
* what's available.
|
|
252
|
+
*
|
|
253
|
+
* `requestConfigs === null` is the "no consumer override" signal — fetch
|
|
254
|
+
* every slice the publisher published. Equivalent to today's behaviour
|
|
255
|
+
* for fat-zip publishes (consumer pulls everything).
|
|
256
|
+
*
|
|
257
|
+
* @param {object} args
|
|
258
|
+
* @returns {Promise<{
|
|
259
|
+
* ok: boolean,
|
|
260
|
+
* reason?: string,
|
|
261
|
+
* slices?: Array<{ config: string, zipPath: string, sha256: string }>,
|
|
262
|
+
* }>}
|
|
263
|
+
*/
|
|
264
|
+
async function downloadV2Slices({
|
|
265
|
+
provider, source, name, version, profileHash,
|
|
266
|
+
exactMeta, requestConfigs, destDir, timeoutMs, authOptions = {},
|
|
267
|
+
}) {
|
|
268
|
+
const available = Object.keys(exactMeta.artefactConfigs || {});
|
|
269
|
+
if (available.length === 0) {
|
|
270
|
+
return { ok: false, reason: 'manifest declared artefactConfigs but the map was empty' };
|
|
271
|
+
}
|
|
272
|
+
// Default: when consumer didn't override, fetch every config the
|
|
273
|
+
// publisher has — equivalent to today's "download everything" behaviour
|
|
274
|
+
// for pre-feature consumers.
|
|
275
|
+
const requested = (Array.isArray(requestConfigs) && requestConfigs.length > 0)
|
|
276
|
+
? requestConfigs
|
|
277
|
+
: available;
|
|
278
|
+
const downloadConfigs = requested.filter((c) => available.includes(c));
|
|
279
|
+
if (downloadConfigs.length === 0) {
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
reason:
|
|
283
|
+
`none of the requested configs [${requested.join(', ')}] are published — ` +
|
|
284
|
+
`available: [${available.join(', ')}]`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (downloadConfigs.length < requested.length) {
|
|
288
|
+
const missing = requested.filter((c) => !available.includes(c));
|
|
289
|
+
log.warn(
|
|
290
|
+
`requested config(s) [${missing.join(', ')}] not published for ${name}@${version} — ` +
|
|
291
|
+
`skipping; available: [${available.join(', ')}]`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { awsProfile, token } = authOptions;
|
|
296
|
+
const slices = [];
|
|
297
|
+
for (const config of downloadConfigs.slice().sort()) {
|
|
298
|
+
const slicePath = path.join(destDir, `${name}-${config}.zip`);
|
|
299
|
+
const ok = await provider.v2DownloadZip(
|
|
300
|
+
{ source, name, version, profileHash, sliceConfig: config, awsProfile, token },
|
|
301
|
+
slicePath,
|
|
302
|
+
timeoutMs,
|
|
303
|
+
);
|
|
304
|
+
if (!ok) {
|
|
305
|
+
return { ok: false, reason: `failed to download slice "${config}" from ${source}` };
|
|
306
|
+
}
|
|
307
|
+
const localSha = sha256Of(slicePath);
|
|
308
|
+
const expectedSha = exactMeta.artefactConfigs[config]?.sha256;
|
|
309
|
+
if (typeof expectedSha !== 'string' || localSha !== expectedSha) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
reason:
|
|
313
|
+
`SHA256 mismatch on slice "${config}":\n` +
|
|
314
|
+
` expected: ${expectedSha}\n` +
|
|
315
|
+
` actual: ${localSha}`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
slices.push({ config, zipPath: slicePath, sha256: localSha });
|
|
319
|
+
log.info(` ${config.padEnd(15)} ${formatSizeShort(slicePath).padStart(10)} sha=${localSha.slice(0, 16)}...`);
|
|
320
|
+
}
|
|
321
|
+
return { ok: true, slices };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Best-effort byte-size formatter for slice download log lines. Returns
|
|
326
|
+
* a short rendering ("3.7 MB") or a fallback when stat fails.
|
|
327
|
+
*/
|
|
328
|
+
function formatSizeShort(filePath) {
|
|
329
|
+
try {
|
|
330
|
+
const n = fs.statSync(filePath).size;
|
|
331
|
+
if (n < 1024) return `${n} B`;
|
|
332
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
333
|
+
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
334
|
+
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
335
|
+
} catch {
|
|
336
|
+
return '?';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
129
340
|
async function tryV2Exact(dep, packageSources, destZipPath, timeoutMs, authOptions = {}) {
|
|
130
341
|
const { name, version, profileHash } = dep;
|
|
131
|
-
const { awsProfile, token } = authOptions;
|
|
342
|
+
const { awsProfile, token, requestConfigs } = authOptions;
|
|
132
343
|
|
|
133
344
|
let indexFoundInSource = null;
|
|
134
345
|
let versionsIndex = null;
|
|
@@ -143,11 +354,50 @@ async function tryV2Exact(dep, packageSources, destZipPath, timeoutMs, authOptio
|
|
|
143
354
|
log.info(`Trying v2: ${metaUrl}`);
|
|
144
355
|
const exactMeta = await provider.v2GetMeta({ source, name, version, profileHash, awsProfile, token });
|
|
145
356
|
if (exactMeta) {
|
|
357
|
+
// ── Per-config slice publish ───────────────────────────────────────
|
|
358
|
+
if (exactMeta.artefactConfigs && Object.keys(exactMeta.artefactConfigs).length > 0) {
|
|
359
|
+
log.info(` per-config publish: configs=${Object.keys(exactMeta.artefactConfigs).join(', ')}`);
|
|
360
|
+
const sliceResult = await downloadV2Slices({
|
|
361
|
+
provider, source, name, version, profileHash,
|
|
362
|
+
exactMeta, requestConfigs,
|
|
363
|
+
destDir: path.dirname(destZipPath),
|
|
364
|
+
timeoutMs,
|
|
365
|
+
authOptions: { awsProfile, token },
|
|
366
|
+
});
|
|
367
|
+
if (sliceResult.ok) {
|
|
368
|
+
return {
|
|
369
|
+
found: true,
|
|
370
|
+
result: {
|
|
371
|
+
kind: 'slice',
|
|
372
|
+
slices: sliceResult.slices,
|
|
373
|
+
configs: sliceResult.slices.map((s) => s.config).sort(),
|
|
374
|
+
perConfigSha256: Object.fromEntries(sliceResult.slices.map((s) => [s.config, s.sha256])),
|
|
375
|
+
gitSha: exactMeta.gitSha ?? null,
|
|
376
|
+
gitRepo: exactMeta.gitRepo ?? null,
|
|
377
|
+
profileHash,
|
|
378
|
+
source,
|
|
379
|
+
},
|
|
380
|
+
indexFoundInSource: source,
|
|
381
|
+
availableProfiles: [],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
// Slice download failed — bubble the reason for caller diagnostics.
|
|
385
|
+
log.error(`per-config download failed for ${name}@${version}: ${sliceResult.reason}`);
|
|
386
|
+
return {
|
|
387
|
+
found: false,
|
|
388
|
+
sliceFailureReason: sliceResult.reason,
|
|
389
|
+
indexFoundInSource: source,
|
|
390
|
+
availableProfiles: [],
|
|
391
|
+
versionsIndex: null,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// ── Fat-zip path (today) ───────────────────────────────────────────
|
|
146
395
|
const ok = await provider.v2DownloadZip({ source, name, version, profileHash, awsProfile, token }, destZipPath, timeoutMs);
|
|
147
396
|
if (ok) {
|
|
148
397
|
return {
|
|
149
398
|
found: true,
|
|
150
399
|
result: {
|
|
400
|
+
kind: 'fat-zip',
|
|
151
401
|
contentSha256: exactMeta.contentSha256 ?? null,
|
|
152
402
|
gitSha: exactMeta.gitSha ?? null,
|
|
153
403
|
gitRepo: exactMeta.gitRepo ?? null,
|
|
@@ -345,6 +595,24 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
345
595
|
// so the caller can print a single aggregated summary at the end of an
|
|
346
596
|
// install (see src/commands/install.js). Mutated in place.
|
|
347
597
|
uploadStats,
|
|
598
|
+
// S1: when true, every download must verify against the registry's
|
|
599
|
+
// trust anchor regardless of what `mode.json` says. Set by
|
|
600
|
+
// `wyvrnpm install --require-signatures`.
|
|
601
|
+
requireSignatures = false,
|
|
602
|
+
// S1 upload-built: when set, the consumer-rebuilt artefact is signed
|
|
603
|
+
// with this context before upload. Resolved once at the install entry
|
|
604
|
+
// point (src/commands/install.js) from the consumer's defaultSigning
|
|
605
|
+
// / per-source signing config.
|
|
606
|
+
uploadSigningContext = null,
|
|
607
|
+
// Per-config slice selection (PLAN-PER-CONFIG). null means "no
|
|
608
|
+
// override; fetch every slice the publisher published" — equivalent
|
|
609
|
+
// to today's behaviour for fat-zip publishes.
|
|
610
|
+
requestConfigs = null,
|
|
611
|
+
// Per-dep override of `requestConfigs`. Map of `{ depName: [...] }`.
|
|
612
|
+
// When a dep is named in this map, those configs apply to it
|
|
613
|
+
// exclusively; otherwise the global `requestConfigs` takes over.
|
|
614
|
+
// Resolved by install.js from wyvrn.local.json: depRequestConfigs.
|
|
615
|
+
depRequestConfigs = null,
|
|
348
616
|
} = options;
|
|
349
617
|
|
|
350
618
|
if (!fs.existsSync(razerDir)) {
|
|
@@ -367,6 +635,16 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
367
635
|
// configured sources.
|
|
368
636
|
const depSources = pinnedSource ? [pinnedSource.url] : packageSources;
|
|
369
637
|
|
|
638
|
+
// Per-dep override of the global slice selection. Resolved once
|
|
639
|
+
// here so the cache check, slice download, and post-extract meta
|
|
640
|
+
// write all see the same effective config list. The override comes
|
|
641
|
+
// from wyvrn.local.json:depRequestConfigs[<name>] when set,
|
|
642
|
+
// otherwise the dep inherits the global `requestConfigs`.
|
|
643
|
+
const depEffectiveRequestConfigs =
|
|
644
|
+
(depRequestConfigs && Object.prototype.hasOwnProperty.call(depRequestConfigs, name))
|
|
645
|
+
? depRequestConfigs[name]
|
|
646
|
+
: requestConfigs;
|
|
647
|
+
|
|
370
648
|
const extractDir = path.join(razerDir, name);
|
|
371
649
|
const versionFile = path.join(extractDir, '.wyvrn-version');
|
|
372
650
|
const metaFile = path.join(extractDir, '.wyvrn-meta.json');
|
|
@@ -396,12 +674,36 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
396
674
|
const sameVersion = installedVersion === version;
|
|
397
675
|
const sameProfile = !profileHash || installedMeta?.profileHash === profileHash;
|
|
398
676
|
|
|
399
|
-
|
|
677
|
+
// Slice publishes additionally key the cache on the consumer's
|
|
678
|
+
// configs request — going from [Release] to [Release, Debug] must
|
|
679
|
+
// re-fetch even when version + profileHash match, otherwise the
|
|
680
|
+
// Debug binaries are missing and find_package(Debug) fails.
|
|
681
|
+
const sameConfigs = (() => {
|
|
682
|
+
// Pre-feature dep (no `configs` in installedMeta) → version + profileHash is enough.
|
|
683
|
+
if (!installedMeta?.configs) return true;
|
|
684
|
+
// Slice dep: compare requestConfigs (or "everything" if null) vs installed.
|
|
685
|
+
// Uses the per-dep effective list so a `depRequestConfigs.grpc`
|
|
686
|
+
// override correctly invalidates the cache when the consumer
|
|
687
|
+
// adds or removes configs from grpc's slice selection.
|
|
688
|
+
if (!depEffectiveRequestConfigs) return true;
|
|
689
|
+
const requestedSet = new Set(depEffectiveRequestConfigs);
|
|
690
|
+
const installedSet = new Set(installedMeta.configs);
|
|
691
|
+
if (requestedSet.size !== installedSet.size) return false;
|
|
692
|
+
for (const c of requestedSet) if (!installedSet.has(c)) return false;
|
|
693
|
+
return true;
|
|
694
|
+
})();
|
|
695
|
+
|
|
696
|
+
if (sameVersion && sameProfile && sameConfigs) {
|
|
400
697
|
log.info(`Already present: ${name}@${version}${profileHash ? ` [${installedMeta?.profileHash}]` : ''} — skipping`);
|
|
401
698
|
lockEntries.set(name, {
|
|
402
699
|
version,
|
|
403
700
|
profileHash: installedMeta?.profileHash ?? null,
|
|
404
|
-
|
|
701
|
+
...(installedMeta?.configs
|
|
702
|
+
? {
|
|
703
|
+
configs: installedMeta.configs,
|
|
704
|
+
perConfigSha256: installedMeta.perConfigSha256 ?? {},
|
|
705
|
+
}
|
|
706
|
+
: { contentSha256: installedMeta?.contentSha256 ?? null }),
|
|
405
707
|
gitSha: installedMeta?.gitSha ?? null,
|
|
406
708
|
resolvedFrom: installedMeta?.resolvedFrom ?? 'cached',
|
|
407
709
|
...(options ? { options } : {}),
|
|
@@ -411,8 +713,10 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
411
713
|
}
|
|
412
714
|
if (!sameVersion) {
|
|
413
715
|
log.info(`Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
|
|
414
|
-
} else {
|
|
716
|
+
} else if (!sameProfile) {
|
|
415
717
|
log.info(`Profile changed for ${name}@${version}, re-downloading`);
|
|
718
|
+
} else {
|
|
719
|
+
log.info(`Requested configs changed for ${name}@${version} — re-downloading`);
|
|
416
720
|
}
|
|
417
721
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
418
722
|
}
|
|
@@ -441,12 +745,26 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
441
745
|
depSources,
|
|
442
746
|
destZipPath,
|
|
443
747
|
timeoutMs,
|
|
444
|
-
{ awsProfile, token },
|
|
748
|
+
{ awsProfile, token, requestConfigs: depEffectiveRequestConfigs },
|
|
445
749
|
);
|
|
446
750
|
|
|
447
751
|
if (v2.found) {
|
|
448
752
|
downloadResult = v2.result;
|
|
449
|
-
resolvedFrom = 'v2';
|
|
753
|
+
resolvedFrom = (v2.result?.kind === 'slice') ? 'v2-slice' : 'v2';
|
|
754
|
+
} else if (v2.sliceFailureReason) {
|
|
755
|
+
// We DID find an exact match for this profileHash, but the per-config
|
|
756
|
+
// slice download failed — empty consumer-publisher intersection,
|
|
757
|
+
// SHA mismatch, network error. Fail-fast: don't fall back to compat
|
|
758
|
+
// / source-build / v1, which would silently substitute a different
|
|
759
|
+
// build (CLAUDE.md §2.8 — silent fallback is a bug).
|
|
760
|
+
log.error(`per-config slice failed for ${name}@${version}: ${v2.sliceFailureReason}`);
|
|
761
|
+
lockEntries.set(name, {
|
|
762
|
+
version,
|
|
763
|
+
profileHash,
|
|
764
|
+
resolvedFrom: 'slice-failed',
|
|
765
|
+
sliceFailureReason: v2.sliceFailureReason,
|
|
766
|
+
});
|
|
767
|
+
continue;
|
|
450
768
|
} else if (v2.indexFoundInSource) {
|
|
451
769
|
// v2 knows this package but no exact profile match (or --build=always
|
|
452
770
|
// forced the path here). Do NOT fall back to v1 — that would silently
|
|
@@ -559,8 +877,14 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
559
877
|
}
|
|
560
878
|
}
|
|
561
879
|
|
|
562
|
-
//
|
|
563
|
-
|
|
880
|
+
// Slice publishes are recognised by `kind: 'slice'` on the download
|
|
881
|
+
// result. SHA-verify already happened in downloadV2Slices (per slice,
|
|
882
|
+
// before this point); the rest of the pipeline just needs to know
|
|
883
|
+
// whether we have one zip or N to extract.
|
|
884
|
+
const sliceMode = downloadResult?.kind === 'slice';
|
|
885
|
+
|
|
886
|
+
// ── SHA256 integrity check (fat-zip only; slice already verified) ──────
|
|
887
|
+
if (!sliceMode && downloadResult?.contentSha256) {
|
|
564
888
|
const actual = sha256Of(destZipPath);
|
|
565
889
|
if (actual !== downloadResult.contentSha256) {
|
|
566
890
|
log.error(
|
|
@@ -575,10 +899,55 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
575
899
|
log.info(`SHA256 verified: ${actual.slice(0, 16)}...`);
|
|
576
900
|
}
|
|
577
901
|
|
|
902
|
+
// ── S1 signature verification ──────────────────────────────────────────
|
|
903
|
+
// Only meaningful for v2 downloads with a known source (skip cached,
|
|
904
|
+
// link, and v1-fallback paths). Aborts on bad sig under any non-off
|
|
905
|
+
// mode; warn-mode missing falls through with a logged warning. Slice
|
|
906
|
+
// mode passes the per-config sha map; fat-zip mode passes contentSha256.
|
|
907
|
+
let signerInfo = null;
|
|
908
|
+
const haveBoundHashes = sliceMode
|
|
909
|
+
? Boolean(downloadResult?.perConfigSha256 && Object.keys(downloadResult.perConfigSha256).length > 0)
|
|
910
|
+
: Boolean(downloadResult?.contentSha256);
|
|
911
|
+
if (downloadResult?.source && downloadResult?.profileHash && haveBoundHashes) {
|
|
912
|
+
try {
|
|
913
|
+
signerInfo = await verifyDownloadIfRequired({
|
|
914
|
+
source: downloadResult.source,
|
|
915
|
+
name, version,
|
|
916
|
+
profileHash: downloadResult.profileHash,
|
|
917
|
+
...(sliceMode
|
|
918
|
+
? { sliceShaMap: downloadResult.perConfigSha256 }
|
|
919
|
+
: { contentSha256: downloadResult.contentSha256 }),
|
|
920
|
+
authOptions: { awsProfile, token },
|
|
921
|
+
requireSignatures,
|
|
922
|
+
});
|
|
923
|
+
} catch (err) {
|
|
924
|
+
log.error(`signature verification failed for ${name}@${version}: ${err.message}`);
|
|
925
|
+
if (sliceMode) {
|
|
926
|
+
for (const slice of downloadResult.slices ?? []) {
|
|
927
|
+
try { fs.unlinkSync(slice.zipPath); } catch { /* ignore */ }
|
|
928
|
+
}
|
|
929
|
+
} else {
|
|
930
|
+
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
|
|
931
|
+
}
|
|
932
|
+
lockEntries.set(name, { version, profileHash: downloadResult.profileHash, resolvedFrom: 'sig-verify-failed' });
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
578
937
|
// ── Extract ─────────────────────────────────────────────────────────────
|
|
579
938
|
try {
|
|
580
939
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
581
|
-
|
|
940
|
+
if (sliceMode) {
|
|
941
|
+
// Multiple slices into one tree — shared files re-extract identically
|
|
942
|
+
// (multi-config-merge invariant locked in by the slice byte-identity
|
|
943
|
+
// audit on the publish side). Per-config files land in their distinct
|
|
944
|
+
// lib/<Config>/ etc. paths, no overwrites.
|
|
945
|
+
for (const slice of downloadResult.slices) {
|
|
946
|
+
await extractZip(slice.zipPath, extractDir);
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
await extractZip(destZipPath, extractDir);
|
|
950
|
+
}
|
|
582
951
|
|
|
583
952
|
// Write .wyvrn-version (v1 compat)
|
|
584
953
|
fs.writeFileSync(versionFile, version, 'utf8');
|
|
@@ -588,16 +957,25 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
588
957
|
version,
|
|
589
958
|
resolvedFrom,
|
|
590
959
|
profileHash: downloadResult?.profileHash ?? null,
|
|
591
|
-
|
|
960
|
+
...(sliceMode
|
|
961
|
+
? {
|
|
962
|
+
configs: downloadResult.configs,
|
|
963
|
+
perConfigSha256: downloadResult.perConfigSha256,
|
|
964
|
+
}
|
|
965
|
+
: { contentSha256: downloadResult?.contentSha256 ?? null }),
|
|
592
966
|
gitSha: downloadResult?.gitSha ?? null,
|
|
593
967
|
gitRepo: downloadResult?.gitRepo ?? null,
|
|
594
968
|
installedAt: new Date().toISOString(),
|
|
969
|
+
// S1: signer identity per dep when verification succeeded
|
|
970
|
+
// (null otherwise — surfaces in `wyvrnpm show` and audit tools).
|
|
971
|
+
signer: signerInfo,
|
|
595
972
|
};
|
|
596
973
|
fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
|
597
974
|
|
|
598
975
|
log.info(
|
|
599
976
|
`Extracted: ${name}@${version}` +
|
|
600
977
|
(downloadResult?.profileHash ? ` [${downloadResult.profileHash}]` : '') +
|
|
978
|
+
(sliceMode ? ` configs=[${downloadResult.configs.join(', ')}]` : '') +
|
|
601
979
|
` (${resolvedFrom}) → ${extractDir}`,
|
|
602
980
|
);
|
|
603
981
|
|
|
@@ -612,7 +990,12 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
612
990
|
version,
|
|
613
991
|
resolvedFrom,
|
|
614
992
|
profileHash: downloadResult?.profileHash ?? null,
|
|
615
|
-
|
|
993
|
+
...(sliceMode
|
|
994
|
+
? {
|
|
995
|
+
configs: downloadResult.configs,
|
|
996
|
+
perConfigSha256: downloadResult.perConfigSha256,
|
|
997
|
+
}
|
|
998
|
+
: { contentSha256: downloadResult?.contentSha256 ?? null }),
|
|
616
999
|
gitSha: downloadResult?.gitSha ?? null,
|
|
617
1000
|
...(options ? { options } : {}),
|
|
618
1001
|
// S4: record the pinned source name on the lock entry so a
|
|
@@ -639,6 +1022,12 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
639
1022
|
options,
|
|
640
1023
|
uploadSource,
|
|
641
1024
|
uploadAuth: uploadAuth ?? { awsProfile, token },
|
|
1025
|
+
// S1 — sign the consumer-rebuilt artefact identically to an
|
|
1026
|
+
// author publish. Resolved once at install entry and threaded
|
|
1027
|
+
// down here. When null, the upload is unsigned and the
|
|
1028
|
+
// destination registry's mode.json governs whether downstream
|
|
1029
|
+
// consumers warn or abort.
|
|
1030
|
+
signingContext: uploadSigningContext,
|
|
642
1031
|
});
|
|
643
1032
|
if (uploadStats) {
|
|
644
1033
|
const bucket = outcome.uploaded
|
|
@@ -657,6 +1046,14 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
657
1046
|
log.warn(`Failed to extract ${name}@${version}: ${err.message}`);
|
|
658
1047
|
lockEntries.set(name, { version, resolvedFrom: 'extract-failed' });
|
|
659
1048
|
} finally {
|
|
1049
|
+
// Clean up downloaded zip(s). Slice mode tracks N paths; fat-zip is
|
|
1050
|
+
// the single destZipPath. Both ignore unlink errors — the file may
|
|
1051
|
+
// not exist if an earlier step bailed.
|
|
1052
|
+
if (downloadResult?.kind === 'slice') {
|
|
1053
|
+
for (const slice of downloadResult.slices ?? []) {
|
|
1054
|
+
try { fs.unlinkSync(slice.zipPath); } catch { /* ignore */ }
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
660
1057
|
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
|
|
661
1058
|
}
|
|
662
1059
|
}
|