Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
232 lines
8.3 KiB
JavaScript
232 lines
8.3 KiB
JavaScript
/*
|
|
* 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',
|
|
});
|
|
export const DESKTOP_NIGHTLY_UPDATE_PROVIDER = Object.freeze({
|
|
provider: 'github',
|
|
owner: 'apache',
|
|
repo: 'maka',
|
|
channel: 'dev',
|
|
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,
|
|
{ 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;
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
);
|
|
}
|