wyvrnpm 2.12.2 → 2.13.11

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.
@@ -112,6 +112,18 @@ class S3Provider extends BaseProvider {
112
112
  }
113
113
  }
114
114
 
115
+ async _getBytes(client, bucket, key, GetObjectCommand) {
116
+ try {
117
+ const resp = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
118
+ const chunks = [];
119
+ for await (const chunk of resp.Body) chunks.push(chunk);
120
+ return Buffer.concat(chunks);
121
+ } catch (err) {
122
+ if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) return null;
123
+ throw err;
124
+ }
125
+ }
126
+
115
127
  async _downloadToFile(client, bucket, key, destPath, GetObjectCommand) {
116
128
  try {
117
129
  const resp = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
@@ -180,8 +192,9 @@ class S3Provider extends BaseProvider {
180
192
  async v2Publish(files, options) {
181
193
  const {
182
194
  source, name, version, profileHash, buildSettings,
183
- contentSha256, gitSha, gitRepo, publishedLock, awsProfile,
184
- updateLatest = true, uploadedBy,
195
+ contentSha256, gitSha, gitRepo, awsProfile,
196
+ updateLatest = true,
197
+ artefactConfigs,
185
198
  } = options;
186
199
  const { PutObjectCommand, GetObjectCommand } = this._loadSdk();
187
200
  const client = this._makeClient(awsProfile);
@@ -190,32 +203,54 @@ class S3Provider extends BaseProvider {
190
203
  const v2root = this._key(prefix, 'v2', name);
191
204
  const buildRoot = `${v2root}/${version}/${profileHash}`;
192
205
 
193
- // 1) Upload zip
194
- await this._putFile(client, bucket, `${buildRoot}/wyvrn.zip`, files.zip, 'application/zip', PutObjectCommand);
195
-
196
- // 2) Upload enriched wyvrn.json.
197
- // buildSettings is stored for debugging purposes only.
198
- // Dependencies are published verbatim from wyvrn.json ranges stay as
199
- // ranges so consumers can intersect them with their own. The author's
200
- // lockfile snapshot is preserved in `publishedLock` for audit.
201
- const rawManifest = JSON.parse(fs.readFileSync(files.manifest, 'utf8'));
202
- const v2Meta = {
203
- ...rawManifest,
204
- schemaVersion: 2,
205
- profileHash,
206
- buildSettings,
207
- contentSha256,
208
- gitSha: gitSha ?? null,
209
- gitRepo: gitRepo ?? null,
210
- publishedAt: new Date().toISOString(),
211
- ...(publishedLock && Object.keys(publishedLock).length > 0 ? { publishedLock } : {}),
212
- ...(uploadedBy ? { uploadedBy } : {}),
213
- };
214
- await this._putBuffer(
206
+ const sliceConfigs =
207
+ (artefactConfigs && Object.keys(artefactConfigs).length > 0)
208
+ ? Object.keys(artefactConfigs).sort()
209
+ : null;
210
+
211
+ // 1) Upload zip(s) fat or per-config slices.
212
+ if (sliceConfigs) {
213
+ if (!files.artefactZips || typeof files.artefactZips !== 'object') {
214
+ throw new Error('v2Publish: artefactConfigs supplied but files.artefactZips missing');
215
+ }
216
+ for (const config of sliceConfigs) {
217
+ const slicePath = files.artefactZips[config];
218
+ if (!slicePath) {
219
+ throw new Error(
220
+ `v2Publish: artefactConfigs declares "${config}" but files.artefactZips["${config}"] is missing`,
221
+ );
222
+ }
223
+ await this._putFile(
224
+ client, bucket, `${buildRoot}/wyvrn-${config}.zip`,
225
+ slicePath, 'application/zip', PutObjectCommand,
226
+ );
227
+ }
228
+ } else {
229
+ if (!files.zip) {
230
+ throw new Error('v2Publish: files.zip required for fat-zip publish');
231
+ }
232
+ await this._putFile(client, bucket, `${buildRoot}/wyvrn.zip`, files.zip, 'application/zip', PutObjectCommand);
233
+ }
234
+
235
+ // 2) Upload wyvrn.json VERBATIM from `files.manifest`. The publisher
236
+ // (commands/publish.js, upload-built.js) builds the v2Meta body
237
+ // via src/v2-meta.js so the signed manifestSha256 matches what
238
+ // consumers compute from v2GetMeta. Providers MUST NOT mutate.
239
+ await this._putFile(
215
240
  client, bucket, `${buildRoot}/wyvrn.json`,
216
- Buffer.from(JSON.stringify(v2Meta, null, 2)), 'application/json', PutObjectCommand,
241
+ files.manifest, 'application/json', PutObjectCommand,
217
242
  );
218
243
 
244
+ // 2.5) signing files (S1) — written between manifest and versions.json
245
+ // so a partial-failure crash never leaves a sig-less entry visible
246
+ // in versions.json. Both files optional; mode.json governs.
247
+ if (files.attPath) {
248
+ await this._putFile(client, bucket, `${buildRoot}/wyvrn.att.json`, files.attPath, 'application/json', PutObjectCommand);
249
+ }
250
+ if (files.sigPath) {
251
+ await this._putFile(client, bucket, `${buildRoot}/wyvrn.sig`, files.sigPath, 'application/json', PutObjectCommand);
252
+ }
253
+
219
254
  // 3) source.json
220
255
  if (gitSha || gitRepo) {
221
256
  await this._putBuffer(
@@ -225,16 +260,21 @@ class S3Provider extends BaseProvider {
225
260
  );
226
261
  }
227
262
 
228
- // 4) Read-modify-write versions.json
263
+ // 4) Read-modify-write versions.json. Per-profile entries hold
264
+ // either `contentSha256` (fat zip) or `configs` (slice publish).
229
265
  let versionsIdx = await this._getJson(client, bucket, `${v2root}/versions.json`, GetObjectCommand)
230
266
  ?? { name, latest: version, versions: {} };
231
267
  if (!versionsIdx.versions) versionsIdx.versions = {};
232
268
  if (!versionsIdx.versions[version]) versionsIdx.versions[version] = { profiles: {} };
233
- versionsIdx.versions[version].profiles[profileHash] = {
234
- contentSha256,
269
+
270
+ const profileEntry = {
235
271
  publishedAt: new Date().toISOString(),
236
272
  buildSettings,
237
273
  };
274
+ if (sliceConfigs) profileEntry.configs = sliceConfigs;
275
+ else profileEntry.contentSha256 = contentSha256;
276
+ versionsIdx.versions[version].profiles[profileHash] = profileEntry;
277
+
238
278
  if (gitSha || gitRepo) {
239
279
  versionsIdx.versions[version].source = { gitRepo: gitRepo ?? null, gitSha: gitSha ?? null };
240
280
  }
@@ -246,9 +286,12 @@ class S3Provider extends BaseProvider {
246
286
 
247
287
  // 5) latest.json — skipped for consumer uploads (see FileProvider note).
248
288
  if (updateLatest) {
289
+ const latestEntry = { version, profileHash };
290
+ if (sliceConfigs) latestEntry.configs = sliceConfigs;
291
+ else latestEntry.contentSha256 = contentSha256;
249
292
  await this._putBuffer(
250
293
  client, bucket, `${v2root}/latest.json`,
251
- Buffer.from(JSON.stringify({ version, profileHash, contentSha256 })),
294
+ Buffer.from(JSON.stringify(latestEntry)),
252
295
  'application/json', PutObjectCommand,
253
296
  );
254
297
  }
@@ -270,13 +313,42 @@ class S3Provider extends BaseProvider {
270
313
  return this._getJson(client, bucket, key, GetObjectCommand);
271
314
  }
272
315
 
273
- async v2DownloadZip({ source, name, version, profileHash, awsProfile }, destPath) {
316
+ async v2DownloadZip({ source, name, version, profileHash, sliceConfig, awsProfile }, destPath) {
274
317
  const { GetObjectCommand } = this._loadSdk();
275
318
  const client = this._makeClient(awsProfile);
276
319
  const { bucket, prefix } = this._parseSource(source);
277
- const key = this._key(prefix, 'v2', name, version, profileHash, 'wyvrn.zip');
320
+ const filename = sliceConfig ? `wyvrn-${sliceConfig}.zip` : 'wyvrn.zip';
321
+ const key = this._key(prefix, 'v2', name, version, profileHash, filename);
278
322
  return this._downloadToFile(client, bucket, key, destPath, GetObjectCommand);
279
323
  }
324
+
325
+ // ── S1 signing surface ────────────────────────────────────────────────────
326
+
327
+ async v2GetAttestation({ source, name, version, profileHash, awsProfile }) {
328
+ const { GetObjectCommand } = this._loadSdk();
329
+ const client = this._makeClient(awsProfile);
330
+ const { bucket, prefix } = this._parseSource(source);
331
+ const key = this._key(prefix, 'v2', name, version, profileHash, 'wyvrn.att.json');
332
+ return this._getBytes(client, bucket, key, GetObjectCommand);
333
+ }
334
+
335
+ async v2GetSignature({ source, name, version, profileHash, awsProfile }) {
336
+ const { GetObjectCommand } = this._loadSdk();
337
+ const client = this._makeClient(awsProfile);
338
+ const { bucket, prefix } = this._parseSource(source);
339
+ const key = this._key(prefix, 'v2', name, version, profileHash, 'wyvrn.sig');
340
+ return this._getBytes(client, bucket, key, GetObjectCommand);
341
+ }
342
+
343
+ async v2GetTrustAnchor({ source, awsProfile }) {
344
+ const { GetObjectCommand } = this._loadSdk();
345
+ const client = this._makeClient(awsProfile);
346
+ const { bucket, prefix } = this._parseSource(source);
347
+ const keys = await this._getJson(client, bucket, this._key(prefix, 'v2', '.trust', 'keys.json'), GetObjectCommand);
348
+ const mode = await this._getJson(client, bucket, this._key(prefix, 'v2', '.trust', 'mode.json'), GetObjectCommand);
349
+ if (keys === null && mode === null) return null;
350
+ return { keys, mode };
351
+ }
280
352
  }
281
353
 
282
354
  module.exports = S3Provider;
@@ -0,0 +1,414 @@
1
+ 'use strict';
2
+
3
+ // Per-config artefact slicing — phase 1 (inventory-driven).
4
+ //
5
+ // Author opts a recipe in via `build.publishPerConfig: true`; the
6
+ // publish command then calls into this module with the unified install
7
+ // tree path and the recipe's declared `build.configs`. Output is one
8
+ // zip per declared config under
9
+ // `{source}/v2/{name}/{version}/{profileHash}/wyvrn-<Config>.zip`,
10
+ // each carrying:
11
+ //
12
+ // - all "shared" install-tree files (headers, license,
13
+ // `*Config.cmake`, base `*Targets.cmake`)
14
+ // - the per-config files for THIS config only
15
+ // (whichever paths CMake's `*Targets-<config>.cmake` claims via
16
+ // IMPORTED_LOCATION_<CONFIG>, IMPORTED_IMPLIB_<CONFIG>, or
17
+ // IMPORTED_SONAME_<CONFIG>)
18
+ //
19
+ // Re-extracting multiple slices into one `wyvrn_internal/<name>/`
20
+ // directory therefore reconstructs the full multi-config install
21
+ // tree — that's the invariant a VS-generator consumer relies on when
22
+ // it asks for both Release and Debug slices.
23
+ //
24
+ // ─── Classification: WHY inventory and not path-segments ──────────────────
25
+ // CMake supports two conventions for distinguishing per-config binaries:
26
+ // 1. MSVC multi-config: `lib/<Config>/foo.lib` (per-config subdirs)
27
+ // 2. Postfix convention: `lib/foo.lib` (Release) vs `lib/food.lib`
28
+ // (Debug, via CMAKE_DEBUG_POSTFIX="d") — all in flat lib/
29
+ // gRPC uses the postfix convention. A path-segment heuristic would
30
+ // dump every binary into the shared bucket and produce four byte-
31
+ // identical slice zips, defeating the entire feature. The only signal
32
+ // that captures both conventions correctly is CMake's own export
33
+ // inventory: `*Targets-<config>.cmake` lists each file the consumer
34
+ // should link for that config.
35
+ //
36
+ // IMPORTED_CONFIGURATIONS regeneration is intentionally a no-op in
37
+ // phase 1. Modern CMake (3.10+) emits the base `*Targets.cmake` with
38
+ // a `file(GLOB ... *Targets-*.cmake)` pattern, so the runtime
39
+ // `IMPORTED_CONFIGURATIONS` set is built up from whichever per-config
40
+ // files happen to be present at consumer extract time.
41
+
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+ const crypto = require('crypto');
45
+
46
+ const { StreamingZipWriter } = require('../zip-stream');
47
+
48
+ // Phase 1 covers only the four canonical CMake configurations.
49
+ const VALID_CMAKE_CONFIGS = ['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel'];
50
+ const VALID_CONFIG_LOWER = new Map(VALID_CMAKE_CONFIGS.map((c) => [c.toLowerCase(), c]));
51
+
52
+ /**
53
+ * Match a string to a canonical CMake config name (case-insensitive).
54
+ * Returns the canonical name (`'Release'`) or `null`.
55
+ *
56
+ * @param {unknown} s
57
+ * @returns {string|null}
58
+ */
59
+ function matchCanonicalConfig(s) {
60
+ if (typeof s !== 'string') return null;
61
+ return VALID_CONFIG_LOWER.get(s.toLowerCase()) ?? null;
62
+ }
63
+
64
+ // CMake's install(EXPORT) generator emits per-config target files
65
+ // matching this pattern. Both PascalCase ('XTargets-release.cmake')
66
+ // and kebab-case ('x-targets-release.cmake') variants appear in
67
+ // real-world packages — gRPC's bundled tree uses both depending on
68
+ // the sub-package's CMake style (absl: PascalCase, c-ares + protobuf
69
+ // + utf8_range: kebab-case). The match is case-insensitive on the
70
+ // "Targets" word AND on the config tag.
71
+ const EXPORT_FILE_RE = /(?:Targets|targets)-([A-Za-z0-9_]+)\.cmake$/i;
72
+
73
+ /**
74
+ * Build the regex that captures every IMPORTED_*_<CONFIG> path in a
75
+ * `*Targets-<config>.cmake` body. Three property variants name on-
76
+ * disk artefacts: IMPORTED_LOCATION_<CONFIG> (every static lib +
77
+ * executable), IMPORTED_IMPLIB_<CONFIG> (Windows DLL import libs),
78
+ * IMPORTED_SONAME_<CONFIG> (Linux .so versioning). Paths are always
79
+ * rooted at the install dir via the literal `${_IMPORT_PREFIX}`
80
+ * variable; CMake guarantees this in install(EXPORT) output.
81
+ *
82
+ * @param {string} canonicalConfig
83
+ * @returns {RegExp}
84
+ */
85
+ function makeImportedPropertyRegex(canonicalConfig) {
86
+ const upper = canonicalConfig.toUpperCase();
87
+ return new RegExp(
88
+ `IMPORTED_(?:LOCATION|IMPLIB|SONAME)_${upper}\\s+"\\$\\{_IMPORT_PREFIX\\}/([^"]+)"`,
89
+ 'g',
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Parse one *Targets-<config>.cmake file's contents and return the
95
+ * relPaths it declares belong to that config. Exported for unit tests
96
+ * that lock down the regex against representative real-world files.
97
+ *
98
+ * @param {string} text Contents of the export file.
99
+ * @param {string} canonicalConfig e.g. 'Release'.
100
+ * @returns {Set<string>}
101
+ */
102
+ function parseExportFile(text, canonicalConfig) {
103
+ const re = makeImportedPropertyRegex(canonicalConfig);
104
+ const out = new Set();
105
+ let m;
106
+ while ((m = re.exec(text)) !== null) {
107
+ // CMake export files always use forward-slash; preserve as-is.
108
+ out.add(m[1]);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * Walk `installDir`, find every `*Targets-<config>.cmake`, parse the
115
+ * IMPORTED_*_<CONFIG> entries inside, and produce the per-config file
116
+ * inventory.
117
+ *
118
+ * Output:
119
+ * - `perConfigFiles[<canonical>]`: Set of relPaths CMake declared
120
+ * belong to that config. Includes the export file itself.
121
+ * - `unknownTags`: Map<rawTag, relPath[]> — `*Targets-<custom>.cmake`
122
+ * files whose tag isn't a canonical CMake config (e.g. "asan"
123
+ * from a custom build_type). Audit turns this into a fail.
124
+ * - `exportFilesFound`: count of valid (canonical) export files.
125
+ * 0 → "tree has no install(EXPORT) output, can't slice".
126
+ *
127
+ * @param {string} installDir
128
+ * @returns {{
129
+ * perConfigFiles: Record<string, Set<string>>,
130
+ * unknownTags: Map<string, string[]>,
131
+ * exportFilesFound: number,
132
+ * }}
133
+ */
134
+ function buildInventory(installDir) {
135
+ /** @type {Record<string, Set<string>>} */
136
+ const perConfigFiles = Object.create(null);
137
+ for (const cfg of VALID_CMAKE_CONFIGS) perConfigFiles[cfg] = new Set();
138
+ /** @type {Map<string, string[]>} */
139
+ const unknownTags = new Map();
140
+ /** @type {Array<{ rel: string, full: string, canonical: string }>} */
141
+ const exportFiles = [];
142
+
143
+ function walk(current) {
144
+ for (const entry of fs.readdirSync(current).sort()) {
145
+ const full = path.join(current, entry);
146
+ const stat = fs.statSync(full);
147
+ if (stat.isDirectory()) { walk(full); continue; }
148
+ const rel = path.relative(installDir, full).replace(/\\/g, '/');
149
+ const m = path.basename(rel).match(EXPORT_FILE_RE);
150
+ if (!m) continue;
151
+ const tag = m[1];
152
+ const canonical = matchCanonicalConfig(tag);
153
+ if (canonical === null) {
154
+ if (!unknownTags.has(tag)) unknownTags.set(tag, []);
155
+ unknownTags.get(tag).push(rel);
156
+ continue;
157
+ }
158
+ exportFiles.push({ rel, full, canonical });
159
+ }
160
+ }
161
+ walk(installDir);
162
+
163
+ for (const { rel, full, canonical } of exportFiles) {
164
+ let text;
165
+ try {
166
+ text = fs.readFileSync(full, 'utf8');
167
+ } catch (err) {
168
+ throw new Error(`slice: cannot read export file ${rel}: ${err.message}`);
169
+ }
170
+ const claimed = parseExportFile(text, canonical);
171
+ for (const p of claimed) perConfigFiles[canonical].add(p);
172
+ // The export file itself is per-config — it's the file that names
173
+ // its config's IMPORTED_LOCATION entries, so it ships with that slice.
174
+ perConfigFiles[canonical].add(rel);
175
+ }
176
+
177
+ return { perConfigFiles, unknownTags, exportFilesFound: exportFiles.length };
178
+ }
179
+
180
+ /**
181
+ * Some files (protoc.exe, gRPC plugin executables) are referenced by
182
+ * the SAME path from every config's export file — they're config-
183
+ * neutral on disk despite being mentioned per-config. Detect via
184
+ * "claimed by ≥2 configs at the same relPath" and demote to shared.
185
+ * Mutates `perConfigFiles` in place; returns the demoted set for
186
+ * diagnostics.
187
+ *
188
+ * @param {Record<string, Set<string>>} perConfigFiles
189
+ * @returns {Set<string>}
190
+ */
191
+ function rectifyMultiConfigFiles(perConfigFiles) {
192
+ /** @type {Map<string, Set<string>>} */
193
+ const fileToConfigs = new Map();
194
+ for (const cfg of VALID_CMAKE_CONFIGS) {
195
+ for (const f of perConfigFiles[cfg]) {
196
+ if (!fileToConfigs.has(f)) fileToConfigs.set(f, new Set());
197
+ fileToConfigs.get(f).add(cfg);
198
+ }
199
+ }
200
+ const demoted = new Set();
201
+ for (const [f, configs] of fileToConfigs) {
202
+ if (configs.size > 1) {
203
+ for (const cfg of configs) perConfigFiles[cfg].delete(f);
204
+ demoted.add(f);
205
+ }
206
+ }
207
+ return demoted;
208
+ }
209
+
210
+ /**
211
+ * Walk `installDir` and produce a slice plan driven by the CMake
212
+ * export inventory. Files mentioned in `IMPORTED_LOCATION_<CONFIG>`
213
+ * (or IMPLIB / SONAME) entries land in that config's bucket; files
214
+ * mentioned by every config at the same path are demoted to shared
215
+ * (config-neutral binaries); everything else is shared.
216
+ *
217
+ * @param {string} installDir
218
+ * @returns {{
219
+ * sharedFiles: Array<{ fullPath: string, relPath: string }>,
220
+ * perConfigFiles: Record<string, Array<{ fullPath: string, relPath: string }>>,
221
+ * unknownConfigs: Map<string, string[]>,
222
+ * exportFilesFound: number,
223
+ * demotedFiles: Set<string>,
224
+ * }}
225
+ */
226
+ function planSlice(installDir) {
227
+ const inventory = buildInventory(installDir);
228
+ const demotedFiles = rectifyMultiConfigFiles(inventory.perConfigFiles);
229
+
230
+ // Index relPath → config for O(1) classification on the file walk.
231
+ const fileToConfig = new Map();
232
+ for (const cfg of VALID_CMAKE_CONFIGS) {
233
+ for (const f of inventory.perConfigFiles[cfg]) fileToConfig.set(f, cfg);
234
+ }
235
+
236
+ // Files matching `*Targets-<custom>.cmake` (non-canonical tag) MUST
237
+ // NOT leak into shared — they'd ship in every slice and consumers
238
+ // would inherit symbols they didn't ask for. The audit fails before
239
+ // any zip is written, but be defence-in-depth: collect them here so
240
+ // the walk can skip them entirely.
241
+ const unknownTagPaths = new Set();
242
+ for (const paths of inventory.unknownTags.values()) {
243
+ for (const p of paths) unknownTagPaths.add(p);
244
+ }
245
+
246
+ const sharedFiles = [];
247
+ /** @type {Record<string, Array<{ fullPath: string, relPath: string }>>} */
248
+ const perConfigBuckets = Object.create(null);
249
+ for (const cfg of VALID_CMAKE_CONFIGS) perConfigBuckets[cfg] = [];
250
+
251
+ function walk(current) {
252
+ for (const entry of fs.readdirSync(current).sort()) {
253
+ const full = path.join(current, entry);
254
+ const stat = fs.statSync(full);
255
+ if (stat.isDirectory()) { walk(full); continue; }
256
+ const rel = path.relative(installDir, full).replace(/\\/g, '/');
257
+ if (unknownTagPaths.has(rel)) continue; // unknown-tag exports never ship
258
+ const cfg = fileToConfig.get(rel);
259
+ if (cfg) perConfigBuckets[cfg].push({ fullPath: full, relPath: rel });
260
+ else sharedFiles.push({ fullPath: full, relPath: rel });
261
+ }
262
+ }
263
+ walk(installDir);
264
+
265
+ return {
266
+ sharedFiles,
267
+ perConfigFiles: perConfigBuckets,
268
+ unknownConfigs: inventory.unknownTags,
269
+ exportFilesFound: inventory.exportFilesFound,
270
+ demotedFiles,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Audit a slice plan against the recipe's declared `build.configs`.
276
+ * Throws on any of:
277
+ * - No `*Targets-<canonical>.cmake` files in the install tree at
278
+ * all → can't slice (header-only or unexported package; recipe
279
+ * should set publishPerConfig: false, or the source CMakeLists
280
+ * should add install(TARGETS … EXPORT …)).
281
+ * - Filenames matching `*Targets-<tag>.cmake` whose tag isn't
282
+ * canonical (typo / unsupported custom build type).
283
+ * - A declared config with zero entries in the inventory (forgot
284
+ * `cmake --install --config <X>`, or the export file has no
285
+ * IMPORTED_LOCATION_<CONFIG> entries at all).
286
+ * - The inventory has a canonical config not declared in
287
+ * `build.configs` (install-tree drifted from recipe).
288
+ *
289
+ * Auditing happens BEFORE any zips are written so a misconfigured
290
+ * publish exits early.
291
+ *
292
+ * @param {ReturnType<planSlice>} plan
293
+ * @param {string[]} declaredConfigs
294
+ */
295
+ function auditSlicePlan(plan, declaredConfigs) {
296
+ if (plan.exportFilesFound === 0) {
297
+ throw new Error(
298
+ 'slice audit: install tree contains no *Targets-<config>.cmake files. ' +
299
+ 'Per-config slicing requires CMake install(EXPORT ...) output. ' +
300
+ 'Either set build.publishPerConfig: false, or add install(TARGETS … EXPORT …) ' +
301
+ 'to the source CMakeLists so the install tree carries per-config target inventories.',
302
+ );
303
+ }
304
+
305
+ if (plan.unknownConfigs.size > 0) {
306
+ const summary = [...plan.unknownConfigs.entries()]
307
+ .map(([tag, paths]) => `${JSON.stringify(tag)} (${paths.length} file${paths.length === 1 ? '' : 's'})`)
308
+ .join(', ');
309
+ throw new Error(
310
+ `slice audit: install tree contains *Targets-<tag>.cmake files with non-canonical ` +
311
+ `config tags: ${summary}. Phase 1 supports only Debug / Release / RelWithDebInfo / MinSizeRel.`,
312
+ );
313
+ }
314
+
315
+ const declared = new Set(declaredConfigs);
316
+ for (const cfg of declaredConfigs) {
317
+ if (!VALID_CONFIG_LOWER.has(cfg.toLowerCase())) {
318
+ throw new Error(
319
+ `slice audit: build.configs contains ${JSON.stringify(cfg)}, which is not a ` +
320
+ `canonical CMake config name`,
321
+ );
322
+ }
323
+ if (plan.perConfigFiles[cfg].length === 0) {
324
+ throw new Error(
325
+ `slice audit: build.configs declares ${JSON.stringify(cfg)} but no ` +
326
+ `IMPORTED_LOCATION_${cfg.toUpperCase()} (or _IMPLIB_/_SONAME_) entries were found in ` +
327
+ `any *Targets-${cfg.toLowerCase()}.cmake. Did you run "wyvrnpm build --config ${cfg} --install"?`,
328
+ );
329
+ }
330
+ }
331
+
332
+ for (const cfg of VALID_CMAKE_CONFIGS) {
333
+ if (plan.perConfigFiles[cfg].length > 0 && !declared.has(cfg)) {
334
+ throw new Error(
335
+ `slice audit: install tree has ${plan.perConfigFiles[cfg].length} file(s) for config ` +
336
+ `${JSON.stringify(cfg)} but build.configs does not declare it. ` +
337
+ `Either add ${JSON.stringify(cfg)} to build.configs, or "wyvrnpm clean" and rebuild ` +
338
+ `with only the declared configs.`,
339
+ );
340
+ }
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Build the (shared ∪ per-config[config]) file list for one slice in
346
+ * deterministic relPath order. archiver preserves insertion order, so
347
+ * the same plan + same input bytes produce a byte-identical zip across
348
+ * runs and across machines.
349
+ *
350
+ * @param {ReturnType<planSlice>} plan
351
+ * @param {string} config
352
+ * @returns {Array<{ fullPath: string, relPath: string }>}
353
+ */
354
+ function buildSliceFileList(plan, config) {
355
+ if (!Object.prototype.hasOwnProperty.call(plan.perConfigFiles, config)) {
356
+ throw new Error(`buildSliceFileList: unknown config ${JSON.stringify(config)}`);
357
+ }
358
+ const merged = [...plan.sharedFiles, ...plan.perConfigFiles[config]];
359
+ merged.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
360
+ return merged;
361
+ }
362
+
363
+ /**
364
+ * Build the slice zip for `config` and return its SHA-256 + size.
365
+ * Zip is closed before SHA is computed; the streaming hash reads from
366
+ * disk, so memory stays flat regardless of slice size.
367
+ *
368
+ * @param {object} args
369
+ * @param {ReturnType<planSlice>} args.plan
370
+ * @param {string} args.config
371
+ * @param {string} args.outZipPath
372
+ * @returns {Promise<{ sha256: string, sizeBytes: number, fileCount: number }>}
373
+ */
374
+ async function buildSliceZip({ plan, config, outZipPath }) {
375
+ const files = buildSliceFileList(plan, config);
376
+ const zip = new StreamingZipWriter(outZipPath);
377
+ for (const { fullPath, relPath } of files) {
378
+ const zipDir = path.dirname(relPath).replace(/\\/g, '/');
379
+ zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
380
+ }
381
+ await zip.finalize();
382
+ const { sha256, sizeBytes } = await sha256AndSize(outZipPath);
383
+ return { sha256, sizeBytes, fileCount: files.length };
384
+ }
385
+
386
+ /**
387
+ * Single-pass streaming SHA-256 + byte size of a file on disk.
388
+ *
389
+ * @param {string} filePath
390
+ * @returns {Promise<{ sha256: string, sizeBytes: number }>}
391
+ */
392
+ function sha256AndSize(filePath) {
393
+ return new Promise((resolve, reject) => {
394
+ const hash = crypto.createHash('sha256');
395
+ let sizeBytes = 0;
396
+ fs.createReadStream(filePath)
397
+ .on('data', (chunk) => { hash.update(chunk); sizeBytes += chunk.length; })
398
+ .on('end', () => resolve({ sha256: hash.digest('hex'), sizeBytes }))
399
+ .on('error', reject);
400
+ });
401
+ }
402
+
403
+ module.exports = {
404
+ parseExportFile,
405
+ buildInventory,
406
+ rectifyMultiConfigFiles,
407
+ matchCanonicalConfig,
408
+ planSlice,
409
+ auditSlicePlan,
410
+ buildSliceFileList,
411
+ buildSliceZip,
412
+ sha256AndSize,
413
+ VALID_CMAKE_CONFIGS,
414
+ };