@rightkit/release 0.2.71 → 0.2.73

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,482 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { RIGHTRELEASE_MANIFEST_SIGNER, signReleaseManifestWithAzure } from "./sign-release-manifest.mjs";
6
+ import { validateCycloneDxSbom, validateInTotoSlsaProvenance } from "./supply-chain-evidence.mjs";
7
+
8
+ const SHA256 = /^[a-f0-9]{64}$/;
9
+ const VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
10
+ const MAX_BOOTSTRAP_BYTES = 256 * 1024;
11
+ const TARGETS = new Set(["windows-x86_64", "windows-arm64", "macos-x86_64", "macos-arm64"]);
12
+
13
+ export function canonicalJson(value) {
14
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
15
+ if (value && typeof value === "object") {
16
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
17
+ }
18
+ return JSON.stringify(value);
19
+ }
20
+
21
+ export function sha256File(path) {
22
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
23
+ }
24
+
25
+ export function collectReleaseAsset({
26
+ target,
27
+ name,
28
+ url,
29
+ archivePath,
30
+ executablePath,
31
+ nativeSignaturePolicy,
32
+ provenancePath,
33
+ sbomPath,
34
+ }) {
35
+ const archive = statSync(archivePath);
36
+ if (!archive.isFile()) throw new Error("release archive is missing");
37
+ if (!statSync(provenancePath).isFile() || !statSync(sbomPath).isFile()) throw new Error("provenance/SBOM evidence is missing");
38
+ const archiveSha256 = sha256File(archivePath);
39
+ validateInTotoSlsaProvenance(provenancePath, { expectedSubject: { name, sha256: archiveSha256 } });
40
+ validateCycloneDxSbom(sbomPath, { expectedFile: { name, sha256: archiveSha256 } });
41
+ const releaseBase = url.slice(0, url.lastIndexOf("/") + 1);
42
+ const provenanceName = provenancePath.split(/[\\/]/).at(-1);
43
+ const sbomName = sbomPath.split(/[\\/]/).at(-1);
44
+ return {
45
+ target,
46
+ name,
47
+ url,
48
+ size: archive.size,
49
+ sha256: archiveSha256,
50
+ executablePath,
51
+ nativeSignaturePolicy,
52
+ provenanceName,
53
+ provenanceUrl: releaseBase + provenanceName,
54
+ provenanceSha256: sha256File(provenancePath),
55
+ sbomName,
56
+ sbomUrl: releaseBase + sbomName,
57
+ sbomSha256: sha256File(sbomPath),
58
+ };
59
+ }
60
+
61
+ function assertRelativeFile(path, label) {
62
+ if (typeof path !== "string" || !path || isAbsolute(path) || path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) {
63
+ throw new Error(`${label} must be a contained relative file path`);
64
+ }
65
+ }
66
+
67
+ function assertGitHubAssetUrl(url) {
68
+ const parsed = new URL(url);
69
+ if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" || !/\/releases\/download\//.test(parsed.pathname)) {
70
+ throw new Error(`release payload must use immutable GitHub Release URL: ${url}`);
71
+ }
72
+ }
73
+
74
+ export function createPortableArchive({ sourceDir, outputPath, commandRunner = spawnSync }) {
75
+ const source = realpathSync(sourceDir);
76
+ mkdirSync(dirname(outputPath), { recursive: true });
77
+ const result = commandRunner("tar", ["-a", "-c", "-f", resolve(outputPath), "-C", source, "."], {
78
+ encoding: "utf8",
79
+ windowsHide: true,
80
+ });
81
+ if (result?.status !== 0) throw new Error(`portable archive failed: ${String(result?.stderr ?? "").trim()}`);
82
+ if (!statSync(outputPath).isFile()) throw new Error(`portable archive missing: ${outputPath}`);
83
+ return { path: resolve(outputPath), size: statSync(outputPath).size, sha256: sha256File(outputPath) };
84
+ }
85
+
86
+ export function buildChecksums(assets) {
87
+ const checksums = {};
88
+ for (const asset of assets) {
89
+ const files = [
90
+ { name: asset.name, sha256: asset.sha256 },
91
+ ...(asset.provenanceName ? [{ name: asset.provenanceName, sha256: asset.provenanceSha256 }] : []),
92
+ ...(asset.sbomName ? [{ name: asset.sbomName, sha256: asset.sbomSha256 }] : []),
93
+ ];
94
+ for (const file of files) {
95
+ assertRelativeFile(file.name, "asset name");
96
+ if (!SHA256.test(file.sha256)) throw new Error(`invalid SHA-256 for ${file.name}`);
97
+ if (checksums[file.name]) throw new Error(`duplicate release asset: ${file.name}`);
98
+ checksums[file.name] = file.sha256;
99
+ }
100
+ }
101
+ return { schemaVersion: 1, algorithm: "sha256", assets: checksums };
102
+ }
103
+
104
+ export function buildReleaseManifest({
105
+ product,
106
+ version,
107
+ tag = `v${version}`,
108
+ sourceCommit,
109
+ minimumBootstrapVersion,
110
+ assets,
111
+ checksumsDigest,
112
+ }) {
113
+ if (!/^[a-z][a-z0-9-]+$/.test(product)) throw new Error("invalid product");
114
+ if (!VERSION.test(version) || tag !== `v${version}`) throw new Error("version/tag mismatch");
115
+ if (!/^[a-f0-9]{40,64}$/.test(sourceCommit)) throw new Error("invalid source commit");
116
+ if (!VERSION.test(minimumBootstrapVersion)) throw new Error("invalid minimum bootstrap version");
117
+ if (!SHA256.test(checksumsDigest)) throw new Error("invalid checksums digest");
118
+ const seen = new Set();
119
+ const normalizedAssets = assets.map((asset) => {
120
+ if (!TARGETS.has(asset.target)) throw new Error(`unsupported release target: ${asset.target}`);
121
+ if (seen.has(asset.target)) throw new Error(`duplicate release target: ${asset.target}`);
122
+ seen.add(asset.target);
123
+ assertRelativeFile(asset.name, "asset name");
124
+ assertGitHubAssetUrl(asset.url);
125
+ if (!Number.isSafeInteger(asset.size) || asset.size < 1 || !SHA256.test(asset.sha256)) throw new Error(`invalid asset evidence: ${asset.name}`);
126
+ if (!SHA256.test(asset.provenanceSha256) || !SHA256.test(asset.sbomSha256)) throw new Error(`invalid bound evidence: ${asset.name}`);
127
+ assertRelativeFile(asset.provenanceName, "provenance name");
128
+ assertRelativeFile(asset.sbomName, "SBOM name");
129
+ assertGitHubAssetUrl(asset.provenanceUrl);
130
+ assertGitHubAssetUrl(asset.sbomUrl);
131
+ const releaseBase = asset.url.slice(0, asset.url.lastIndexOf("/") + 1);
132
+ if (asset.provenanceUrl !== releaseBase + asset.provenanceName || asset.sbomUrl !== releaseBase + asset.sbomName) throw new Error(`bound evidence URL mismatch: ${asset.name}`);
133
+ if (asset.target.startsWith("windows-") && asset.nativeSignaturePolicy !== "authenticode-valid") throw new Error(`Windows asset must require Authenticode: ${asset.name}`);
134
+ if (asset.target.startsWith("macos-") && asset.nativeSignaturePolicy !== "developer-id-notarized") throw new Error(`macOS asset must require Developer ID notarization: ${asset.name}`);
135
+ assertRelativeFile(asset.executablePath, "executable path");
136
+ return { ...asset };
137
+ }).sort((left, right) => left.target.localeCompare(right.target));
138
+ return {
139
+ schemaVersion: 1,
140
+ kind: "rightkit-direct-release-manifest",
141
+ product,
142
+ version,
143
+ tag,
144
+ sourceCommit,
145
+ minimumBootstrapVersion,
146
+ signingKeyId: RIGHTRELEASE_MANIFEST_SIGNER.id,
147
+ signatureAlgorithm: RIGHTRELEASE_MANIFEST_SIGNER.algorithm,
148
+ checksumsSha256: checksumsDigest,
149
+ assets: normalizedAssets,
150
+ };
151
+ }
152
+
153
+ export function materializeDirectRelease({ outputDir, manifestInput }) {
154
+ mkdirSync(outputDir, { recursive: true });
155
+ const checksums = buildChecksums(manifestInput.assets);
156
+ const checksumsText = `${canonicalJson(checksums)}\n`;
157
+ const checksumsPath = join(outputDir, "checksums.json");
158
+ writeFileSync(checksumsPath, checksumsText);
159
+ const manifest = buildReleaseManifest({
160
+ ...manifestInput,
161
+ checksumsDigest: createHash("sha256").update(checksumsText).digest("hex"),
162
+ });
163
+ const manifestText = `${canonicalJson(manifest)}\n`;
164
+ const manifestPath = join(outputDir, "release-manifest.json");
165
+ const signaturePath = join(outputDir, "release-manifest.sig");
166
+ writeFileSync(manifestPath, manifestText);
167
+ const signing = signReleaseManifestWithAzure({ manifestPath, signaturePath });
168
+ if (!statSync(signaturePath).isFile() || statSync(signaturePath).size < 1) throw new Error("detached release manifest signature is missing");
169
+ if (signing?.signer?.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || signing.signer.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || !/^[a-f0-9]{64}$/.test(signing.signer.certificateSha256 ?? "")) {
170
+ throw new Error("detached release manifest signer is not RightRelease Azure authority");
171
+ }
172
+ return { manifest, manifestPath, signaturePath, signing, checksums };
173
+ }
174
+
175
+ function psQuote(value) {
176
+ return "'" + String(value).replaceAll("'", "''") + "'";
177
+ }
178
+
179
+ function psArray(values) {
180
+ return "@(" + values.map(psQuote).join(",") + ")";
181
+ }
182
+
183
+ export function renderPowerShellBootstrap({
184
+ product,
185
+ repository,
186
+ bootstrapVersion,
187
+ acceptedManifestSigners,
188
+ installRootSubdir,
189
+ executablePath,
190
+ activationArgs,
191
+ statusArgs,
192
+ expectedStatusKind,
193
+ expectedStatus = "complete",
194
+ healthAssertions = null,
195
+ preflightArgs = [],
196
+ }) {
197
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error("invalid GitHub repository");
198
+ if (!VERSION.test(bootstrapVersion)) throw new Error("invalid bootstrap version");
199
+ assertRelativeFile(executablePath, "executable path");
200
+ if (!Array.isArray(acceptedManifestSigners) || !acceptedManifestSigners.length) throw new Error("accepted manifest signers are required");
201
+ const signers = {};
202
+ for (const signer of acceptedManifestSigners) {
203
+ if (typeof signer.id !== "string" || !signer.id || typeof signer.subject !== "string" || !/^CN=/.test(signer.subject) || !/^[a-f0-9]{64}$/.test(signer.certificateSha256 ?? "")) {
204
+ throw new Error("accepted manifest signer requires id, certificate subject, and SHA-256 fingerprint");
205
+ }
206
+ signers[signer.id] = { subject: signer.subject, certificateSha256: signer.certificateSha256 };
207
+ }
208
+ if (healthAssertions === null && (typeof expectedStatusKind !== "string" || !expectedStatusKind)) {
209
+ throw new Error("expectedStatusKind or product-specific healthAssertions is required");
210
+ }
211
+ const assertions = healthAssertions ?? [
212
+ { path: "kind", equals: expectedStatusKind },
213
+ { path: "status", equals: expectedStatus },
214
+ ];
215
+ if (!Array.isArray(assertions) || !assertions.length) throw new Error("health assertions are required");
216
+ for (const assertion of assertions) {
217
+ if (!assertion || typeof assertion.path !== "string" || !/^[A-Za-z_][A-Za-z0-9_-]*(?:\.(?:[A-Za-z_][A-Za-z0-9_-]*|\d+))*$/.test(assertion.path)) {
218
+ throw new Error("health assertion requires safe JSON path");
219
+ }
220
+ const predicates = [Object.hasOwn(assertion, "equals"), assertion.nonempty === true, Object.hasOwn(assertion, "minCount")].filter(Boolean).length;
221
+ if (predicates !== 1) throw new Error("health assertion requires exactly one equals, nonempty, or minCount predicate");
222
+ if (Object.hasOwn(assertion, "minCount") && (!Number.isSafeInteger(assertion.minCount) || assertion.minCount < 0)) throw new Error("health minCount must be a nonnegative integer");
223
+ }
224
+ const contract = {
225
+ schemaVersion: 1,
226
+ generator: "rightkit-direct-bootstrap",
227
+ product,
228
+ repository,
229
+ bootstrapVersion,
230
+ installRootSubdir,
231
+ acceptedManifestSigners: signers,
232
+ executablePath: executablePath.replaceAll("\\", "/"),
233
+ activationArgs,
234
+ statusArgs,
235
+ healthAssertions: assertions,
236
+ preflightArgs,
237
+ };
238
+ // Bind generated contract metadata so a caller cannot pass a marker plus an
239
+ // arbitrary base64 payload and have it treated as a RightRelease bootstrap.
240
+ // This is not a replacement for CMS manifest signing; it is a structural
241
+ // integrity fence for the executable bootstrap source itself.
242
+ contract.contractSha256 = createHash("sha256").update(canonicalJson(contract)).digest("hex");
243
+ const contractB64 = Buffer.from(JSON.stringify(contract)).toString("base64");
244
+ const lines = [
245
+ "# rightkit-direct-bootstrap:v1",
246
+ "# generated contract: generator=rightkit-direct-bootstrap contractSha256-bound",
247
+ "[CmdletBinding()] param([string]$Version, [switch]$AllowDowngrade)",
248
+ "$ErrorActionPreference = 'Stop'",
249
+ "$ProgressPreference = 'SilentlyContinue'",
250
+ "$Contract = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(" + psQuote(contractB64) + ")) | ConvertFrom-Json",
251
+ "$AcceptedSigners = @{}; $Contract.acceptedManifestSigners.PSObject.Properties | ForEach-Object { $AcceptedSigners[$_.Name] = $_.Value }",
252
+ "$InstallRoot = Join-Path $env:LOCALAPPDATA " + psQuote(installRootSubdir),
253
+ "$VersionsRoot = Join-Path $InstallRoot 'versions'",
254
+ "$Current = Join-Path $InstallRoot 'current'",
255
+ "$PreviousLink = Join-Path $InstallRoot '.current-previous'",
256
+ "$NextLink = Join-Path $InstallRoot '.current-next'",
257
+ "$Journal = Join-Path $InstallRoot 'integration-journal.json'",
258
+ "function Expand-BootstrapArgs($Values, $ActiveVersionRoot, $ActiveVersion) { foreach ($Value in $Values) { ([string]$Value).Replace('{current}', $Current).Replace('{installRoot}', $InstallRoot).Replace('{versionRoot}', $ActiveVersionRoot).Replace('{version}', $ActiveVersion) } }",
259
+ "function Resolve-HealthPath($Value, [string]$JsonPath) { $CurrentValue = $Value; foreach ($Segment in $JsonPath.Split('.')) { if ($Segment -match '^\\d+$') { $Index = [int]$Segment; if ($null -eq $CurrentValue -or $Index -ge @($CurrentValue).Count) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = @($CurrentValue)[$Index] } else { $Property = $CurrentValue.PSObject.Properties[$Segment]; if ($null -eq $Property) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = $Property.Value } }; return $CurrentValue }",
260
+ "function Assert-Health($Health, [string]$ActiveVersion) { foreach ($Assertion in $Contract.healthAssertions) { $Actual = Resolve-HealthPath $Health ([string]$Assertion.path); if ($Assertion.PSObject.Properties.Name -contains 'equals') { $Expected = $Assertion.equals; if ($Expected -is [string]) { $Expected = $Expected.Replace('{version}', $ActiveVersion).Replace('{current}', $Current).Replace('{installRoot}', $InstallRoot) }; if ($Actual -ne $Expected) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif ($Assertion.nonempty -eq $true) { if ($null -eq $Actual -or ([string]$Actual).Trim().Length -eq 0) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif (@($Actual).Count -lt [int]$Assertion.minCount) { throw ('Health assertion failed: ' + $Assertion.path) } } }",
261
+ "New-Item -ItemType Directory -Force -Path $VersionsRoot | Out-Null",
262
+ "if (-not $Version) { $Version = ((Invoke-RestMethod " + psQuote("https://api.github.com/repos/" + repository + "/releases/latest") + ").tag_name -replace '^v','') }",
263
+ "if ($Version -notmatch '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$') { throw 'Invalid stable release version' }",
264
+ "$PriorTarget = $null; if (Test-Path $Current) { $PriorTarget = (Get-Item $Current).Target }",
265
+ "$PriorVersion = if ($PriorTarget) { ((Split-Path ([string]$PriorTarget) -Leaf) -split '-')[0] } else { $null }",
266
+ "if ($PriorVersion -and -not $AllowDowngrade -and ([version]$Version -lt [version]$PriorVersion)) { throw 'Downgrade requires -AllowDowngrade' }",
267
+ "$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
268
+ "$Work = Join-Path ([IO.Path]::GetTempPath()) (" + psQuote(product + "-install-") + " + [guid]::NewGuid())",
269
+ "New-Item -ItemType Directory -Force -Path $Work | Out-Null",
270
+ "$ManifestPath = Join-Path $Work 'release-manifest.json'; $SignaturePath = Join-Path $Work 'release-manifest.sig'; $ChecksumsPath = Join-Path $Work 'checksums.json'",
271
+ "Invoke-WebRequest ($ReleaseBase + '/release-manifest.json') -OutFile $ManifestPath",
272
+ "Invoke-WebRequest ($ReleaseBase + '/release-manifest.sig') -OutFile $SignaturePath",
273
+ "$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath); $SignatureBytes = [IO.File]::ReadAllBytes($SignaturePath)",
274
+ "Add-Type -AssemblyName System.Security; $Cms = New-Object Security.Cryptography.Pkcs.SignedCms((New-Object Security.Cryptography.Pkcs.ContentInfo(,$ManifestBytes)), $true); $Cms.Decode($SignatureBytes); $Cms.CheckSignature($true)",
275
+ "if ($Cms.SignerInfos.Count -ne 1) { throw 'Release manifest signature is invalid' }; $SignerCertificate = $Cms.SignerInfos[0].Certificate",
276
+ "$Sha256 = [Security.Cryptography.SHA256]::Create(); try { $SignerFingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($SignerCertificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
277
+ "$VerifiedKeyId = $null; foreach ($KeyId in $AcceptedSigners.Keys) { $Accepted = $AcceptedSigners[$KeyId]; if ($SignerCertificate.Subject -eq [string]$Accepted.subject -and $SignerFingerprint -eq [string]$Accepted.certificateSha256) { $VerifiedKeyId = $KeyId; break } }",
278
+ "if (-not $VerifiedKeyId) { throw 'Release manifest signer is not accepted' }",
279
+ "$Manifest = [Text.Encoding]::UTF8.GetString($ManifestBytes) | ConvertFrom-Json",
280
+ "if ($Manifest.signingKeyId -ne $VerifiedKeyId -or $Manifest.signatureAlgorithm -ne 'cms-sha256') { throw 'Release manifest signer identity mismatch' }",
281
+ "if ($Manifest.product -ne " + psQuote(product) + " -or $Manifest.version -ne $Version -or $Manifest.tag -ne ('v' + $Version)) { throw 'Release identity mismatch' }",
282
+ "if ([version]$Contract.bootstrapVersion -lt [version]$Manifest.minimumBootstrapVersion) { throw 'Bootstrap update required' }",
283
+ "$Machine = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString(); $Target = if ($Machine -eq 'Arm64') { 'windows-arm64' } elseif ($Machine -eq 'X64') { 'windows-x86_64' } else { throw ('Unsupported architecture: ' + $Machine) }",
284
+ "$Matches = @($Manifest.assets | Where-Object { $_.target -eq $Target }); if ($Matches.Count -ne 1) { throw 'Release has no unique exact target' }; $Asset = $Matches[0]",
285
+ "if ($Asset.url -ne ($ReleaseBase + '/' + $Asset.name)) { throw 'Asset URL is not exact immutable GitHub release URL' }",
286
+ "Invoke-WebRequest ($ReleaseBase + '/checksums.json') -OutFile $ChecksumsPath",
287
+ "$ChecksumHash = (Get-FileHash -Algorithm SHA256 $ChecksumsPath).Hash.ToLowerInvariant(); if ($ChecksumHash -ne $Manifest.checksumsSha256) { throw 'checksums.json is not manifest-bound' }",
288
+ "$Checksums = Get-Content -Raw $ChecksumsPath | ConvertFrom-Json; if ($Checksums.assets.($Asset.name) -ne $Asset.sha256) { throw 'Asset checksum differs from bound checksums' }",
289
+ "$Archive = Join-Path $Work $Asset.name; Invoke-WebRequest $Asset.url -OutFile $Archive",
290
+ "if ((Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant() -ne $Asset.sha256) { throw 'Archive checksum mismatch' }",
291
+ "if ($Checksums.assets.($Asset.provenanceName) -ne $Asset.provenanceSha256 -or $Checksums.assets.($Asset.sbomName) -ne $Asset.sbomSha256) { throw 'Bound provenance/SBOM checksums are missing' }",
292
+ "$ProvenancePath = Join-Path $Work $Asset.provenanceName; $SbomPath = Join-Path $Work $Asset.sbomName",
293
+ "Invoke-WebRequest $Asset.provenanceUrl -OutFile $ProvenancePath; Invoke-WebRequest $Asset.sbomUrl -OutFile $SbomPath",
294
+ "if ((Get-FileHash -Algorithm SHA256 $ProvenancePath).Hash.ToLowerInvariant() -ne $Asset.provenanceSha256) { throw 'Provenance checksum mismatch' }",
295
+ "if ((Get-FileHash -Algorithm SHA256 $SbomPath).Hash.ToLowerInvariant() -ne $Asset.sbomSha256) { throw 'SBOM checksum mismatch' }",
296
+ "$Stage = Join-Path $Work 'stage'; New-Item -ItemType Directory -Force -Path $Stage | Out-Null",
297
+ "Add-Type -AssemblyName System.IO.Compression.FileSystem; $Zip = [IO.Compression.ZipFile]::OpenRead($Archive); try { foreach ($Entry in $Zip.Entries) { $Dest = [IO.Path]::GetFullPath((Join-Path $Stage $Entry.FullName)); if (-not $Dest.StartsWith(([IO.Path]::GetFullPath($Stage) + [IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) { throw 'Archive path escapes staging root' } } } finally { $Zip.Dispose() }",
298
+ "Expand-Archive -Path $Archive -DestinationPath $Stage -Force",
299
+ "$VersionRoot = Join-Path $VersionsRoot ($Version + '-' + $Asset.sha256.Substring(0,12) + '-' + [guid]::NewGuid()); Move-Item $Stage $VersionRoot",
300
+ "$VersionExe = Join-Path $VersionRoot ($Asset.executablePath -replace '/', [IO.Path]::DirectorySeparatorChar); if (-not (Test-Path $VersionExe)) { throw 'Release executable missing' }",
301
+ "$Authenticode = Get-AuthenticodeSignature $VersionExe; if ($Asset.nativeSignaturePolicy -eq 'authenticode-valid' -and $Authenticode.Status -ne 'Valid') { throw 'Native signature invalid' }",
302
+ "$ActivationArgsTemplate = " + psArray(activationArgs),
303
+ "$StatusArgsTemplate = " + psArray(statusArgs),
304
+ "$PreflightArgsTemplate = " + psArray(preflightArgs),
305
+ "$PreflightArgs = @(Expand-BootstrapArgs $PreflightArgsTemplate $VersionRoot $Version)",
306
+ "$PriorStatus = $null; if ($PriorTarget) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); try { $PriorStatus = (& $PriorExe @PriorStatusArgs | Out-String).Trim() } catch {} }",
307
+ "@{ schemaVersion=1; priorTarget=$PriorTarget; priorVersion=$PriorVersion; targetVersion=$Version; priorStatus=$PriorStatus } | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $Journal",
308
+ "if ($PreflightArgs.Count) { & $VersionExe @PreflightArgs; if ($LASTEXITCODE -ne 0) { throw 'Activation preflight failed' } }",
309
+ "if (Test-Path $NextLink) { Remove-Item -Force $NextLink }; if (Test-Path $PreviousLink) { Remove-Item -Force $PreviousLink }",
310
+ "New-Item -ItemType Junction -Path $NextLink -Target $VersionRoot | Out-Null",
311
+ "$Switched = $false; $PriorDetached = $false",
312
+ "try {",
313
+ " if (Test-Path $Current) { Move-Item $Current $PreviousLink; $PriorDetached = $true }; Move-Item $NextLink $Current; $Switched = $true",
314
+ " $StableExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")),
315
+ " $ActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate $VersionRoot $Version); $StatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate $VersionRoot $Version)",
316
+ " & $StableExe @ActivationArgs; if ($LASTEXITCODE -ne 0) { throw 'Activation failed' }",
317
+ " $HealthText = (& $StableExe @StatusArgs | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Health command failed' }; $Health = $HealthText | ConvertFrom-Json",
318
+ " Assert-Health $Health $Version",
319
+ " $StableBin = Split-Path $StableExe -Parent; $UserPath = [Environment]::GetEnvironmentVariable('Path','User'); if (-not (($UserPath -split ';') -contains $StableBin)) { [Environment]::SetEnvironmentVariable('Path', (($UserPath.TrimEnd(';') + ';' + $StableBin).Trim(';')), 'User') }",
320
+ // Journal cleanup must succeed before discarding the prior pointer. If
321
+ // cleanup fails, catch still has a rollback anchor to restore.
322
+ " Remove-Item -Force $Journal; if (Test-Path $PreviousLink) { Remove-Item -Force $PreviousLink }; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
323
+ "} catch {",
324
+ " if (($Switched -or $PriorDetached) -and (Test-Path $Current)) { Remove-Item -Force $Current }; if ($PriorDetached -and (Test-Path $PreviousLink)) { Move-Item $PreviousLink $Current }",
325
+ " if ($PriorTarget -and (Test-Path $Current)) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate ([string]$PriorTarget) $PriorVersion); $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); & $PriorExe @PriorActivationArgs; if ($LASTEXITCODE -ne 0) { throw 'Rollback activation failed' }; $PriorHealthText = (& $PriorExe @PriorStatusArgs | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Rollback health failed' }; $PriorHealth = $PriorHealthText | ConvertFrom-Json; try { Assert-Health $PriorHealth $PriorVersion } catch { throw 'Prior health was not restored' } }",
326
+ " if (Test-Path $VersionRoot) { Remove-Item -Recurse -Force $VersionRoot }; throw",
327
+ "} finally { if (Test-Path $Work) { Remove-Item -Recurse -Force $Work } }",
328
+ ];
329
+ const body = lines.join("\r\n") + "\r\n";
330
+ const sourceSha256 = createHash("sha256").update(body).digest("hex");
331
+ return `${body}# source-sha256:${sourceSha256}\r\n`;
332
+ }
333
+
334
+ export function validatePowerShellBootstrap(script, expected = {}) {
335
+ const required = [
336
+ "# rightkit-direct-bootstrap:v1",
337
+ "Release manifest signature is invalid",
338
+ "certificateSha256",
339
+ "CheckSignature($true)",
340
+ "checksums.json is not manifest-bound",
341
+ "Provenance checksum mismatch",
342
+ "SBOM checksum mismatch",
343
+ "New-Item -ItemType Junction",
344
+ "integration-journal.json",
345
+ "Rollback activation failed",
346
+ "Prior health was not restored",
347
+ "Downgrade requires -AllowDowngrade",
348
+ "Get-AuthenticodeSignature",
349
+ "generator",
350
+ "contractSha256",
351
+ "source-sha256:",
352
+ ];
353
+ const errors = required.filter((marker) => !script.includes(marker)).map((marker) => "missing bootstrap contract marker: " + marker);
354
+ const encoded = script.match(/FromBase64String\('([^']+)'\)/)?.[1];
355
+ let contract = {};
356
+ try { contract = encoded ? JSON.parse(Buffer.from(encoded, "base64").toString()) : {}; } catch { errors.push("bootstrap contract is not valid encoded JSON"); }
357
+ const { contractSha256, ...unsignedContract } = contract;
358
+ if (contract.schemaVersion !== 1 || contract.generator !== "rightkit-direct-bootstrap" || contract.bootstrapVersion == null || contract.repository == null || !/^[a-f0-9]{64}$/.test(contractSha256 ?? "") || contractSha256 !== createHash("sha256").update(canonicalJson(unsignedContract)).digest("hex")) errors.push("bootstrap encoded contract is incomplete or tampered");
359
+ try {
360
+ const acceptedManifestSigners = Object.entries(contract.acceptedManifestSigners ?? {}).map(([id, signer]) => ({ id, ...signer }));
361
+ const regenerated = renderPowerShellBootstrap({
362
+ product: contract.product,
363
+ repository: contract.repository,
364
+ bootstrapVersion: contract.bootstrapVersion,
365
+ acceptedManifestSigners,
366
+ installRootSubdir: contract.installRootSubdir,
367
+ executablePath: contract.executablePath,
368
+ activationArgs: contract.activationArgs,
369
+ statusArgs: contract.statusArgs,
370
+ healthAssertions: contract.healthAssertions,
371
+ preflightArgs: contract.preflightArgs,
372
+ });
373
+ if (regenerated !== script) errors.push("bootstrap source does not match canonical generated output");
374
+ } catch {
375
+ errors.push("bootstrap contract cannot be regenerated");
376
+ }
377
+ const sourceMarker = script.match(/# source-sha256:([a-f0-9]{64})\r?\n?$/);
378
+ const markerOffset = script.lastIndexOf("# source-sha256:");
379
+ if (!sourceMarker || markerOffset < 0 || createHash("sha256").update(script.slice(0, markerOffset)).digest("hex") !== sourceMarker[1]) errors.push("bootstrap source digest is missing or tampered");
380
+ if (expected.product && contract.product !== expected.product) errors.push("bootstrap product identity missing");
381
+ if (expected.acceptedSignerIds) for (const id of expected.acceptedSignerIds) if (!Object.hasOwn(contract.acceptedManifestSigners ?? {}, id)) errors.push("accepted manifest signer missing: " + id);
382
+ return { valid: errors.length === 0, errors };
383
+ }
384
+
385
+ export function planBootstrapPublication({ product, bootstrapVersion, scriptPath, stableKey = `${product}/install.ps1` }) {
386
+ if (!/^[a-z][a-z0-9-]+$/.test(product ?? "")) throw new Error("invalid bootstrap product");
387
+ if (!VERSION.test(bootstrapVersion)) throw new Error("invalid bootstrap version");
388
+ if (stableKey !== `${product}/install.ps1`) throw new Error("bootstrap stable key must be product/install.ps1");
389
+ const script = statSync(scriptPath);
390
+ if (!script.isFile() || script.size < 1 || script.size > MAX_BOOTSTRAP_BYTES || scriptPath.split(/[\\/]/).at(-1) !== "install.ps1") throw new Error("bootstrap must be one small install.ps1 file");
391
+ const validation = validatePowerShellBootstrap(readFileSync(scriptPath, "utf8"), { product });
392
+ if (!validation.valid) throw new Error("bootstrap script violates generated RightRelease contract: " + validation.errors.join("; "));
393
+ const versionedKey = `${product}/versions/${bootstrapVersion}/install.ps1`;
394
+ return {
395
+ product,
396
+ bootstrapVersion,
397
+ bucket: "rightapps-downloads",
398
+ stableKey,
399
+ versionedKey,
400
+ scriptPath: resolve(scriptPath),
401
+ sha256: sha256File(scriptPath),
402
+ };
403
+ }
404
+
405
+ export function createWranglerR2Client({ commandRunner = spawnSync, wranglerRunner = ["pnpm", ["dlx", "wrangler@4"]] } = {}) {
406
+ const run = (args) => {
407
+ const [command, prefix] = wranglerRunner;
408
+ const result = commandRunner(command, [...prefix, "r2", "object", ...args, "--remote"], { encoding: "utf8", windowsHide: true });
409
+ if (result?.status !== 0) throw new Error(`R2 operation failed: ${String(result?.stderr ?? "").trim()}`);
410
+ };
411
+ return {
412
+ put(bucket, key, path) { run(["put", `${bucket}/${key}`, "--file", path]); },
413
+ get(bucket, key, path) { run(["get", `${bucket}/${key}`, "--file", path]); },
414
+ };
415
+ }
416
+
417
+ export function publishBootstrapPlan(plan, r2, { verificationPath }) {
418
+ validateBootstrapPublicationPlan(plan);
419
+ r2.put(plan.bucket, plan.versionedKey, plan.scriptPath);
420
+ r2.get(plan.bucket, plan.versionedKey, verificationPath);
421
+ if (sha256File(verificationPath) !== plan.sha256) throw new Error("immutable bootstrap remote verification failed");
422
+ r2.put(plan.bucket, plan.stableKey, plan.scriptPath);
423
+ r2.get(plan.bucket, plan.stableKey, verificationPath);
424
+ if (sha256File(verificationPath) !== plan.sha256) throw new Error("stable bootstrap remote verification failed");
425
+ return { ...plan, verified: true };
426
+ }
427
+
428
+ export function validateBootstrapPublicationPlan(plan) {
429
+ if (!plan || !/^[a-z][a-z0-9-]+$/.test(plan.product ?? "")) throw new Error("bootstrap publication product is invalid");
430
+ if (!VERSION.test(plan.bootstrapVersion ?? "")) throw new Error("bootstrap publication version is invalid");
431
+ if (plan.bucket !== "rightapps-downloads") throw new Error("bootstrap publication bucket is not branded public R2");
432
+ if (plan.stableKey !== `${plan.product}/install.ps1`) throw new Error("bootstrap stable key is outside product boundary");
433
+ if (plan.versionedKey !== `${plan.product}/versions/${plan.bootstrapVersion}/install.ps1`) throw new Error("bootstrap versioned key is outside product boundary");
434
+ if (String(plan.stableKey).includes("..") || String(plan.versionedKey).includes("..")) throw new Error("bootstrap key traversal is forbidden");
435
+ return plan;
436
+ }
437
+
438
+ export async function runInstallTransaction({
439
+ targetVersion,
440
+ currentVersion,
441
+ allowDowngrade = false,
442
+ stage,
443
+ verify,
444
+ preflight = async () => {},
445
+ snapshotIntegrations,
446
+ switchCurrent,
447
+ activate,
448
+ verifyHealth,
449
+ restoreCurrent = async () => {},
450
+ restoreIntegrations = async () => {},
451
+ cleanup = async () => {},
452
+ }) {
453
+ if (!VERSION.test(targetVersion)) throw new Error("invalid target version");
454
+ if (currentVersion && !allowDowngrade && targetVersion.localeCompare(currentVersion, undefined, { numeric: true }) < 0) {
455
+ throw new Error("downgrade requires explicit allowDowngrade");
456
+ }
457
+ const integrationJournal = await snapshotIntegrations();
458
+ let staged;
459
+ let switched = false;
460
+ try {
461
+ staged = await stage();
462
+ await verify(staged);
463
+ await preflight(staged);
464
+ // switchCurrent is a compound operation (detach prior, install next). It
465
+ // may throw after changing state, so recovery must run even when its
466
+ // promise rejects before returning.
467
+ switched = true;
468
+ await switchCurrent(staged);
469
+ await activate(staged);
470
+ const health = await verifyHealth(staged);
471
+ if (!health || health.status !== "complete") throw new Error("installed product health is incomplete");
472
+ return { status: "complete", targetVersion, health };
473
+ } catch (error) {
474
+ try {
475
+ if (switched) await restoreCurrent();
476
+ } finally {
477
+ await restoreIntegrations(integrationJournal);
478
+ }
479
+ await cleanup(staged);
480
+ throw error;
481
+ }
482
+ }