mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): only offer desktop builds whose release is published (#2670)
This commit is contained in:
@@ -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`]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user