fix(desktop): deduplicate macOS release feeds (#2056)

This commit is contained in:
Hampus
2026-08-28 19:16:35 +02:00
committed by GitHub
parent 094fb0d1c8
commit 53ddca725e
3 changed files with 95 additions and 33 deletions
@@ -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<string>();
const routeCounts = new Map<string, number>();
const releaseAssets = new Map<string, {sha256: string; size: number}>();
const releaseAssetNames = new Map<string, string>([[descriptorName.toLowerCase(), descriptorName]]);
const assets: Array<DesktopReleaseAsset> = [];
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);
+18 -15
View File
@@ -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<DesktopReleaseAsset>,
storage_keys: BTreeSet<String>,
release_asset_content: BTreeMap<String, (String, u64)>,
release_asset_names: BTreeMap<String, String>,
}
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
+61 -15
View File
@@ -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<String> {
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::<String, usize>::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::<String, String>::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,