blob: 35b50047322ea0cfa8489084dfa2d8b8d467f6b7 [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 } 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),
);
}