@kungfu-tech/buildchain 2.14.18 → 2.14.19-alpha.1

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.
@@ -0,0 +1,325 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export const RELEASE_ACTIVATION_CONTRACT =
4
+ "kungfu-buildchain-release-activation-transaction/v1";
5
+ export const RELEASE_ACTIVATION_RECEIPT_SET_CONTRACT =
6
+ "kungfu-buildchain-release-activation-receipt-set/v1";
7
+
8
+ export const RELEASE_ACTIVATION_PHASES = Object.freeze([
9
+ "candidate-qualified",
10
+ "artifacts-published",
11
+ "passport-sealed",
12
+ "site-published",
13
+ "public-readback",
14
+ "evidence-synthesized",
15
+ ]);
16
+
17
+ const ROOT = /^sha256:[0-9a-f]{64}$/;
18
+ const SHA = /^[0-9a-f]{40}$/;
19
+ const RECEIPT_KINDS = Object.freeze([
20
+ "artifact-publication",
21
+ "release-passport",
22
+ "site-publication",
23
+ "public-readback",
24
+ "product-qualification",
25
+ ]);
26
+
27
+ function stableJson(value) {
28
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
29
+ if (value && typeof value === "object") {
30
+ return `{${Object.keys(value)
31
+ .sort()
32
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
33
+ .join(",")}}`;
34
+ }
35
+ return JSON.stringify(value);
36
+ }
37
+
38
+ export function releaseActivationRoot(value) {
39
+ const copy = structuredClone(value);
40
+ delete copy.transactionRoot;
41
+ delete copy.receiptSetRoot;
42
+ return `sha256:${crypto.createHash("sha256").update(stableJson(copy)).digest("hex")}`;
43
+ }
44
+
45
+ function requiredString(value, label) {
46
+ const normalized = String(value || "").trim();
47
+ if (!normalized) throw new Error(`${label} must be a non-empty string`);
48
+ return normalized;
49
+ }
50
+
51
+ function exactRoot(value, label) {
52
+ const normalized = requiredString(value, label).toLowerCase();
53
+ if (!ROOT.test(normalized)) throw new Error(`${label} must be a sha256 content root`);
54
+ return normalized;
55
+ }
56
+
57
+ function exactSha(value, label) {
58
+ const normalized = requiredString(value, label).toLowerCase();
59
+ if (!SHA.test(normalized)) throw new Error(`${label} must be an exact 40-character Git SHA`);
60
+ return normalized;
61
+ }
62
+
63
+ function normalizeBindings(bindings = {}) {
64
+ const version = requiredString(bindings.version, "bindings.version");
65
+ const tag = requiredString(bindings.tag, "bindings.tag");
66
+ if (tag !== `v${version}`) throw new Error("bindings.tag must exactly match bindings.version");
67
+ const channel = requiredString(bindings.channel, "bindings.channel");
68
+ if (!["alpha", "release"].includes(channel)) {
69
+ throw new Error("bindings.channel must be alpha or release");
70
+ }
71
+ const environment = requiredString(bindings.environment, "bindings.environment");
72
+ if (!["shadow", "production"].includes(environment)) {
73
+ throw new Error("bindings.environment must be shadow or production");
74
+ }
75
+ return {
76
+ sourceSha: exactSha(bindings.sourceSha, "bindings.sourceSha"),
77
+ siteSourceSha: exactSha(bindings.siteSourceSha, "bindings.siteSourceSha"),
78
+ tag,
79
+ channel,
80
+ version,
81
+ environment,
82
+ artifactSetRoot: exactRoot(bindings.artifactSetRoot, "bindings.artifactSetRoot"),
83
+ };
84
+ }
85
+
86
+ function normalizeOwners(owners = {}) {
87
+ return {
88
+ product: requiredString(owners.product, "owners.product"),
89
+ transaction: requiredString(owners.transaction, "owners.transaction"),
90
+ site: requiredString(owners.site, "owners.site"),
91
+ };
92
+ }
93
+
94
+ export function createReleaseActivationTransaction({
95
+ transactionId,
96
+ mode = "shadow",
97
+ bindings,
98
+ owners,
99
+ } = {}) {
100
+ if (!["shadow", "activation"].includes(mode)) {
101
+ throw new Error("mode must be shadow or activation");
102
+ }
103
+ const normalizedBindings = normalizeBindings(bindings);
104
+ if (mode === "shadow" && normalizedBindings.environment !== "shadow") {
105
+ throw new Error("shadow transactions must use the shadow environment");
106
+ }
107
+ if (mode === "activation" && normalizedBindings.environment !== "production") {
108
+ throw new Error("activation transactions must use the production environment");
109
+ }
110
+ const transaction = {
111
+ schema: RELEASE_ACTIVATION_CONTRACT,
112
+ transactionId: requiredString(transactionId, "transactionId"),
113
+ mode,
114
+ state: "open",
115
+ releasedUseClaim: false,
116
+ bindings: normalizedBindings,
117
+ owners: normalizeOwners(owners),
118
+ phases: RELEASE_ACTIVATION_PHASES.map((id) => ({
119
+ id,
120
+ status: "pending",
121
+ attempts: 0,
122
+ receiptRoots: [],
123
+ })),
124
+ retainedReceiptRoots: [],
125
+ failure: null,
126
+ rollback: null,
127
+ };
128
+ transaction.transactionRoot = releaseActivationRoot(transaction);
129
+ return transaction;
130
+ }
131
+
132
+ export function validateReleaseActivationTransaction(transaction) {
133
+ const issues = [];
134
+ if (transaction?.schema !== RELEASE_ACTIVATION_CONTRACT) {
135
+ issues.push(`schema must be ${RELEASE_ACTIVATION_CONTRACT}`);
136
+ }
137
+ try {
138
+ normalizeBindings(transaction?.bindings);
139
+ normalizeOwners(transaction?.owners);
140
+ } catch (error) {
141
+ issues.push(error.message);
142
+ }
143
+ if (!["shadow", "activation"].includes(transaction?.mode)) issues.push("mode is invalid");
144
+ if (!["open", "complete", "aborted", "rolled-back"].includes(transaction?.state)) {
145
+ issues.push("state is invalid");
146
+ }
147
+ if (
148
+ !Array.isArray(transaction?.phases) ||
149
+ transaction.phases.map((phase) => phase.id).join(",") !== RELEASE_ACTIVATION_PHASES.join(",")
150
+ ) {
151
+ issues.push("phases must preserve the canonical activation order");
152
+ } else {
153
+ let pendingSeen = false;
154
+ for (const phase of transaction.phases) {
155
+ if (!["pending", "passed", "failed", "rolled-back"].includes(phase.status)) {
156
+ issues.push(`phase ${phase.id} has invalid status`);
157
+ }
158
+ if (phase.status === "pending") pendingSeen = true;
159
+ if (pendingSeen && phase.status === "passed") {
160
+ issues.push(`phase ${phase.id} passed after an incomplete predecessor`);
161
+ }
162
+ if (!Number.isSafeInteger(phase.attempts) || phase.attempts < 0) {
163
+ issues.push(`phase ${phase.id} attempts must be a non-negative integer`);
164
+ }
165
+ if (
166
+ !Array.isArray(phase.receiptRoots) ||
167
+ phase.receiptRoots.some((root) => !ROOT.test(root))
168
+ ) {
169
+ issues.push(`phase ${phase.id} receipt roots are invalid`);
170
+ }
171
+ }
172
+ }
173
+ if (transaction?.mode === "shadow" && transaction?.releasedUseClaim !== false) {
174
+ issues.push("shadow transactions must never make a released-use claim");
175
+ }
176
+ if (transaction?.transactionRoot !== releaseActivationRoot(transaction || {})) {
177
+ issues.push("transactionRoot mismatch");
178
+ }
179
+ return { valid: issues.length === 0, issues };
180
+ }
181
+
182
+ function refresh(transaction) {
183
+ transaction.retainedReceiptRoots = [
184
+ ...new Set(transaction.phases.flatMap((phase) => phase.receiptRoots)),
185
+ ].sort();
186
+ transaction.transactionRoot = releaseActivationRoot(transaction);
187
+ return transaction;
188
+ }
189
+
190
+ export function recordReleaseActivationPhase(
191
+ transaction,
192
+ phaseId,
193
+ { receiptRoots = [], failure = "" } = {},
194
+ ) {
195
+ const validation = validateReleaseActivationTransaction(transaction);
196
+ if (!validation.valid) throw new Error(`invalid activation transaction: ${validation.issues.join("; ")}`);
197
+ if (transaction.state !== "open") {
198
+ if (transaction.state === "complete" && !failure) return structuredClone(transaction);
199
+ throw new Error(`cannot record a phase while transaction state is ${transaction.state}`);
200
+ }
201
+ const next = structuredClone(transaction);
202
+ const index = RELEASE_ACTIVATION_PHASES.indexOf(phaseId);
203
+ if (index < 0) throw new Error(`unknown activation phase: ${phaseId}`);
204
+ if (next.phases.slice(0, index).some((phase) => phase.status !== "passed")) {
205
+ throw new Error(`activation phase ${phaseId} cannot skip an incomplete predecessor`);
206
+ }
207
+ const phase = next.phases[index];
208
+ const normalizedRoots = [...new Set(receiptRoots.map((root, receiptIndex) =>
209
+ exactRoot(root, `receiptRoots[${receiptIndex}]`)))].sort();
210
+ if (phase.status === "passed") {
211
+ if (stableJson(phase.receiptRoots) !== stableJson(normalizedRoots)) {
212
+ throw new Error(`activation phase ${phaseId} replay changed receipt roots`);
213
+ }
214
+ return next;
215
+ }
216
+ phase.attempts += 1;
217
+ phase.receiptRoots = normalizedRoots;
218
+ if (failure) {
219
+ phase.status = "failed";
220
+ next.failure = { phase: phaseId, reason: requiredString(failure, "failure") };
221
+ return refresh(next);
222
+ }
223
+ phase.status = "passed";
224
+ next.failure = null;
225
+ if (index === RELEASE_ACTIVATION_PHASES.length - 1) next.state = "complete";
226
+ return refresh(next);
227
+ }
228
+
229
+ export function abortReleaseActivationTransaction(transaction, reason) {
230
+ if (transaction.state === "complete") throw new Error("completed activation transactions cannot be aborted");
231
+ const next = structuredClone(transaction);
232
+ next.state = "aborted";
233
+ next.failure = {
234
+ phase: next.phases.find((phase) => phase.status !== "passed")?.id || "",
235
+ reason: requiredString(reason, "reason"),
236
+ };
237
+ return refresh(next);
238
+ }
239
+
240
+ export function rollbackReleaseActivationTransaction(transaction, { toSiteSourceSha, reason } = {}) {
241
+ if (!["complete", "aborted"].includes(transaction.state)) {
242
+ throw new Error("rollback requires a complete or aborted activation transaction");
243
+ }
244
+ const next = structuredClone(transaction);
245
+ next.state = "rolled-back";
246
+ next.rollback = {
247
+ toSiteSourceSha: exactSha(toSiteSourceSha, "toSiteSourceSha"),
248
+ reason: requiredString(reason, "reason"),
249
+ };
250
+ for (const phase of next.phases.slice(3).reverse()) {
251
+ if (phase.status === "passed") phase.status = "rolled-back";
252
+ }
253
+ return refresh(next);
254
+ }
255
+
256
+ export function createReleaseActivationReceiptSet({ transaction, receipts = [] } = {}) {
257
+ const validation = validateReleaseActivationTransaction(transaction);
258
+ if (!validation.valid) throw new Error(`invalid activation transaction: ${validation.issues.join("; ")}`);
259
+ if (transaction.state !== "complete") throw new Error("receipt synthesis requires a complete activation transaction");
260
+ const byKind = new Map();
261
+ for (const [index, receipt] of receipts.entries()) {
262
+ const kind = requiredString(receipt?.kind, `receipts[${index}].kind`);
263
+ if (!RECEIPT_KINDS.includes(kind)) throw new Error(`unsupported activation receipt kind: ${kind}`);
264
+ if (byKind.has(kind)) throw new Error(`duplicate activation receipt kind: ${kind}`);
265
+ byKind.set(kind, {
266
+ kind,
267
+ root: exactRoot(receipt.root, `receipts[${index}].root`),
268
+ bindingRoot: exactRoot(receipt.bindingRoot, `receipts[${index}].bindingRoot`),
269
+ locator: requiredString(receipt.locator, `receipts[${index}].locator`),
270
+ });
271
+ }
272
+ for (const kind of RECEIPT_KINDS) {
273
+ if (!byKind.has(kind)) throw new Error(`activation receipt set is missing ${kind}`);
274
+ }
275
+ const bindingRoot = releaseActivationRoot(transaction.bindings);
276
+ if ([...byKind.values()].some((receipt) => receipt.bindingRoot !== bindingRoot)) {
277
+ throw new Error("activation receipts do not bind the exact transaction inputs");
278
+ }
279
+ const receiptSet = {
280
+ schema: RELEASE_ACTIVATION_RECEIPT_SET_CONTRACT,
281
+ transactionId: transaction.transactionId,
282
+ transactionRoot: transaction.transactionRoot,
283
+ mode: transaction.mode,
284
+ releasedUseClaim: transaction.mode === "activation",
285
+ bindings: structuredClone(transaction.bindings),
286
+ receipts: RECEIPT_KINDS.map((kind) => byKind.get(kind)),
287
+ legalBoundary: {
288
+ firstUseDateClaim: null,
289
+ legalConclusion: "not-made",
290
+ registrationStatusClaim: "none",
291
+ },
292
+ };
293
+ receiptSet.receiptSetRoot = releaseActivationRoot(receiptSet);
294
+ return receiptSet;
295
+ }
296
+
297
+ export function validateReleaseActivationReceiptSet(receiptSet, { allowShadow = true } = {}) {
298
+ const issues = [];
299
+ if (receiptSet?.schema !== RELEASE_ACTIVATION_RECEIPT_SET_CONTRACT) {
300
+ issues.push(`schema must be ${RELEASE_ACTIVATION_RECEIPT_SET_CONTRACT}`);
301
+ }
302
+ try {
303
+ normalizeBindings(receiptSet?.bindings);
304
+ } catch (error) {
305
+ issues.push(error.message);
306
+ }
307
+ if (!allowShadow && receiptSet?.mode !== "activation") issues.push("released evidence requires activation mode");
308
+ if (receiptSet?.mode === "shadow" && receiptSet?.releasedUseClaim !== false) {
309
+ issues.push("shadow receipt sets must not claim released use");
310
+ }
311
+ const receipts = Array.isArray(receiptSet?.receipts) ? receiptSet.receipts : [];
312
+ if (receipts.map((receipt) => receipt.kind).join(",") !== RECEIPT_KINDS.join(",")) {
313
+ issues.push("receipt kinds are missing, duplicated, or out of canonical order");
314
+ }
315
+ const bindingRoot = releaseActivationRoot(receiptSet?.bindings || {});
316
+ for (const receipt of receipts) {
317
+ if (!ROOT.test(receipt?.root || "")) issues.push(`${receipt?.kind || "receipt"} root is invalid`);
318
+ if (receipt?.bindingRoot !== bindingRoot) issues.push(`${receipt?.kind || "receipt"} binding root mismatch`);
319
+ if (!String(receipt?.locator || "").trim()) issues.push(`${receipt?.kind || "receipt"} locator is missing`);
320
+ }
321
+ if (receiptSet?.receiptSetRoot !== releaseActivationRoot(receiptSet || {})) {
322
+ issues.push("receiptSetRoot mismatch");
323
+ }
324
+ return { valid: issues.length === 0, issues };
325
+ }
@@ -0,0 +1,85 @@
1
+ function parsePackageVersion(version) {
2
+ const match = String(version || "").match(
3
+ /^(\d+)\.(\d+)\.(\d+)(?:-alpha\.(\d+))?$/,
4
+ );
5
+ if (!match) {
6
+ throw new Error(
7
+ "root package version must expose a numeric semver version for self-dogfood",
8
+ );
9
+ }
10
+ return {
11
+ major: Number(match[1]),
12
+ minor: Number(match[2]),
13
+ patch: Number(match[3]),
14
+ alpha: match[4] === undefined ? undefined : Number(match[4]),
15
+ };
16
+ }
17
+
18
+ function parseAlphaRef(ref) {
19
+ const match = String(ref || "").match(/^v(\d+)-alpha$/);
20
+ if (!match) {
21
+ throw new Error(
22
+ "Buildchain self-dogfood alpha lock must target a major alpha ref",
23
+ );
24
+ }
25
+ return Number(match[1]);
26
+ }
27
+
28
+ export function resolveSelfDogfoodMajor({
29
+ packageVersion,
30
+ alphaRef,
31
+ majorBootstrap = false,
32
+ } = {}) {
33
+ const version = parsePackageVersion(packageVersion);
34
+ const acceptedMajor = parseAlphaRef(alphaRef);
35
+ if (acceptedMajor === version.major) {
36
+ return {
37
+ packageMajor: version.major,
38
+ workflowMajor: acceptedMajor,
39
+ bootstrap: false,
40
+ };
41
+ }
42
+
43
+ const stableBootstrap =
44
+ version.minor === 0 && version.patch === 0 && version.alpha === undefined;
45
+ const nextAlphaBootstrap =
46
+ version.minor === 0 && version.patch === 1 && version.alpha !== undefined;
47
+ if (
48
+ majorBootstrap === true &&
49
+ acceptedMajor + 1 === version.major &&
50
+ (stableBootstrap || nextAlphaBootstrap)
51
+ ) {
52
+ return {
53
+ packageMajor: version.major,
54
+ workflowMajor: acceptedMajor,
55
+ bootstrap: true,
56
+ };
57
+ }
58
+
59
+ throw new Error(
60
+ "Buildchain self-dogfood alpha lock must target the current major alpha ref",
61
+ );
62
+ }
63
+
64
+ export function contractForSelfDogfoodEvaluation({
65
+ currentContract,
66
+ majorResolution,
67
+ } = {}) {
68
+ if (!currentContract || typeof currentContract !== "object") {
69
+ throw new Error("Buildchain self-dogfood requires a current contract");
70
+ }
71
+ if (!majorResolution?.bootstrap) {
72
+ return currentContract;
73
+ }
74
+ return {
75
+ ...currentContract,
76
+ majorLine: `v${majorResolution.workflowMajor}`,
77
+ };
78
+ }
79
+
80
+ export function canAdmitSelfDogfoodLockEvaluation({
81
+ evaluation,
82
+ majorResolution,
83
+ } = {}) {
84
+ return evaluation?.compatible === true || majorResolution?.bootstrap === true;
85
+ }
@@ -5,6 +5,11 @@ import {
5
5
  collectPublicSurfaceReverseAudit,
6
6
  } from "../packages/core/public-surface-audit.js";
7
7
  import { evaluateBuildchainContractLock } from "../packages/core/buildchain-contract.js";
8
+ import {
9
+ canAdmitSelfDogfoodLockEvaluation,
10
+ contractForSelfDogfoodEvaluation,
11
+ resolveSelfDogfoodMajor,
12
+ } from "../packages/core/self-dogfood-version.js";
8
13
  import { generateChannelBuildWorkflow } from "./generate-channel-build-workflow.mjs";
9
14
  import {
10
15
  generateChannelPromotionWorkflow,
@@ -138,13 +143,12 @@ const selfDogfoodAlphaLock = JSON.parse(
138
143
  const currentBuildchainContract = JSON.parse(
139
144
  fs.readFileSync(path.join(root, "dist/site/buildchain-contract.json"), "utf8"),
140
145
  );
141
- const selfDogfoodMajor = String(rootPackage.version || "").match(/^(\d+)\./)?.[1];
142
- if (!selfDogfoodMajor) {
143
- throw new Error("root package version must expose a numeric major for self-dogfood");
144
- }
145
- if (selfDogfoodAlphaLock.buildchain?.ref !== `v${selfDogfoodMajor}-alpha`) {
146
- throw new Error("Buildchain self-dogfood alpha lock must target the current major alpha ref");
147
- }
146
+ const selfDogfoodMajorResolution = resolveSelfDogfoodMajor({
147
+ packageVersion: rootPackage.version,
148
+ alphaRef: selfDogfoodAlphaLock.buildchain?.ref,
149
+ majorBootstrap: process.env.BUILDCHAIN_MAJOR_VERSION_BOOTSTRAP === "true",
150
+ });
151
+ const selfDogfoodMajor = String(selfDogfoodMajorResolution.workflowMajor);
148
152
  if (!/^[0-9a-f]{40}$/.test(selfDogfoodAlphaLock.buildchain?.resolvedSha || "")) {
149
153
  throw new Error("Buildchain self-dogfood alpha lock must record an exact accepted SHA");
150
154
  }
@@ -153,12 +157,20 @@ if (selfDogfoodAlphaLock.buildchain?.compatibilityPolicy !== "major-compatible")
153
157
  }
154
158
  const selfDogfoodAlphaEvaluation = evaluateBuildchainContractLock({
155
159
  lock: selfDogfoodAlphaLock,
156
- current: currentBuildchainContract,
160
+ current: contractForSelfDogfoodEvaluation({
161
+ currentContract: currentBuildchainContract,
162
+ majorResolution: selfDogfoodMajorResolution,
163
+ }),
157
164
  runtimeRef: `v${selfDogfoodMajor}-alpha`,
158
165
  runtimeSha: "current-development-contract",
159
166
  runtimeClass: "alpha",
160
167
  });
161
- if (!selfDogfoodAlphaEvaluation.compatible) {
168
+ if (
169
+ !canAdmitSelfDogfoodLockEvaluation({
170
+ evaluation: selfDogfoodAlphaEvaluation,
171
+ majorResolution: selfDogfoodMajorResolution,
172
+ })
173
+ ) {
162
174
  throw new Error("Buildchain self-dogfood alpha lock requires review after a breaking contract change");
163
175
  }
164
176
  for (const requiredSnippet of [
@@ -329,6 +329,7 @@ const manualMetaById = new Map(Object.entries({
329
329
  "controller-evidence": { capabilityGroup: "reusable-build", audience: ["consumer", "release-operator", "agent"], maturity: "draft", order: 205 },
330
330
  "binary-distribution": { capabilityGroup: "release-passport-trust", audience: ["release-operator", "agent"], maturity: "stable", order: 110 },
331
331
  "publish-transaction": { capabilityGroup: "release-passport-trust", audience: ["release-operator"], maturity: "stable", order: 120 },
332
+ "release-activation-transaction": { capabilityGroup: "release-passport-trust", audience: ["release-operator", "agent"], maturity: "preview", order: 125 },
332
333
  "release-candidate": { capabilityGroup: "reusable-build", audience: ["release-operator", "consumer"], maturity: "stable", order: 130 },
333
334
  "stable-candidate-patrol": { capabilityGroup: "governance-versioning", audience: ["release-operator", "consumer"], maturity: "preview", order: 135 },
334
335
  "observed-evidence-patrol": { capabilityGroup: "governance-versioning", audience: ["release-operator", "consumer", "agent"], maturity: "preview", order: 140 },
@@ -519,6 +520,7 @@ function nodeApiMeta(exportName) {
519
520
  "./release-candidate": { group: "reusable-build", summary: "PR-stage release-candidate artifact, passport, and promote-only resolver APIs." },
520
521
  "./stable-candidate-ledger": { group: "governance-versioning", summary: "Immutable alpha candidate ledger, qualification, revocation, selection, and exact stable source-lock APIs." },
521
522
  "./release-propagation": { group: "site-and-propagation", summary: "Release propagation graph, plan, and exact upstream lock APIs." },
523
+ "./release-activation-transaction": { group: "release-passport-trust", summary: "Ordered cross-repository activation, exact receipt-set binding, retry, abort, rollback, and shadow-rehearsal APIs." },
522
524
  "./release-line-bootstrap": { group: "governance-versioning", summary: "Semver release-line bootstrap planning and version-state APIs." },
523
525
  "./buildchain-contract": { group: "governance-versioning", summary: "Runtime contract world and compatibility digest APIs for floating-ref drift checks." },
524
526
  "./controller-evidence": { group: "reusable-build", summary: "Project-independent controller descriptors, source/runtime-bound plans, receipts, aggregates, and validation APIs." },
@@ -77,11 +77,14 @@ function readAsset(artifactRoot, relativePath, expected) {
77
77
  const safePath = safeRelative(relativePath, "installer asset path");
78
78
  const absolute = path.resolve(artifactRoot, safePath);
79
79
  const rootPath = path.resolve(artifactRoot);
80
- if (
81
- !absolute.startsWith(`${rootPath}${path.sep}`) ||
82
- !fs.statSync(absolute).isFile()
83
- ) {
84
- throw new Error(`installer asset is missing: ${safePath}`);
80
+ if (!absolute.startsWith(`${rootPath}${path.sep}`)) {
81
+ throw new Error(`installer asset escapes artifact root: ${safePath}`);
82
+ }
83
+ const stat = fs.lstatSync(absolute);
84
+ if (!stat.isFile() || stat.isSymbolicLink()) {
85
+ throw new Error(
86
+ `installer asset must be a regular non-symlink file: ${safePath}`,
87
+ );
85
88
  }
86
89
  const bytes = fs.readFileSync(absolute);
87
90
  const observed = {
@@ -164,6 +167,13 @@ export function validateInstallerPublication({ publication, artifactRoot }) {
164
167
  if (!Number.isSafeInteger(asset.size) || asset.size < 1) {
165
168
  throw new Error(`${asset.name}.size is invalid`);
166
169
  }
170
+ const expectedContentType =
171
+ asset.name === "install.sh"
172
+ ? "text/x-shellscript; charset=utf-8"
173
+ : "text/plain; charset=utf-8";
174
+ if (asset.contentType !== expectedContentType) {
175
+ throw new Error(`${asset.name}.contentType is invalid`);
176
+ }
167
177
  const immutable = readAsset(
168
178
  artifactRoot,
169
179
  `${immutablePath}/${asset.name}`,