blob: 0bd56eb85c73f9cb78ca0d21aa917abdd80f34c4 [file]
/*
* 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, writeFile } 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',
});
export const DESKTOP_NIGHTLY_UPDATE_PROVIDER = Object.freeze({
provider: 'github',
owner: 'apache',
repo: 'maka',
channel: 'dev',
updaterCacheDirName: '@makadesktop-updater',
});
/**
* electron-builder suffixes the Linux feed with the architecture for everything
* except x64, so the two Linux architectures never share a feed the way the two
* macOS ones do.
*/
export function linuxUpdateMetadataName(arch, isNightly) {
const channel = isNightly ? 'dev' : 'latest';
return arch === 'x64' ? `${channel}-linux.yml` : `${channel}-linux-${arch}.yml`;
}
/**
* The payloads electron-builder writes a `<file>.blockmap` beside. Only the
* archive and NSIS targets call `createBlockmap`, which writes that sidecar; the
* AppImage calls `appendBlockmap`, which puts the block map inside the AppImage,
* and fpm targets build none. Naming the two that have one keeps a payload with
* no sidecar from silently skipping the check.
*/
const SIDECAR_BLOCKMAP_EXTENSIONS = Object.freeze(['.exe', '.zip']);
/** 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,
{ channel = 'release', 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 });
}
const expected =
channel === 'nightly' ? DESKTOP_NIGHTLY_UPDATE_PROVIDER : DESKTOP_UPDATE_PROVIDER;
requireExactObject(configuration, expected, 'Packaged update configuration');
return configuration;
}
/**
* macOS carries every architecture in one feed, and electron-updater picks its
* payload out of that one `files` list. Each architecture is packaged on a
* runner of its own, so each build writes a feed naming only its own zip;
* publishing either alone would offer one architecture an update it cannot
* install. The first document is the primary: its `path` and top-level digest
* survive the merge, and the rest contribute only their payloads.
*/
function mergeDesktopUpdateFeedDocuments(documents) {
const [primary, ...rest] = documents;
if (!primary) {
throw new Error('Desktop update feed merge requires at least one feed');
}
for (const document of rest) {
if (document?.version !== primary.version) {
throw new Error(
`Desktop update feeds disagree on version: ${JSON.stringify(primary.version)} and ${JSON.stringify(document?.version)}`,
);
}
}
const files = documents.flatMap((document) => document.files ?? []);
const urls = files.map((file) => file?.url);
if (new Set(urls).size !== urls.length) {
throw new Error(`Desktop update feeds advertise the same payload twice: ${urls.join(', ')}`);
}
return { ...primary, files };
}
export async function mergeDesktopUpdateFeeds({ sourcePaths, outputPath }) {
const documents = await Promise.all(sourcePaths.map((path) => readYaml(path)));
const merged = mergeDesktopUpdateFeedDocuments(documents);
const { stringify } = await import('yaml');
await writeFile(outputPath, stringify(merged), 'utf8');
return merged;
}
async function sha512Base64(path) {
return new Promise((resolvePromise, reject) => {
const hash = createHash('sha512');
const stream = createReadStream(path);
stream.once('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.once('end', () => resolvePromise(hash.digest('base64')));
});
}
/**
* Validates the update metadata against the bytes that will be published.
* One feed carries every payload a client on that platform may be offered —
* both macOS architectures, or the AppImage and the deb — so the caller names
* all of them and anything else in the feed is an unverified update path.
*/
export async function verifyDesktopUpdateArtifacts({
directory,
metadataName,
version,
artifactNames,
}) {
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}`,
);
}
const expected = [...artifactNames].sort();
const advertised = (metadata.files ?? []).map((file) => file?.url).sort();
if (JSON.stringify(advertised) !== JSON.stringify(expected)) {
throw new Error(
`${metadataName} advertises ${JSON.stringify(advertised)}, expected ${JSON.stringify(expected)}`,
);
}
if (!artifactNames.includes(metadata.path)) {
throw new Error(
`${metadataName} points at ${JSON.stringify(metadata.path)}, expected one of ${JSON.stringify(expected)}`,
);
}
// The top-level digest belongs to the payload named by `path`; an updater
// that trusts it while downloading a different file would verify nothing.
const primary = metadata.files.find((file) => file.url === metadata.path);
if (primary.sha512 !== metadata.sha512) {
throw new Error(`${metadataName} has inconsistent payload identity for ${metadata.path}`);
}
for (const file of metadata.files) {
const artifactPath = join(directory, file.url);
const artifact = await stat(artifactPath);
if (!artifact.isFile()) {
throw new Error(`Desktop update payload is not a file: ${artifactPath}`);
}
if (file.sha512 !== (await sha512Base64(artifactPath))) {
throw new Error(`${metadataName} sha512 does not match ${file.url}`);
}
if (file.size !== artifact.size) {
throw new Error(
`${metadataName} records ${file.url} size ${JSON.stringify(file.size)}, expected ${artifact.size}`,
);
}
if (!SIDECAR_BLOCKMAP_EXTENSIONS.some((extension) => file.url.endsWith(extension))) continue;
const blockmapPath = join(directory, `${file.url}.blockmap`);
if (!(await stat(blockmapPath)).isFile()) {
throw new Error(`Desktop update blockmap is not a file: ${blockmapPath}`);
}
}
return { artifactNames, 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),
);
}