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.
- package/README.md +163 -2
- package/bin/wyvrnpm.js +167 -11
- package/cmake/cpp.cmake +6 -8
- 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/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/toolchain/template.cmake +7 -5
- package/src/upload-built.js +88 -14
- package/src/v2-meta.js +98 -0
package/src/providers/s3.js
CHANGED
|
@@ -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,
|
|
184
|
-
updateLatest = true,
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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(
|
|
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
|
|
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
|
+
};
|