diff --git a/lib/githubApi.js b/lib/githubApi.js index b986af31..715e6928 100644 --- a/lib/githubApi.js +++ b/lib/githubApi.js @@ -34,6 +34,29 @@ async function fetchGitHubJson(pathname) { return response.json() } +async function fetchDesktopReleaseAssetText(assetUrl) { + const expectedPrefix = + 'https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/' + if (typeof assetUrl !== 'string' || !assetUrl.startsWith(expectedPrefix)) { + throw new Error('Desktop release asset URL is outside the release repository') + } + const response = await fetch(assetUrl, { headers: githubHeaders() }) + if (!response.ok) { + throw new Error(`Desktop release asset returned ${response.status}`) + } + return response.text() +} + +async function fetchDesktopTagCommit(tag) { + const data = await fetchGitHubJson( + `/repos/OpenAdaptAI/openadapt-desktop/commits/${encodeURIComponent(tag)}` + ) + if (!data || !/^[0-9a-f]{40}$/.test(data.sha || '')) { + throw new Error(`Desktop tag ${tag} did not resolve to one commit`) + } + return data.sha +} + /** * Fetch current star/fork social proof and throw on failure. * Server-side cache/fallback policy belongs to the caller. @@ -96,40 +119,113 @@ export async function getOpenIssuesByLabel(repository, label) { * "open releases on GitHub" fallback), false when GitHub answered but no * complete prerelease exists yet. */ -export async function getDesktopRelease() { +export async function resolveDesktopRelease( + releases, + { + fetchAssetText = fetchDesktopReleaseAssetText, + fetchTagCommit = fetchDesktopTagCommit, + } = {} +) { const { - DESKTOP_REPO, + DESKTOP_RELEASE_MANIFEST, + desktopReleaseCandidates, desktopReleaseLifecycle, - selectDesktopRelease, - } = await import('../utils/desktopRelease') + isLegacyBetaDesktopRelease, + validateDesktopReleaseChecksums, + validateDesktopReleaseManifest, + } = await import('../utils/desktopRelease.js') + const { createHash } = await import('node:crypto') + for (const selected of desktopReleaseCandidates(releases)) { + const strictLifecycle = desktopReleaseLifecycle(selected) + const lifecycle = + strictLifecycle || + (isLegacyBetaDesktopRelease(selected) ? 'beta' : null) + const assets = (selected.assets || []) + .filter( + (asset) => + asset && + typeof asset.name === 'string' && + typeof asset.browser_download_url === 'string' + ) + .map((asset) => ({ + name: asset.name, + size: typeof asset.size === 'number' ? asset.size : null, + browser_download_url: asset.browser_download_url, + })) + if (strictLifecycle !== 'beta') { + return { + release: { + tag_name: selected.tag_name || null, + name: selected.name || null, + lifecycle, + manifest: null, + assets, + }, + fetchFailed: false, + } + } + try { + const manifestAsset = selected.assets.find( + (asset) => asset.name === DESKTOP_RELEASE_MANIFEST + ) + const checksumAsset = selected.assets.find( + (asset) => asset.name === 'SHA256SUMS' + ) + const [manifestText, checksumText, tagSourceCommit] = + await Promise.all([ + fetchAssetText(manifestAsset?.browser_download_url), + fetchAssetText(checksumAsset?.browser_download_url), + fetchTagCommit(selected.tag_name), + ]) + const manifestData = JSON.parse(manifestText) + const manifest = validateDesktopReleaseManifest( + selected, + manifestData, + tagSourceCommit + ) + const manifestDigest = createHash('sha256') + .update(manifestText) + .digest('hex') + if ( + !manifest || + !validateDesktopReleaseChecksums( + selected, + manifestData, + checksumText, + manifestDigest + ) + ) { + continue + } + return { + release: { + tag_name: selected.tag_name || null, + name: selected.name || null, + lifecycle, + manifest: { + ...manifest, + browser_download_url: + manifestAsset.browser_download_url, + }, + assets, + }, + fetchFailed: false, + } + } catch (err) { + // A malformed or unavailable candidate does not hide an older + // valid strict Beta or the verified transition fallback. + } + } + return { release: null, fetchFailed: false } +} + +export async function getDesktopRelease() { + const { DESKTOP_REPO } = await import('../utils/desktopRelease.js') try { const releases = await fetchGitHubJson( `/repos/${DESKTOP_REPO}/releases?per_page=20` ) - const selected = selectDesktopRelease(releases) - if (!selected) { - return { release: null, fetchFailed: false } - } - return { - release: { - tag_name: selected.tag_name || null, - name: selected.name || null, - lifecycle: desktopReleaseLifecycle(selected), - assets: (selected.assets || []) - .filter( - (asset) => - asset && - typeof asset.name === 'string' && - typeof asset.browser_download_url === 'string' - ) - .map((asset) => ({ - name: asset.name, - size: typeof asset.size === 'number' ? asset.size : null, - browser_download_url: asset.browser_download_url, - })), - }, - fetchFailed: false, - } + return await resolveDesktopRelease(releases) } catch (err) { return { release: null, fetchFailed: true } } diff --git a/pages/download.js b/pages/download.js index 2a81929e..1809a6f6 100644 --- a/pages/download.js +++ b/pages/download.js @@ -95,6 +95,7 @@ export default function DownloadPage({ release, fetchFailed }) { [assets, lifecycle] ) const checksumAsset = assets.find((asset) => asset.name === 'SHA256SUMS') + const releaseManifest = release?.manifest || null return (
@@ -328,6 +329,45 @@ export default function DownloadPage({ release, fetchFailed }) { {version ? ` (${version})` : ''}. Choose the one that matches your machine.

+ {releaseManifest && ( +
+

+ Verified release manifest +

+

+ This release binds {releaseManifest.artifactCount}{' '} + installer files and its CycloneDX SBOM to exact + SHA-256 values. Build source:{' '} + + {releaseManifest.sourceCommit.slice(0, 12)} + + . +

+
+ + Release manifest + + + CycloneDX SBOM + +
+
+ )}
{platformDownloads.map(({ platform, asset }) => (
({ +const url = (name, tag = 'test') => ({ name, size: 1024, - browser_download_url: `https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/test/${name}`, + browser_download_url: `https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/${tag}/${name}`, }) function binaryNames(lifecycle, version = '0.7.0') { @@ -39,20 +45,81 @@ function betaMetadataNames(version = '0.7.0') { } function release(lifecycle, version, publishedAt) { + const tag = `desktop-v${version}` const names = [ ...binaryNames(lifecycle, version), ...(lifecycle === 'Beta' ? betaMetadataNames(version) : []), + ...(lifecycle === 'Beta' + ? [`OpenAdapt-Desktop-${tag}.cyclonedx.json`] + : []), + ...(lifecycle === 'Beta' + ? ['openadapt-desktop-release-manifest.json'] + : []), 'SHA256SUMS', ] return { - tag_name: `desktop-v${version}`, + tag_name: tag, prerelease: true, draft: false, published_at: publishedAt, - assets: names.map(url), + assets: names.map((name) => url(name, tag)), } } +function manifestFor(candidate, sourceCommit = 'a'.repeat(40)) { + const version = candidate.tag_name.slice('desktop-v'.length) + const sbomName = `OpenAdapt-Desktop-${candidate.tag_name}.cyclonedx.json` + return { + schema_version: 1, + lifecycle: 'Beta', + native_tag: candidate.tag_name, + native_version: version, + source_commit: sourceCommit, + verification: { + github_artifact_attestation: 'required', + installer_smoke: 'install, launch, and uninstall', + sha256_manifest: 'SHA256SUMS', + }, + artifacts: binaryNames('Beta', version).map((name) => ({ + name, + platform: name.includes('-macos-') + ? 'macos' + : name.includes('-windows-') + ? 'windows' + : 'linux', + architecture: name.includes('-arm64-') ? 'arm64' : 'x86_64', + signing: name.includes('-adhoc.') + ? 'adhoc' + : name.includes('-windows-') || name.includes('-linux-') + ? 'unsigned' + : 'adhoc', + sha256: 'b'.repeat(64), + })), + sbom: { name: sbomName, format: 'CycloneDX', sha256: 'c'.repeat(64) }, + } +} + +function checksumsFor(candidate, manifest, manifestText) { + const manifestDigest = createHash('sha256') + .update(manifestText) + .digest('hex') + return candidate.assets + .filter((asset) => asset.name !== 'SHA256SUMS') + .map((asset) => { + const described = manifest.artifacts.find( + (artifact) => artifact.name === asset.name + ) + const digest = + asset.name === 'openadapt-desktop-release-manifest.json' + ? manifestDigest + : asset.name === manifest.sbom.name + ? manifest.sbom.sha256 + : described?.sha256 || 'd'.repeat(64) + return `${digest} ${asset.name}` + }) + .join('\n') +} + test('accepts a complete Beta set only with checksums and per-platform provenance', () => { const candidate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') assert.equal(isCompleteDesktopRelease(candidate), true) @@ -64,6 +131,116 @@ test('accepts a complete Beta set only with checksums and per-platform provenanc assert.equal(isCompleteDesktopRelease(candidate), false) }) +test('requires the release manifest for every complete Beta set', () => { + const candidate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + candidate.assets = candidate.assets.filter( + (asset) => asset.name !== 'openadapt-desktop-release-manifest.json' + ) + assert.equal(isCompleteDesktopRelease(candidate), false) + assert.equal(isLegacyBetaDesktopRelease(candidate), true) + assert.equal(selectDesktopRelease([candidate]), candidate) +}) + +test('validates and binds the fetched release manifest to GitHub assets', () => { + const candidate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + const manifest = manifestFor(candidate) + const validated = validateDesktopReleaseManifest( + candidate, + manifest, + 'a'.repeat(40) + ) + assert.equal(validated.artifactCount, 6) + assert.equal(validated.sourceCommit, 'a'.repeat(40)) + assert.equal(validated.sbom.name, manifest.sbom.name) + + const manifestText = JSON.stringify(manifest) + const manifestDigest = createHash('sha256').update(manifestText).digest('hex') + const checksumEntries = checksumsFor(candidate, manifest, manifestText) + assert.equal( + validateDesktopReleaseChecksums( + candidate, + manifest, + checksumEntries, + manifestDigest + ), + true + ) + + assert.equal( + validateDesktopReleaseChecksums( + candidate, + manifest, + checksumEntries.replace(manifestDigest, '0'.repeat(64)), + manifestDigest + ), + false + ) + + manifest.artifacts[1] = { ...manifest.artifacts[0] } + assert.equal( + validateDesktopReleaseManifest(candidate, manifest, 'a'.repeat(40)), + null + ) +}) + +test('rejects duplicate, extra, and combined-signing Beta asset sets', () => { + const duplicate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + duplicate.assets.push({ ...duplicate.assets[0] }) + assert.equal(isCompleteDesktopRelease(duplicate), false) + + const extra = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + extra.assets.push(url('bogus-checksum-asset.bin', extra.tag_name)) + const extraManifest = manifestFor(extra) + const extraManifestText = JSON.stringify(extraManifest) + const extraDigest = createHash('sha256') + .update(extraManifestText) + .digest('hex') + assert.equal( + validateDesktopReleaseManifest(extra, extraManifest, 'a'.repeat(40)), + null + ) + assert.equal( + validateDesktopReleaseChecksums( + extra, + extraManifest, + checksumsFor(extra, extraManifest, extraManifestText), + extraDigest + ), + false + ) + + const combined = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + const prefix = 'OpenAdapt-Desktop-Beta-v0.7.0-windows-x86_64' + combined.assets.push( + url(`${prefix}-authenticode.msi`, combined.tag_name), + url(`${prefix}-authenticode-nsis-setup.exe`, combined.tag_name), + url(`${prefix}-authenticode-metadata.json`, combined.tag_name) + ) + assert.equal(isCompleteDesktopRelease(combined), false) + assert.equal( + validateDesktopReleaseManifest( + combined, + manifestFor(combined), + 'a'.repeat(40) + ), + null + ) +}) + +test('requires the manifest source commit and verification contract to match', () => { + const candidate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + const manifest = manifestFor(candidate) + assert.equal( + validateDesktopReleaseManifest(candidate, manifest, 'f'.repeat(40)), + null + ) + manifest.verification.installer_smoke = 'started once' + assert.equal( + validateDesktopReleaseManifest(candidate, manifest, 'a'.repeat(40)), + null + ) +}) + test('keeps complete legacy Experimental sets discoverable during transition', () => { const candidate = release( 'Experimental', @@ -155,6 +332,86 @@ test('a complete Beta remains primary when a legacy release is newer', () => { assert.equal(selectDesktopRelease([laterLegacy, beta]), beta) }) +test('a pre-manifest Beta remains primary when Experimental is newer', () => { + const beta = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + beta.assets = beta.assets.filter( + (asset) => asset.name !== 'openadapt-desktop-release-manifest.json' + ) + const experimental = release( + 'Experimental', + '0.8.0', + '2026-07-22T12:00:00Z' + ) + assert.deepEqual(desktopReleaseCandidates([experimental, beta]), [ + beta, + experimental, + ]) + assert.equal(selectDesktopRelease([experimental, beta]), beta) +}) + +test('a malformed newest strict Beta falls back to the next valid Beta', async () => { + const current = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + const malformed = release('Beta', '0.8.0', '2026-07-22T12:00:00Z') + const manifests = new Map( + [current, malformed].map((candidate) => { + const manifest = manifestFor(candidate) + if (candidate === malformed) { + manifest.native_tag = 'desktop-v9.9.9' + } + const text = JSON.stringify(manifest) + return [ + candidate.tag_name, + { + manifest, + text, + checksums: checksumsFor(candidate, manifest, text), + }, + ] + }) + ) + const fetchAssetText = async (assetUrl) => { + const tag = [...manifests.keys()].find((value) => + assetUrl.includes(`/download/${value}/`) + ) + if (assetUrl.endsWith('/openadapt-desktop-release-manifest.json')) { + return manifests.get(tag).text + } + if (assetUrl.endsWith('/SHA256SUMS')) { + return manifests.get(tag).checksums + } + throw new Error('unexpected asset') + } + const result = await resolveDesktopRelease([malformed, current], { + fetchAssetText, + fetchTagCommit: async () => 'a'.repeat(40), + }) + assert.equal(result.fetchFailed, false) + assert.equal(result.release.tag_name, current.tag_name) + assert.equal(result.release.manifest.sourceCommit, 'a'.repeat(40)) +}) + +test('a malformed strict Beta preserves the current pre-manifest Beta', async () => { + const current = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + current.assets = current.assets.filter( + (asset) => asset.name !== 'openadapt-desktop-release-manifest.json' + ) + const malformed = release('Beta', '0.8.0', '2026-07-22T12:00:00Z') + const manifest = manifestFor(malformed) + manifest.source_commit = 'f'.repeat(40) + const manifestText = JSON.stringify(manifest) + const result = await resolveDesktopRelease([malformed, current], { + fetchAssetText: async (assetUrl) => + assetUrl.endsWith('/SHA256SUMS') + ? checksumsFor(malformed, manifest, manifestText) + : manifestText, + fetchTagCommit: async () => 'a'.repeat(40), + }) + assert.equal(result.fetchFailed, false) + assert.equal(result.release.tag_name, current.tag_name) + assert.equal(result.release.lifecycle, 'beta') + assert.equal(result.release.manifest, null) +}) + test('platform selection stays in the chosen release lifecycle', () => { const assets = [ ...release('Experimental', '0.6.2', '2026-07-20T12:00:00Z').assets, diff --git a/utils/desktopRelease.js b/utils/desktopRelease.js index b8e5d817..27d5359b 100644 --- a/utils/desktopRelease.js +++ b/utils/desktopRelease.js @@ -80,12 +80,25 @@ const REQUIRED_ASSETS = [ // build. Existing Experimental releases predate that contract and remain // discoverable during the transition, but a new Beta set is never accepted // without all four metadata records and the checksum manifest. -const BETA_PROVENANCE_ASSETS = [ - /^-macos-arm64-(?:adhoc|developer-id-notarized)-metadata\.json$/i, - /^-macos-x86_64-(?:adhoc|developer-id-notarized)-metadata\.json$/i, - /^-windows-x86_64-(?:unsigned|authenticode)-metadata\.json$/i, - /^-linux-x86_64-unsigned-metadata\.json$/i, -] +export const DESKTOP_RELEASE_MANIFEST = + 'openadapt-desktop-release-manifest.json' + +const RELEASE_VERIFICATION = { + sha256_manifest: 'SHA256SUMS', + github_artifact_attestation: 'required', + installer_smoke: 'install, launch, and uninstall', +} + +function hasExactVerification(value) { + return ( + value && + Object.keys(value).sort().join(',') === + 'github_artifact_attestation,installer_smoke,sha256_manifest' && + Object.entries(RELEASE_VERIFICATION).every( + ([key, expected]) => value[key] === expected + ) + ) +} function hasDownloadUrl(asset) { return Boolean( @@ -96,6 +109,65 @@ function hasDownloadUrl(asset) { ) } +function exactBetaAssetNames(release, requireManifest) { + if ( + !release || + release.draft || + release.prerelease !== true || + !DESKTOP_TAG.test(release.tag_name || '') || + !Array.isArray(release.assets) || + !release.assets.every(hasDownloadUrl) + ) { + return null + } + const names = release.assets.map((asset) => asset.name) + const observed = new Set(names) + if (observed.size !== names.length) return null + + const version = release.tag_name.slice('desktop-v'.length) + const prefix = `OpenAdapt-Desktop-Beta-v${version}` + const oneMode = (modes, nameForMode) => { + const matches = modes.filter((mode) => observed.has(nameForMode(mode))) + return matches.length === 1 ? matches[0] : null + } + const macosArmMode = oneMode( + ['adhoc', 'developer-id-notarized'], + (mode) => `${prefix}-macos-arm64-${mode}.dmg` + ) + const macosX64Mode = oneMode( + ['adhoc', 'developer-id-notarized'], + (mode) => `${prefix}-macos-x86_64-${mode}.dmg` + ) + const windowsMode = oneMode( + ['unsigned', 'authenticode'], + (mode) => `${prefix}-windows-x86_64-${mode}.msi` + ) + if (!macosArmMode || !macosX64Mode || !windowsMode) return null + + const expected = new Set([ + `${prefix}-macos-arm64-${macosArmMode}.dmg`, + `${prefix}-macos-arm64-${macosArmMode}-metadata.json`, + `${prefix}-macos-x86_64-${macosX64Mode}.dmg`, + `${prefix}-macos-x86_64-${macosX64Mode}-metadata.json`, + `${prefix}-windows-x86_64-${windowsMode}.msi`, + `${prefix}-windows-x86_64-${windowsMode}-nsis-setup.exe`, + `${prefix}-windows-x86_64-${windowsMode}-metadata.json`, + `${prefix}-linux-x86_64-unsigned.AppImage`, + `${prefix}-linux-x86_64-unsigned.deb`, + `${prefix}-linux-x86_64-unsigned-metadata.json`, + `OpenAdapt-Desktop-${release.tag_name}.cyclonedx.json`, + 'SHA256SUMS', + ...(requireManifest ? [DESKTOP_RELEASE_MANIFEST] : []), + ]) + if ( + expected.size !== observed.size || + [...expected].some((name) => !observed.has(name)) + ) { + return null + } + return expected +} + export function assetForPlatform(assets, platform, preferredLifecycle = null) { return assets .filter(hasDownloadUrl) @@ -113,7 +185,11 @@ export function assetForPlatform(assets, platform, preferredLifecycle = null) { })[0] } -function isCompleteDesktopReleaseForLifecycle(release, lifecycle) { +function isCompleteDesktopReleaseForLifecycle( + release, + lifecycle, + requireBetaManifest = true +) { if ( !release || release.draft || @@ -124,14 +200,15 @@ function isCompleteDesktopReleaseForLifecycle(release, lifecycle) { return false } + if (lifecycle === 'beta') { + return exactBetaAssetNames(release, requireBetaManifest) !== null + } + const version = release.tag_name.slice('desktop-v'.length) const expectedPrefix = `OpenAdapt-Desktop-${RELEASE_ASSET_FAMILIES[lifecycle]}-v${version}-` const assets = release.assets.filter(hasDownloadUrl) const hasChecksums = assets.some((asset) => asset.name === 'SHA256SUMS') - const required = - lifecycle === 'beta' - ? [...REQUIRED_ASSETS, ...BETA_PROVENANCE_ASSETS] - : REQUIRED_ASSETS + const required = REQUIRED_ASSETS return ( hasChecksums && required.every((pattern) => @@ -146,6 +223,143 @@ function isCompleteDesktopReleaseForLifecycle(release, lifecycle) { ) } +export function isLegacyBetaDesktopRelease(release) { + return ( + isCompleteDesktopReleaseForLifecycle(release, 'beta', false) && + !release.assets.some( + (asset) => + hasDownloadUrl(asset) && + asset.name === DESKTOP_RELEASE_MANIFEST + ) + ) +} + +export function validateDesktopReleaseManifest( + release, + manifest, + tagSourceCommit +) { + if ( + !release || + !manifest || + manifest.schema_version !== 1 || + manifest.lifecycle !== 'Beta' || + manifest.native_tag !== release.tag_name || + manifest.native_version !== release.tag_name?.slice('desktop-v'.length) || + !/^[0-9a-f]{40}$/.test(tagSourceCommit || '') || + manifest.source_commit !== tagSourceCommit || + !hasExactVerification(manifest.verification) || + !Array.isArray(manifest.artifacts) + ) { + return null + } + if (!exactBetaAssetNames(release, true)) return null + const releaseAssets = new Map( + (release.assets || []).filter(hasDownloadUrl).map((asset) => [asset.name, asset]) + ) + const expectedInstallerNames = new Set( + [...releaseAssets.keys()].filter((name) => + DESKTOP_PLATFORMS.some((platform) => platform.match(name)) + ) + ) + const observed = new Set() + for (const artifact of manifest.artifacts) { + const expectedPlatform = artifact?.name?.includes('-macos-') + ? 'macos' + : artifact?.name?.includes('-windows-') + ? 'windows' + : artifact?.name?.includes('-linux-') + ? 'linux' + : null + const expectedArchitecture = artifact?.name?.includes('-arm64-') + ? 'arm64' + : artifact?.name?.includes('-x86_64-') + ? 'x86_64' + : null + const expectedSigning = artifact?.name?.match( + /-(adhoc|developer-id-notarized|unsigned|authenticode)(?:\.|-nsis-setup\.exe$)/ + )?.[1] + if ( + !artifact || + typeof artifact.name !== 'string' || + observed.has(artifact.name) || + !expectedInstallerNames.has(artifact.name) || + !/^[0-9a-f]{64}$/.test(artifact.sha256 || '') || + artifact.platform !== expectedPlatform || + artifact.architecture !== expectedArchitecture || + artifact.signing !== expectedSigning + ) { + return null + } + observed.add(artifact.name) + } + if ( + observed.size !== expectedInstallerNames.size || + [...expectedInstallerNames].some((name) => !observed.has(name)) + ) { + return null + } + const expectedSbomName = `OpenAdapt-Desktop-${release.tag_name}.cyclonedx.json` + if ( + !manifest.sbom || + Object.keys(manifest.sbom).sort().join(',') !== 'format,name,sha256' || + manifest.sbom?.name !== expectedSbomName || + manifest.sbom?.format !== 'CycloneDX' || + !/^[0-9a-f]{64}$/.test(manifest.sbom?.sha256 || '') || + !releaseAssets.has(expectedSbomName) + ) { + return null + } + return { + sourceCommit: manifest.source_commit, + artifactCount: observed.size, + sbom: { + ...manifest.sbom, + browser_download_url: + releaseAssets.get(expectedSbomName).browser_download_url, + }, + } +} + +export function validateDesktopReleaseChecksums( + release, + manifest, + checksumText, + manifestDigest +) { + if ( + !exactBetaAssetNames(release, true) || + !/^[0-9a-f]{64}$/.test(manifestDigest || '') || + typeof checksumText !== 'string' + ) { + return false + } + const checksums = new Map() + for (const line of checksumText.split('\n').filter(Boolean)) { + const match = line.match(/^([0-9a-f]{64}) ([^/\\]+)$/) + if (!match || checksums.has(match[2])) return false + checksums.set(match[2], match[1]) + } + const expectedNames = new Set( + (release.assets || []) + .filter(hasDownloadUrl) + .map((asset) => asset.name) + .filter((name) => name !== 'SHA256SUMS') + ) + if ( + checksums.size !== expectedNames.size || + [...expectedNames].some((name) => !checksums.has(name)) || + checksums.get(DESKTOP_RELEASE_MANIFEST) !== manifestDigest + ) { + return false + } + return ( + manifest.artifacts.every( + (artifact) => checksums.get(artifact.name) === artifact.sha256 + ) && checksums.get(manifest.sbom.name) === manifest.sbom.sha256 + ) +} + export function desktopReleaseLifecycle(release) { return ( RELEASE_LIFECYCLES.find((lifecycle) => @@ -159,40 +373,47 @@ export function isCompleteDesktopRelease(release) { } export function selectDesktopRelease(releases) { - if (!Array.isArray(releases)) return null - const complete = releases.filter(isCompleteDesktopRelease) - if (complete.length === 0) return null - - // Once a complete Beta exists, legacy Experimental compatibility releases - // can never become primary again—even if one is published later. This - // makes the lifecycle transition monotonic while retaining the newest - // complete Experimental release as a fallback before Beta is available. - const beta = complete.filter( - (release) => desktopReleaseLifecycle(release) === 'beta' - ) - const candidates = beta.length > 0 ? beta : complete + return desktopReleaseCandidates(releases)[0] || null +} - // The GitHub endpoint is normally newest-first, but select by publication - // metadata so a stable release interleaved in the response or a changed - // API ordering cannot make the download page advertise an older desktop - // prerelease. - return candidates.reduce((latest, candidate) => { +function newestFirst(candidates) { + return [...candidates].sort((left, right) => { const latestTime = Date.parse( - latest.published_at || latest.created_at || '' + left.published_at || left.created_at || '' ) const candidateTime = Date.parse( - candidate.published_at || candidate.created_at || '' + right.published_at || right.created_at || '' ) - if ( - Number.isFinite(candidateTime) && - (!Number.isFinite(latestTime) || candidateTime > latestTime) - ) { - return candidate + if (!Number.isFinite(latestTime) && !Number.isFinite(candidateTime)) { + return 0 } - return latest + if (!Number.isFinite(latestTime)) return 1 + if (!Number.isFinite(candidateTime)) return -1 + return candidateTime - latestTime }) } +export function desktopReleaseCandidates(releases) { + if (!Array.isArray(releases)) return [] + const strictBeta = releases.filter( + (release) => desktopReleaseLifecycle(release) === 'beta' + ) + const legacyBeta = releases.filter(isLegacyBetaDesktopRelease) + const experimental = releases.filter( + (release) => desktopReleaseLifecycle(release) === 'experimental' + ) + + // Lifecycle priority is independent of publication time. A legacy Beta is + // therefore never replaced by a newer Experimental release. Every strict + // Beta remains ahead of both transition fallbacks, so the server can try + // the next strict candidate if the newest manifest content is malformed. + return [ + ...newestFirst(strictBeta), + ...newestFirst(legacyBeta), + ...newestFirst(experimental), + ] +} + // Compatibility exports for consumers that still use the old names. Their // behavior intentionally includes both Beta and legacy Experimental releases, // avoiding a flag day while callers migrate to the lifecycle-neutral names.