| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { createHash } from 'node:crypto'; |
| import { createReadStream } from 'node:fs'; |
| import { readFile, stat } from 'node:fs/promises'; |
| import { createServer } from 'node:http'; |
| import { join } from 'node:path'; |
| import { parseProductReleaseVersion } from './release-version.mjs'; |
| |
| export const DESKTOP_UPDATE_PROVIDER = Object.freeze({ |
| provider: 'github', |
| owner: 'apache', |
| repo: 'maka', |
| updaterCacheDirName: '@makadesktop-updater', |
| }); |
| |
| /** A stable successor lets stable, alpha, and beta candidates use one feed contract. */ |
| export function bumpedAutoupdateVersion(candidateVersion) { |
| const { core, prerelease } = parseProductReleaseVersion(candidateVersion); |
| const [major, minor, patch] = core; |
| return prerelease.length > 0 ? `${major}.${minor}.${patch}` : `${major}.${minor}.${patch + 1n}`; |
| } |
| |
| async function readYaml(path, read = readFile) { |
| const [source, { parse }] = await Promise.all([read(path, 'utf8'), import('yaml')]); |
| return parse(source); |
| } |
| |
| function requireExactObject(actual, expected, subject) { |
| const exact = |
| actual && |
| typeof actual === 'object' && |
| !Array.isArray(actual) && |
| Object.keys(actual).length === Object.keys(expected).length && |
| Object.entries(expected).every(([key, value]) => actual[key] === value); |
| if (!exact) { |
| throw new Error( |
| `${subject} is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, |
| ); |
| } |
| } |
| |
| /** Proves that a packaged client points at the one production release authority. */ |
| export async function assertPackagedUpdateConfiguration(resourcesPath, { read = readFile } = {}) { |
| const path = join(resourcesPath, 'app-update.yml'); |
| let configuration; |
| try { |
| configuration = await readYaml(path, read); |
| } catch (error) { |
| throw new Error(`Packaged update configuration is unreadable: ${path}`, { cause: error }); |
| } |
| requireExactObject(configuration, DESKTOP_UPDATE_PROVIDER, 'Packaged update configuration'); |
| return configuration; |
| } |
| |
| /** |
| * Validates the update metadata against the bytes that will be published. |
| * The release is single-platform and single-architecture, so accepting extra |
| * payloads here would create an unverified update path. |
| */ |
| export async function verifyDesktopUpdateArtifacts({ |
| directory, |
| metadataName, |
| version, |
| artifactName, |
| }) { |
| const metadataPath = join(directory, metadataName); |
| let metadata; |
| try { |
| metadata = await readYaml(metadataPath); |
| } catch (error) { |
| throw new Error(`Desktop update metadata is unreadable: ${metadataPath}`, { cause: error }); |
| } |
| if (metadata?.version !== version) { |
| throw new Error( |
| `${metadataName} advertises version ${JSON.stringify(metadata?.version)}, expected ${version}`, |
| ); |
| } |
| if (metadata.path !== artifactName || metadata.files?.length !== 1) { |
| throw new Error(`${metadataName} must advertise only ${artifactName}`); |
| } |
| const file = metadata.files[0]; |
| if (file?.url !== artifactName || file.sha512 !== metadata.sha512) { |
| throw new Error(`${metadataName} has inconsistent payload identity for ${artifactName}`); |
| } |
| const artifactPath = join(directory, artifactName); |
| const artifact = await stat(artifactPath); |
| if (!artifact.isFile()) throw new Error(`Desktop update payload is not a file: ${artifactPath}`); |
| const sha512 = await new Promise((resolvePromise, reject) => { |
| const hash = createHash('sha512'); |
| const stream = createReadStream(artifactPath); |
| stream.once('error', reject); |
| stream.on('data', (chunk) => hash.update(chunk)); |
| stream.once('end', () => resolvePromise(hash.digest('base64'))); |
| }); |
| if (metadata.sha512 !== sha512 || file.sha512 !== sha512) { |
| throw new Error(`${metadataName} sha512 does not match ${artifactName}`); |
| } |
| if (file.size !== artifact.size) { |
| throw new Error( |
| `${metadataName} records ${artifactName} size ${JSON.stringify(file.size)}, expected ${artifact.size}`, |
| ); |
| } |
| const blockmapPath = join(directory, `${artifactName}.blockmap`); |
| if (!(await stat(blockmapPath)).isFile()) { |
| throw new Error(`Desktop update blockmap is not a file: ${blockmapPath}`); |
| } |
| return { artifactName, metadata, metadataName, version }; |
| } |
| |
| /** |
| * Exact loopback replica of the generic feed used by the packaged E2E tests. |
| * Mapped-but-absent files intentionally return 404: that is how the updater |
| * probes an unavailable previous blockmap before falling back to a full file. |
| */ |
| export async function startDesktopUpdateFeed(files) { |
| const requests = []; |
| let unexpectedRequests = 0; |
| const bodies = new Map(); |
| for (const [name, filePath] of files) { |
| try { |
| bodies.set(`/${name}`, await readFile(filePath)); |
| } catch (error) { |
| if (error?.code !== 'ENOENT') throw error; |
| } |
| } |
| const knownPaths = new Set([...files.keys()].map((name) => `/${name}`)); |
| const server = createServer((request, response) => { |
| const method = request.method ?? 'GET'; |
| const target = request.url ?? '/'; |
| const queryIndex = target.indexOf('?'); |
| const path = queryIndex === -1 ? target : target.slice(0, queryIndex); |
| const record = { method, path, target, status: 0 }; |
| requests.push(record); |
| if ((method !== 'GET' && method !== 'HEAD') || !knownPaths.has(path)) { |
| unexpectedRequests += 1; |
| record.status = 404; |
| response.writeHead(404).end(); |
| return; |
| } |
| const body = bodies.get(path); |
| if (body === undefined) { |
| record.status = 404; |
| response.writeHead(404).end(); |
| return; |
| } |
| const range = /^bytes=(\d+)-(\d*)$/u.exec(request.headers.range ?? ''); |
| if (range) { |
| const start = Number(range[1]); |
| const end = range[2] === '' ? body.length - 1 : Math.min(Number(range[2]), body.length - 1); |
| if (start > end || start >= body.length) { |
| record.status = 416; |
| response.writeHead(416, { 'Content-Range': `bytes */${body.length}` }).end(); |
| return; |
| } |
| record.status = 206; |
| response.writeHead(206, { |
| 'Accept-Ranges': 'bytes', |
| 'Content-Length': end - start + 1, |
| 'Content-Range': `bytes ${start}-${end}/${body.length}`, |
| 'Content-Type': 'application/octet-stream', |
| }); |
| response.end(method === 'HEAD' ? undefined : body.subarray(start, end + 1)); |
| return; |
| } |
| record.status = 200; |
| response.writeHead(200, { |
| 'Accept-Ranges': 'bytes', |
| 'Content-Length': body.length, |
| 'Content-Type': 'application/octet-stream', |
| }); |
| response.end(method === 'HEAD' ? undefined : body); |
| }); |
| await new Promise((resolvePromise, reject) => { |
| server.once('error', reject); |
| server.listen(0, '127.0.0.1', resolvePromise); |
| }); |
| const address = server.address(); |
| if (!address || typeof address === 'string') { |
| server.close(); |
| throw new Error('Could not start the loopback update feed.'); |
| } |
| return { |
| url: `http://127.0.0.1:${address.port}`, |
| requests, |
| unexpectedCount: () => unexpectedRequests, |
| close: () => |
| new Promise((resolvePromise) => { |
| server.close(() => resolvePromise()); |
| server.closeAllConnections?.(); |
| }), |
| }; |
| } |
| |
| export function feedServed(feed, name) { |
| return feed.requests.some( |
| (request) => |
| request.method === 'GET' && |
| request.path === `/${name}` && |
| (request.status === 200 || request.status === 206), |
| ); |
| } |