diff --git a/fluxer_api/src/api/download/DesktopReleaseContract.ts b/fluxer_api/src/api/download/DesktopReleaseContract.ts index ca3941e83..bf32f7d54 100644 --- a/fluxer_api/src/api/download/DesktopReleaseContract.ts +++ b/fluxer_api/src/api/download/DesktopReleaseContract.ts @@ -6,7 +6,7 @@ import {isJsonRecord} from '../utils/JsonBoundaryUtils'; const DESKTOP_BUCKET_PREFIX = 'desktop'; const MIN_RELEASE_ROUTE_COUNT = 28; const MAX_RELEASE_ROUTE_COUNT = 128; -const MIN_RELEASE_ASSET_COUNT = 26; +const MIN_RELEASE_ASSET_COUNT = 24; interface DesktopReleaseAsset { storage_key: string; @@ -101,12 +101,14 @@ export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDes const expectedTag = `fluxer-desktop-${value.channel}@${value.version}`; const expectedStoragePrefix = `desktop/${value.channel}/`; const expectedReleasePrefix = `${value.channel === 'canary' ? 'Fluxer-Canary' : 'Fluxer'}-${value.version}-`; + const descriptorName = `${expectedReleasePrefix}release-manifest.json`; if (value.release_tag !== expectedTag) { return null; } const storageKeys = new Set(); const routeCounts = new Map(); const releaseAssets = new Map(); + const releaseAssetNames = new Map([[descriptorName.toLowerCase(), descriptorName]]); const assets: Array = []; for (const rawAsset of value.assets) { const asset = parseDesktopReleaseAsset(rawAsset); @@ -121,12 +123,23 @@ export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDes storageKeys.add(asset.storage_key); const [, , platform, arch, filename] = asset.storage_key.split('/'); const platformToken = platform === 'win32' ? 'win' : platform === 'darwin' ? 'mac' : 'linux'; + const releaseFilename = + platform === 'darwin' && filename.toLowerCase() === 'releases.json' ? 'releases.json' : filename; const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix) ? filename - : `${expectedReleasePrefix}${platformToken}-${arch}-${filename}`; - if (asset.release_asset !== expectedReleaseAsset) { + : `${expectedReleasePrefix}${platformToken}-${arch}-${releaseFilename}`; + if ( + asset.release_asset !== expectedReleaseAsset || + asset.release_asset.toLowerCase() === descriptorName.toLowerCase() + ) { return null; } + const caseFoldedReleaseAsset = asset.release_asset.toLowerCase(); + const existingReleaseAssetName = releaseAssetNames.get(caseFoldedReleaseAsset); + if (existingReleaseAssetName && existingReleaseAssetName !== asset.release_asset) { + return null; + } + releaseAssetNames.set(caseFoldedReleaseAsset, asset.release_asset); const scope = `${platform}/${arch}`; routeCounts.set(scope, (routeCounts.get(scope) ?? 0) + 1); const existing = releaseAssets.get(asset.release_asset); diff --git a/tools/ci/src/desktop.rs b/tools/ci/src/desktop.rs index a26ddf1bd..e5d6e06b2 100644 --- a/tools/ci/src/desktop.rs +++ b/tools/ci/src/desktop.rs @@ -12,7 +12,7 @@ use crate::common::{ use crate::functions::write_json_pretty; use crate::release::{ DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, DesktopReleaseAsset, DesktopReleaseDescriptor, - desktop_release_descriptor_filename, desktop_release_product, + desktop_release_asset_name, desktop_release_descriptor_filename, desktop_release_product, validate_desktop_release_descriptor, }; use anyhow::{Context, Result, anyhow, bail, ensure}; @@ -3781,6 +3781,7 @@ struct DesktopReleaseAssetBuilder<'a> { descriptor_assets: Vec, storage_keys: BTreeSet, release_asset_content: BTreeMap, + release_asset_names: BTreeMap, } impl<'a> DesktopReleaseAssetBuilder<'a> { @@ -3800,6 +3801,7 @@ impl<'a> DesktopReleaseAssetBuilder<'a> { descriptor_assets: Vec::new(), storage_keys: BTreeSet::new(), release_asset_content: BTreeMap::new(), + release_asset_names: BTreeMap::new(), } } @@ -3810,21 +3812,13 @@ impl<'a> DesktopReleaseAssetBuilder<'a> { source.display() ); let source_name = file_name_string(source)?; - let platform_token = match platform { - "win32" => "win", - "darwin" => "mac", - "linux" => "linux", - other => bail!("Unsupported desktop release platform {other:?}"), - }; let canonical_prefix = format!("{}-{}-", self.product, self.version); - let release_asset = if qualify_name && !source_name.starts_with(&canonical_prefix) { - format!( - "{}-{}-{platform_token}-{arch}-{source_name}", - self.product, self.version - ) - } else { - source_name.clone() - }; + ensure!( + qualify_name || source_name.starts_with(&canonical_prefix), + "Shipped desktop artifact name is not canonical: {source_name:?}" + ); + let release_asset = + desktop_release_asset_name(self.channel, self.version, platform, arch, &source_name)?; ensure!( release_asset.starts_with(&canonical_prefix) && release_asset.bytes().all(|byte| { @@ -3832,6 +3826,15 @@ impl<'a> DesktopReleaseAssetBuilder<'a> { }), "Desktop release asset name is not canonical and URL-safe: {release_asset:?}" ); + if let Some(existing) = self + .release_asset_names + .insert(release_asset.to_ascii_lowercase(), release_asset.clone()) + { + ensure!( + existing == release_asset, + "Desktop release asset names differ only by case: {existing:?} and {release_asset:?}" + ); + } let storage_key = format!( "{}/{}/{platform}/{arch}/{source_name}", self.s3_prefix, self.channel diff --git a/tools/ci/src/release.rs b/tools/ci/src/release.rs index 292d6da11..2a3c9148d 100644 --- a/tools/ci/src/release.rs +++ b/tools/ci/src/release.rs @@ -15,7 +15,7 @@ const RELEASE_REPOSITORY: &str = "fluxerapp/fluxer"; const RELEASE_COMPARE_URL: &str = "https://github.com/fluxerapp/fluxer/compare"; pub(crate) const DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION: u8 = 1; pub(crate) const DESKTOP_RELEASE_ROUTE_COUNT: usize = 28; -pub(crate) const DESKTOP_RELEASE_ASSET_COUNT: usize = 26; +pub(crate) const DESKTOP_RELEASE_ASSET_COUNT: usize = 24; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub(crate) struct DesktopReleaseAsset { @@ -50,6 +50,38 @@ pub(crate) fn desktop_release_descriptor_filename(channel: &str, version: &str) )) } +pub(crate) fn desktop_release_asset_name( + channel: &str, + version: &str, + platform: &str, + arch: &str, + storage_filename: &str, +) -> Result { + let release_prefix = format!("{}-{version}-", desktop_release_product(channel)?); + let platform_token = match platform { + "win32" => "win", + "darwin" => "mac", + "linux" => "linux", + other => bail!("Unsupported desktop release platform {other:?}"), + }; + ensure!( + matches!(arch, "x64" | "arm64"), + "Unsupported desktop release architecture {arch:?}" + ); + if storage_filename.starts_with(&release_prefix) { + return Ok(storage_filename.to_string()); + } + let release_filename = + if platform == "darwin" && storage_filename.eq_ignore_ascii_case("releases.json") { + "releases.json" + } else { + storage_filename + }; + Ok(format!( + "{release_prefix}{platform_token}-{arch}-{release_filename}" + )) +} + pub(crate) fn validate_desktop_release_descriptor( descriptor: &DesktopReleaseDescriptor, channel: &str, @@ -101,6 +133,10 @@ pub(crate) fn validate_desktop_release_descriptor( let mut storage_keys = BTreeSet::new(); let mut route_counts = BTreeMap::::new(); let mut release_assets = BTreeMap::<&str, (&str, u64)>::new(); + let mut release_asset_names = BTreeMap::from([( + descriptor_name.to_ascii_lowercase(), + descriptor_name.as_str(), + )]); for asset in &descriptor.assets { ensure!( storage_keys.insert(asset.storage_key.as_str()), @@ -125,20 +161,13 @@ pub(crate) fn validate_desktop_release_descriptor( *route_counts .entry(format!("{}/{}", key_segments[2], key_segments[3])) .or_default() += 1; - let platform_token = match key_segments[2] { - "win32" => "win", - "darwin" => "mac", - "linux" => "linux", - _ => unreachable!(), - }; - let expected_release_asset = if key_segments[4].starts_with(&release_prefix) { - key_segments[4].to_string() - } else { - format!( - "{release_prefix}{platform_token}-{}-{}", - key_segments[3], key_segments[4] - ) - }; + let expected_release_asset = desktop_release_asset_name( + channel, + version, + key_segments[2], + key_segments[3], + key_segments[4], + )?; ensure!( asset.release_asset.starts_with(&release_prefix) && asset.release_asset != descriptor_name @@ -149,6 +178,16 @@ pub(crate) fn validate_desktop_release_descriptor( "Desktop release descriptor contains invalid release asset {:?}", asset.release_asset ); + if let Some(existing) = release_asset_names.insert( + asset.release_asset.to_ascii_lowercase(), + asset.release_asset.as_str(), + ) { + ensure!( + existing == asset.release_asset, + "Desktop release asset names differ only by case: {existing:?} and {:?}", + asset.release_asset + ); + } ensure!( asset.sha256.len() == 64 && asset @@ -613,6 +652,7 @@ fn local_release_assets( ); let mut assets = Vec::with_capacity(entries.len()); + let mut case_folded_names = BTreeMap::::new(); for entry in entries { let path = entry.path(); let metadata = fs::symlink_metadata(&path) @@ -640,6 +680,12 @@ fn local_release_assets( name.starts_with(&prefix), "Release asset {name:?} must start with {prefix:?}" ); + if let Some(existing) = case_folded_names.insert(name.to_ascii_lowercase(), name.clone()) { + ensure!( + existing == name, + "Release asset names differ only by case: {existing:?} and {name:?}" + ); + } assets.push(LocalReleaseAsset { digest: sha256_file(&path)?, path,