fix(api): only offer desktop builds whose release is published (#2670)

This commit is contained in:
Hampus
2026-09-11 13:17:41 +02:00
committed by GitHub
parent 61a13e1c1a
commit e6ee3b8059
4 changed files with 788 additions and 241 deletions
+302 -239
View File
@@ -17,7 +17,7 @@ import {
StorageObjectRangeNotSatisfiableError,
} from '../infrastructure/IStorageService';
import {Logger} from '../Logger';
import {isJsonRecord, parseJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
import {isJsonRecord, parseJsonRecord} from '../utils/JsonBoundaryUtils';
import {
parseDesktopArtifactScope,
parseDesktopReleaseDescriptor,
@@ -58,6 +58,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
);
}
const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
const MAX_DESKTOP_RELEASE_CANDIDATES = 10;
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
@@ -198,6 +199,14 @@ export type GitHubDesktopReleaseResolution =
| {kind: 'awaiting_release'}
| {kind: 'ready'; location: string};
type DesktopReleaseState = {kind: 'untracked'} | {kind: 'unpublished'} | {kind: 'published'; descriptorText: string};
type ListedDesktopVersion = {
version: string;
pub_date: Date;
files: Map<DesktopFormat, {filename: string; sha256Key: string | null}>;
};
export class DownloadService {
constructor(private readonly storageService: IStorageService) {}
@@ -216,41 +225,28 @@ export class DownloadService {
) {
return {kind: 'not_current'};
}
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
if (descriptorText == null) {
const release = await this.readDesktopReleaseState(scope.channel, manifest.version);
if (release.kind === 'untracked') {
return {kind: 'not_current'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
if (release.kind === 'unpublished') {
return {kind: 'awaiting_release'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonRecord(release.descriptorText));
if (
!descriptor ||
descriptor.channel !== scope.channel ||
descriptor.version !== manifest.version ||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
) {
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
throw new Error(
`Invalid GitHub desktop release descriptor: ${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`,
);
}
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
if (!releaseAsset) {
return {kind: 'not_current'};
}
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
if (marker == null) {
return {kind: 'awaiting_release'};
}
const readiness = parseDesktopReleaseReadiness(marker);
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
if (
!readiness ||
readiness.channel !== descriptor.channel ||
readiness.version !== descriptor.version ||
readiness.release_tag !== descriptor.release_tag ||
readiness.source_sha !== descriptor.source_sha ||
readiness.descriptor_sha256 !== descriptorSha256
) {
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
}
return {
kind: 'ready',
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
@@ -269,18 +265,22 @@ export class DownloadService {
return null;
}
const manifestKey = `${prefix}/manifest.json`;
const releasability = new Map<string, boolean>();
try {
const manifest = await this.readJsonObjectFromStorage(manifestKey);
if (!isDesktopManifest(manifest)) {
return this.resolveLatestDesktopKeyFromObjects(params);
if (
!isDesktopManifest(manifest) ||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
) {
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const entry = manifest.files[params.format];
if (!entry) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const filename = this.extractFilename(entry);
if (filename.trim().length === 0) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const resolvedFilename = await this.resolveManifestFilename({
channel: params.channel,
@@ -291,7 +291,11 @@ export class DownloadService {
test: params.test,
});
if (!resolvedFilename) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const parsed = this.parseVersionFromFilename(resolvedFilename, params.channel, params.plat, params.arch);
if (parsed && parsed.version !== manifest.version) {
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
return this.buildDesktopArtifactKey({
channel: params.channel,
@@ -302,7 +306,7 @@ export class DownloadService {
});
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
throw error;
}
@@ -320,10 +324,14 @@ export class DownloadService {
return null;
}
const manifestKey = `${prefix}/manifest.json`;
const releasability = new Map<string, boolean>();
try {
const manifest = await this.readJsonObjectFromStorage(manifestKey);
if (!isDesktopManifest(manifest)) {
return this.getLatestDesktopVersionFromObjects(params);
if (
!isDesktopManifest(manifest) ||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
) {
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
const result = await this.getLatestDesktopVersionFromManifest(params, manifest);
if (result) {
@@ -331,11 +339,11 @@ export class DownloadService {
}
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return this.getLatestDesktopVersionFromObjects(params);
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
throw error;
}
return this.getLatestDesktopVersionFromObjects(params);
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
async listDesktopVersions(params: {
@@ -351,158 +359,17 @@ export class DownloadService {
versions: Array<VersionInfo>;
hasMore: boolean;
}> {
const basePrefix = desktopArtifactPrefix(params);
if (!basePrefix) {
return {versions: [], hasMore: false};
let listedVersions = await this.listDesktopVersionFiles(params);
if (params.before) {
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.before ?? '') > 0);
}
const prefix = `${basePrefix}/`;
try {
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return {versions: [], hasMore: false};
}
const versionMap = new Map<
string,
{
pub_date: Date;
files: Map<
DesktopFormat,
{
filename: string;
sha256Key: string | null;
}
>;
}
>();
const sha256Files = new Set<string>();
for (const obj of objects) {
if (obj.key.endsWith('.sha256')) {
sha256Files.add(obj.key);
}
}
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed) {
continue;
}
const {version, format} = parsed;
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
if (!versionMap.has(version)) {
versionMap.set(version, {
pub_date: obj.lastModified ?? new Date(),
files: new Map(),
});
}
const entry = versionMap.get(version);
if (entry) {
if (!entry.files.has(format)) {
entry.files.set(format, {filename, sha256Key});
}
if (obj.lastModified && obj.lastModified > entry.pub_date) {
entry.pub_date = obj.lastModified;
}
}
}
const sortedVersions = Array.from(versionMap.keys()).sort(this.compareVersions);
let filteredVersions = sortedVersions;
if (params.before) {
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.before ?? '') > 0);
}
if (params.after) {
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.after ?? '') < 0);
}
const hasMore = filteredVersions.length > params.limit;
const paginatedVersions = filteredVersions.slice(0, params.limit);
const sha256Promises: Array<
Promise<{
key: string;
hash: string | null;
}>
> = [];
for (const version of paginatedVersions) {
const entry = versionMap.get(version);
if (!entry) {
continue;
}
for (const [, fileInfo] of entry.files) {
if (fileInfo.sha256Key) {
sha256Promises.push(
(async () => {
try {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key: fileInfo.sha256Key as string,
});
if (streamResult) {
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
}
} catch {
return {key: fileInfo.sha256Key as string, hash: null};
}
return {key: fileInfo.sha256Key as string, hash: null};
})(),
);
}
}
}
const sha256Results = await Promise.all(sha256Promises);
const sha256Map = new Map<string, string | null>();
for (const result of sha256Results) {
sha256Map.set(result.key, result.hash);
}
const versions: Array<VersionInfo> = [];
for (const version of paginatedVersions) {
const entry = versionMap.get(version);
if (!entry) {
continue;
}
const files: Record<string, VersionFile> = {};
for (const [format, fileInfo] of entry.files) {
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
files[format] = {
url: this.buildDesktopVersionUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version,
format,
baseUrl: params.baseUrl,
test: params.test,
}),
sha256: validSha256,
checksum_url: validSha256
? this.buildDesktopVersionChecksumUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version,
format,
baseUrl: params.baseUrl,
test: params.test,
})
: null,
};
}
versions.push({
version,
pub_date: entry.pub_date.toISOString(),
files,
});
}
return {versions, hasMore};
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return {versions: [], hasMore: false};
}
throw error;
if (params.after) {
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.after ?? '') < 0);
}
return {
versions: await this.buildListedDesktopVersions(params, listedVersions.slice(0, params.limit)),
hasMore: listedVersions.length > params.limit,
};
}
async resolveVersionedDesktopKey(params: {
@@ -558,10 +425,10 @@ export class DownloadService {
}): Promise<DesktopChecksumFile | null> {
const version = await this.getLatestDesktopVersion(params);
const file = version?.files[params.format];
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
if (!version || !file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
const key = await this.resolveLatestDesktopKey(params);
const key = await this.resolveVersionedDesktopKey({...params, version: version.version});
if (!key) {
return null;
}
@@ -586,8 +453,16 @@ export class DownloadService {
if (objectSha256) {
return this.buildDesktopChecksumFile(key, filename, objectSha256);
}
const latest = await this.getLatestDesktopVersion(params);
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
const prefix = desktopArtifactPrefix(params);
if (!prefix) {
return null;
}
const manifest = await this.readOptionalJsonObjectFromStorage(`${prefix}/manifest.json`);
const versionInfo =
isDesktopManifest(manifest) && manifest.version === params.version
? await this.getLatestDesktopVersionFromManifest(params, manifest)
: null;
const file = versionInfo?.files[params.format];
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
@@ -768,6 +643,77 @@ export class DownloadService {
}
}
private async readDesktopReleaseState(channel: DesktopChannel, version: string): Promise<DesktopReleaseState> {
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.json`;
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.ready.json`;
const [descriptorText, markerText] = await Promise.all([
this.readOptionalTextFromStorage(descriptorKey),
this.readOptionalTextFromStorage(markerKey),
]);
if (descriptorText == null) {
return {kind: 'untracked'};
}
if (markerText == null) {
return {kind: 'unpublished'};
}
const readiness = parseDesktopReleaseReadiness(parseJsonRecord(markerText));
if (
!readiness ||
readiness.channel !== channel ||
readiness.version !== version ||
readiness.release_tag !== `fluxer-desktop-${channel}@${version}` ||
readiness.descriptor_sha256 !== createHash('sha256').update(descriptorText).digest('hex')
) {
Logger.error({key: markerKey}, 'Invalid GitHub desktop release readiness marker');
return {kind: 'unpublished'};
}
return {kind: 'published', descriptorText};
}
private async isReleasableDesktopVersion(
params: {channel: DesktopChannel; test?: boolean},
version: string,
releasability: Map<string, boolean>,
): Promise<boolean> {
if (params.test || Config.instance.selfHosted) {
return true;
}
const checked = releasability.get(version);
if (checked !== undefined) {
return checked;
}
let releasable = true;
try {
releasable = (await this.readDesktopReleaseState(params.channel, version)).kind !== 'unpublished';
} catch (error) {
Logger.error({error, channel: params.channel, version}, 'Failed to read desktop release readiness');
}
releasability.set(version, releasable);
return releasable;
}
private async findNewestReleasableDesktopVersion<T extends {version: string}>(
params: {channel: DesktopChannel; test?: boolean},
candidates: ReadonlyArray<T>,
releasability: Map<string, boolean>,
): Promise<T | null> {
const newestCandidates = candidates.slice(0, MAX_DESKTOP_RELEASE_CANDIDATES);
for (const candidate of newestCandidates) {
if (await this.isReleasableDesktopVersion(params, candidate.version, releasability)) {
return candidate;
}
}
const [newest] = newestCandidates;
if (!newest) {
return null;
}
Logger.error(
{channel: params.channel, version: newest.version},
'No recent desktop version has a published release',
);
return newest;
}
private isValidSha256(value: string): boolean {
return /^[a-f0-9]{64}$/u.test(value);
}
@@ -789,7 +735,13 @@ export class DownloadService {
) {
return manifestFilename;
}
return this.findLatestFilenameForRequestedArch(params);
for (const entry of await this.listDesktopVersionFiles(params)) {
const file = entry.files.get(params.format);
if (file) {
return file.filename;
}
}
return null;
}
private async listDesktopArtifacts(prefix: string): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
@@ -826,44 +778,145 @@ export class DownloadService {
return params.filename.toLowerCase().endsWith('.exe');
}
private async findLatestFilenameForRequestedArch(params: LatestFilenameLookupParams): Promise<string | null> {
private async listDesktopVersionFiles(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
test?: boolean;
}): Promise<Array<ListedDesktopVersion>> {
const basePrefix = desktopArtifactPrefix(params);
if (!basePrefix) {
return null;
return [];
}
const prefix = `${basePrefix}/`;
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return null;
try {
const objects = await this.listDesktopArtifacts(prefix);
const versionMap = new Map<string, ListedDesktopVersion>();
const sha256Files = new Set<string>();
for (const obj of objects) {
if (obj.key.endsWith('.sha256')) {
sha256Files.add(obj.key);
}
}
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed) {
continue;
}
const {version, format} = parsed;
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
if (!versionMap.has(version)) {
versionMap.set(version, {
version,
pub_date: obj.lastModified ?? new Date(),
files: new Map(),
});
}
const entry = versionMap.get(version);
if (entry) {
if (!entry.files.has(format)) {
entry.files.set(format, {filename, sha256Key});
}
if (obj.lastModified && obj.lastModified > entry.pub_date) {
entry.pub_date = obj.lastModified;
}
}
}
return Array.from(versionMap.values()).sort((left, right) => this.compareVersions(left.version, right.version));
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return [];
}
throw error;
}
let latestFilename: string | null = null;
let latestVersion: string | null = null;
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.length === 0) {
continue;
}
if (
filename.includes('/') ||
filename.endsWith('.sha256') ||
filename.endsWith('.blockmap') ||
filename.endsWith('.yml') ||
filename === 'manifest.json' ||
filename === 'RELEASES.json' ||
filename === 'releases.json'
) {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed || parsed.format !== params.format) {
continue;
}
if (!latestVersion || this.compareVersions(parsed.version, latestVersion) < 0) {
latestVersion = parsed.version;
latestFilename = filename;
}
private async buildListedDesktopVersions(
params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
},
listedVersions: ReadonlyArray<ListedDesktopVersion>,
): Promise<Array<VersionInfo>> {
const sha256Promises: Array<
Promise<{
key: string;
hash: string | null;
}>
> = [];
for (const entry of listedVersions) {
for (const [, fileInfo] of entry.files) {
if (fileInfo.sha256Key) {
sha256Promises.push(
(async () => {
try {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key: fileInfo.sha256Key as string,
});
if (streamResult) {
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
}
} catch {
return {key: fileInfo.sha256Key as string, hash: null};
}
return {key: fileInfo.sha256Key as string, hash: null};
})(),
);
}
}
}
return latestFilename;
const sha256Results = await Promise.all(sha256Promises);
const sha256Map = new Map<string, string | null>();
for (const result of sha256Results) {
sha256Map.set(result.key, result.hash);
}
const versions: Array<VersionInfo> = [];
for (const entry of listedVersions) {
const files: Record<string, VersionFile> = {};
for (const [format, fileInfo] of entry.files) {
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
files[format] = {
url: this.buildDesktopVersionUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version: entry.version,
format,
baseUrl: params.baseUrl,
test: params.test,
}),
sha256: validSha256,
checksum_url: validSha256
? this.buildDesktopVersionChecksumUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version: entry.version,
format,
baseUrl: params.baseUrl,
test: params.test,
})
: null,
};
}
versions.push({
version: entry.version,
pub_date: entry.pub_date.toISOString(),
files,
});
}
return versions;
}
private escapeRegex(str: string): string {
@@ -1030,16 +1083,25 @@ export class DownloadService {
return key;
}
private async resolveLatestDesktopKeyFromObjects(params: LatestFilenameLookupParams): Promise<string | null> {
const filename = await this.findLatestFilenameForRequestedArch(params);
if (!filename) {
private async resolveLatestDesktopKeyFromObjects(
params: LatestFilenameLookupParams,
releasability: Map<string, boolean>,
): Promise<string | null> {
const listedVersions = await this.listDesktopVersionFiles(params);
const latest = await this.findNewestReleasableDesktopVersion(
params,
listedVersions.filter((entry) => entry.files.has(params.format)),
releasability,
);
const file = latest?.files.get(params.format);
if (!file) {
return null;
}
return this.buildDesktopArtifactKey({
channel: params.channel,
plat: params.plat,
arch: params.arch,
filename,
filename: file.filename,
test: params.test,
});
}
@@ -1189,22 +1251,23 @@ export class DownloadService {
};
}
private async getLatestDesktopVersionFromObjects(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
}): Promise<VersionInfo | null> {
const {versions} = await this.listDesktopVersions({
channel: params.channel,
plat: params.plat,
arch: params.arch,
limit: 1,
baseUrl: params.baseUrl,
test: params.test,
});
return versions[0] ?? null;
private async getLatestDesktopVersionFromObjects(
params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
},
releasability: Map<string, boolean>,
): Promise<VersionInfo | null> {
const listedVersions = await this.listDesktopVersionFiles(params);
const latest = await this.findNewestReleasableDesktopVersion(params, listedVersions, releasability);
if (!latest) {
return null;
}
const [versionInfo] = await this.buildListedDesktopVersions(params, [latest]);
return versionInfo ?? null;
}
private async resolveDesktopFileSha256(params: {
@@ -0,0 +1,337 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
import {describe, expect, it} from 'vitest';
import {getConfig} from '../../Config';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const PREFIX = 'desktop/canary/linux/x64';
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
const RELEASES_PREFIX = 'desktop/canary/github-releases';
const SOURCE_SHA = 'b'.repeat(40);
const V904 = '2026.904.135113';
const V908 = '2026.908.173325';
const V909 = '2026.909.202036';
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
['darwin', 'arm64', 4],
['darwin', 'x64', 4],
['linux', 'arm64', 4],
['linux', 'x64', 4],
['win32', 'arm64', 6],
['win32', 'x64', 6],
];
type StoredObjects = Map<string, string>;
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
const prefix = options.prefix ?? PREFIX;
const filename = appImageFilename(version);
objects.set(`${prefix}/${filename}`, filename);
if (options.checksum !== false) {
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
}
objects.set(
`${prefix}/manifest.json`,
JSON.stringify({
channel: 'canary',
platform: 'linux',
arch: 'x64',
version,
pub_date: '2026-09-08T18:06:00Z',
files: {appimage: {filename, sha256: sha256Hex(filename)}},
}),
);
}
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
const assets = routes.flatMap(([plat, arch, count]) =>
Array.from({length: count}, (_, index) => {
const filename =
plat === 'linux' && arch === 'x64' && index === 0
? appImageFilename(version)
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
return {
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
release_asset: filename,
sha256: sha256Hex(filename),
size: 1,
};
}),
);
const descriptor = JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
assets,
});
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
return descriptor;
}
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
objects.set(
`${RELEASES_PREFIX}/${version}.ready.json`,
JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
descriptor_sha256: sha256Hex(descriptor),
}),
);
}
function releaseBuild(objects: StoredObjects, version: string) {
const descriptor = publishDescriptor(objects, version);
uploadBuild(objects, version);
publishMarker(objects, version, descriptor);
}
function incidentObjects(): StoredObjects {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
return objects;
}
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
const reads: Array<string> = [];
const listings: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
onRead?.(params.key);
const body = objects.get(params.key);
if (body == null) {
return null;
}
const buffer = Buffer.from(body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) => {
listings.push(params.prefix);
return Array.from(objects.keys())
.filter((key) => key.startsWith(params.prefix))
.sort()
.map((key) => ({key}));
},
getObjectMetadata: async (_bucket: string, key: string) =>
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads, listings};
}
async function resolveLatest(service: DownloadService, test?: boolean) {
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
return {version: metadata?.version, key, checksum: checksum?.body};
}
function latestOf(version: string, prefix = PREFIX) {
const filename = appImageFilename(version);
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
}
describe('desktop release readiness', () => {
it('offers a published manifest version after reading only its release state', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
releaseBuild(objects, V909);
const {service, reads, listings} = createService(objects);
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V909}.json`,
`${RELEASES_PREFIX}/${V909}.ready.json`,
]);
expect(listings).toEqual([]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
});
it('falls back to the newest published version while the manifest version awaits its release', async () => {
const {service} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
const {service, reads, listings} = createService(incidentObjects());
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V908}.json`,
`${RELEASES_PREFIX}/${V908}.ready.json`,
`${RELEASES_PREFIX}/${V904}.json`,
`${RELEASES_PREFIX}/${V904}.ready.json`,
`${PREFIX}/${appImageFilename(V904)}.sha256`,
]);
expect(listings).toEqual([`${PREFIX}/`]);
});
it('offers a manifest version that has no release descriptor', async () => {
const objects: StoredObjects = new Map();
uploadBuild(objects, V904);
uploadBuild(objects, V908);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
});
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
publishMarker(objects, V908, 'another descriptor');
const {service} = createService(objects);
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const descriptor = publishDescriptor(
objects,
V908,
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
);
uploadBuild(objects, V908);
publishMarker(objects, V908, descriptor);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
'Invalid GitHub desktop release descriptor',
);
});
it.each([
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
])('offers the manifest version when reading its release %s fails with a storage error', async (_name, failingKey) => {
const {service} = createService(incidentObjects(), (key) => {
if (key === failingKey) {
throw new S3ServiceException({
name: 'SlowDown',
$fault: 'server',
$metadata: {httpStatusCode: 503},
message: 'Please reduce your request rate.',
});
}
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
});
it('still resolves the unpublished version through versioned routes', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {checksum: false});
const {service} = createService(objects);
const params = {...APPIMAGE_PARAMS, version: V908};
const filename = appImageFilename(V908);
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
sha256: sha256Hex(filename),
});
});
it('keeps offering the manifest version on self-hosted instances', async () => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
const {service, reads} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
} finally {
config.instance.selfHosted = originalSelfHosted;
}
});
it('keeps offering the newest test build', async () => {
const objects: StoredObjects = new Map();
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
const {service, reads} = createService(objects);
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
});
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
const objects = incidentObjects();
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
publishMarker(objects, V909, descriptor);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
kind: 'ready',
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
});
});
it('offers the newest version when ten unpublished versions hide a published one', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const newest = '2026.908.170009';
for (let build = 0; build < 10; build++) {
const version = `2026.908.${170000 + build}`;
publishDescriptor(objects, version);
uploadBuild(objects, version);
}
const {service, reads} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
});
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
const objects = incidentObjects();
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
let manifestReads = 0;
const {service} = createService(objects, (key) => {
if (key !== `${PREFIX}/manifest.json`) {
return;
}
manifestReads += 1;
if (manifestReads === 2) {
publishMarker(objects, V909, descriptor);
}
});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
expect(checksum?.body).toBe(latestOf(V904).checksum);
});
it('lists an unpublished version while latest skips it', async () => {
const {service} = createService(incidentObjects());
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
});
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {describe, expect, it} from 'vitest';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const PREFIX = 'desktop/canary/linux/x64';
const BASE_URL = 'https://api.example.test';
const V1 = '2026.901.100000';
const V2 = '2026.902.100000';
const V3 = '2026.903.100000';
const V4 = '2026.904.100000';
const V5 = '2026.905.100000';
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
type StoredObject = {body?: string; lastModified?: Date};
type StoredObjects = Map<string, StoredObject>;
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function debFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-amd64.deb`;
}
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
if (options.sha256 !== undefined) {
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
}
}
function createService(objects: StoredObjects) {
const reads: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
const object = objects.get(params.key);
if (object?.body == null) {
return null;
}
const buffer = Buffer.from(object.body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) =>
Array.from(objects.entries())
.filter(([key]) => key.startsWith(params.prefix))
.sort(([left], [right]) => (left < right ? -1 : 1))
.map(([key, object]) => ({key, lastModified: object.lastModified})),
getObjectMetadata: async () => null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads};
}
function versionNumbers(versions: Array<{version: string}>): Array<string> {
return versions.map((entry) => entry.version);
}
describe('desktop version listing', () => {
it('lists versions newest first with the files of each version', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
expect(listed.hasMore).toBe(false);
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
});
it('excludes names that are not artefacts for the requested coordinate', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V3]);
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
});
it('pages with limit, before and after and reports whether more remain', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version));
}
const {service} = createService(objects);
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
expect(firstPage.hasMore).toBe(true);
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
expect(olderPage.hasMore).toBe(false);
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
expect(newerPage.hasMore).toBe(false);
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
expect(versionNumbers(between.versions)).toEqual([V4]);
expect(between.hasMore).toBe(true);
});
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
const hash = 'b'.repeat(64);
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: hash});
addArtifact(objects, appImageFilename(V2));
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(listed.versions[0].files.appimage).toEqual({
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
sha256: hash,
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
});
expect(listed.versions[1].files.appimage.sha256).toBeNull();
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
expect(listed.versions[2].files.appimage.sha256).toBeNull();
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
});
it('reads a checksum only for the versions it returns', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
}
const {service, reads} = createService(objects);
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
});
});