//! Release catalog contract (port of `shared/updateContract.js`). use crate::core::error::{CoreError, CoreResult}; use crate::core::version::{compare_versions, parse_version}; use once_cell::sync::Lazy; use regex::Regex; use serde::Serialize; use serde_json::Value; use std::cmp::Ordering; pub static SHA512_PATTERN: Lazy = Lazy::new(|| Regex::new(r"(?i)^[a-f0-9]{128}$").unwrap()); static BUILD_ID: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$").unwrap()); pub fn normalize_architecture(value: &str) -> String { match value.trim().to_lowercase().as_str() { "ia32" | "x86" => "x86".into(), "x64" | "amd64" | "x86_64" => "x64".into(), "arm64" | "aarch64" => "arm64".into(), other => other.into(), } } pub fn normalize_platform(value: &str) -> String { match value.trim().to_lowercase().as_str() { "win32" | "windows" => "win".into(), "darwin" | "macos" => "mac".into(), "linux" => "ubuntu".into(), other => other.into(), } } pub fn server_channel(channel: &str) -> CoreResult { let c = channel.trim().to_lowercase(); if ["alpha", "beta", "stable"].contains(&c.as_str()) { Ok(c) } else { Err(CoreError::plain(format!("Unsupported update channel: {}", if c.is_empty() { "" } else { &c }))) } } pub fn assert_supported_target(platform: &str, arch: &str) -> CoreResult<(String, String)> { let (p, a) = (normalize_platform(platform), normalize_architecture(arch)); let allowed: &[&str] = match p.as_str() { "win" => &["x64", "x86", "arm64", "universal"], "mac" => &["arm64"], "ubuntu" => &["x64", "arm64", "universal"], _ => return Err(CoreError::plain(format!("Unsupported update platform: {}", if p.is_empty() { "" } else { &p }))), }; if !allowed.contains(&a.as_str()) { return Err(CoreError::plain(format!("Unsupported update architecture for {p}: {}", if a.is_empty() { "" } else { &a }))); } Ok((p, a)) } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Release { pub id: String, pub build_id: Option, pub name: String, pub version: String, pub platform: String, pub arch: String, pub channel: String, pub mandatory: bool, pub notes: String, pub sha512: String, pub size: u64, pub filename: String, pub download_url: String, pub signature_url: String, pub published_at: Option, } pub struct Expected<'a> { pub platform: &'a str, pub arch: &'a str, pub channel: &'a str, pub current_version: &'a str, } fn require_string(value: Option<&Value>, field: &str) -> CoreResult { let text = match value { Some(Value::String(s)) => s.trim().to_string(), Some(Value::Number(n)) => n.to_string(), Some(Value::Bool(true)) => "true".into(), _ => String::new(), }; if text.is_empty() { Err(CoreError::plain(format!("Missing {field}."))) } else { Ok(text) } } pub fn parse_date(value: &str) -> Option> { chrono::DateTime::parse_from_rfc3339(value) .ok() .or_else(|| chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S").ok().map(|d| d.and_utc().fixed_offset())) .or_else(|| chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").ok().and_then(|d| d.and_hms_opt(0, 0, 0)).map(|d| d.and_utc().fixed_offset())) } pub fn validate_release(source: &Value, expected: &Expected<'_>) -> CoreResult { let version = require_string(source.get("version"), "release.version")?; parse_version(&version)?; let platform = normalize_platform(&require_string(source.get("platform"), "release.platform")?); let arch = normalize_architecture(&require_string(source.get("arch").or(Some(&Value::String("universal".into()))), "release.arch")?); let channel = require_string(source.get("channel").filter(|v| !v.is_null()).or(Some(&Value::String("stable".into()))), "release.channel")?.to_lowercase(); let (expected_platform, expected_arch) = assert_supported_target(expected.platform, expected.arch)?; let expected_channel = server_channel(expected.channel)?; assert_supported_target(&platform, &arch)?; if let Some(kind) = source.get("type").and_then(Value::as_str) { if kind != "app" { return Err(CoreError::plain("Release is not an application update.")); } } if platform != expected_platform { return Err(CoreError::plain(format!("Unexpected release platform: {platform}"))); } if arch != "universal" && arch != expected_arch { return Err(CoreError::plain(format!("Unexpected release architecture: {arch}"))); } if channel != expected_channel { return Err(CoreError::plain(format!("Unexpected release channel: {channel}"))); } let sha512 = require_string(source.get("sha512"), "release.sha512")?.to_lowercase(); if !SHA512_PATTERN.is_match(&sha512) { return Err(CoreError::plain("Invalid release SHA-512.")); } let size = source.get("size").and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).unwrap_or(0.0); if size.fract() != 0.0 || size <= 0.0 || size > 9_007_199_254_740_991.0 { return Err(CoreError::plain("Invalid release size.")); } let build_id = match source.get("build_id") { None | Some(Value::Null) => None, Some(Value::String(s)) if s.is_empty() => None, other => Some(require_string(other, "release.build_id")?), }; if let Some(id) = &build_id { if !BUILD_ID.is_match(id) { return Err(CoreError::plain("Invalid release build ID.")); } } let default_id = Value::String(format!("{platform}-{arch}-{version}")); Ok(Release { id: require_string(source.get("id").filter(|v| !v.is_null() && v.as_str() != Some("")).or(Some(&default_id)), "release.id")?, build_id, name: require_string(source.get("name").filter(|v| !v.is_null() && v.as_str() != Some("")).or(Some(&Value::String("CH-J Server Manager".into()))), "release.name")?, version, platform, arch, channel, mandatory: source.get("mandatory").map(|v| v.as_bool().unwrap_or(!v.is_null() && v != &Value::from(0) && v.as_str() != Some(""))).unwrap_or(false), notes: source.get("notes").and_then(Value::as_str).unwrap_or("").to_string(), sha512, size: size as u64, filename: require_string(source.get("filename"), "release.filename")?, download_url: require_string(source.get("download_url").or(source.get("download_path")), "release.download_url")?, signature_url: require_string(source.get("signature_url").or(source.get("signature_path")), "release.signature_url")?, published_at: source.get("published_at").filter(|v| !v.is_null()).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())), }) } pub fn validate_release_list(payload: &Value, expected: &Expected<'_>) -> CoreResult> { let list = payload.get("releases").and_then(Value::as_array).ok_or_else(|| CoreError::plain("Update catalog response must contain a releases array."))?; let mut ids = std::collections::HashSet::new(); let mut releases = Vec::new(); for source in list { let release = validate_release(source, expected)?; if release.published_at.as_deref().and_then(parse_date).is_none() { return Err(CoreError::plain("Invalid release publication date.")); } if !ids.insert(release.id.clone()) { return Err(CoreError::plain(format!("Duplicate release id: {}", release.id))); } releases.push(release); } releases.sort_by(|a, b| { compare_versions(&b.version, &a.version) .unwrap_or(Ordering::Equal) .then_with(|| parse_date(b.published_at.as_deref().unwrap_or("")).cmp(&parse_date(a.published_at.as_deref().unwrap_or("")))) }); Ok(releases) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn release(version: &str, id: &str) -> Value { json!({"id": id, "version": version, "platform": "mac", "arch": "arm64", "channel": "beta", "sha512": "a".repeat(128), "size": 10, "filename": "x.dmg", "download_url": "/files/x.dmg", "signature_url": "/files/x.dmg.asc", "published_at": "2026-10-24T12:00:00Z", "build_id": "core-20261024T121943Z-f4fc7ca3b8b2"}) } #[test] fn validates_and_sorts() { let expected = Expected { platform: "darwin", arch: "arm64", channel: "beta", current_version: "0.1.1" }; let list = validate_release_list(&json!({"releases": [release("0.1.0", "a"), release("0.1.1", "b")]}), &expected).unwrap(); assert_eq!(list[0].version, "0.1.1"); assert!(validate_release_list(&json!({"releases": [release("0.1.0", "a"), release("0.1.1", "a")]}), &expected).is_err()); let mut wrong = release("0.1.2", "c"); wrong["channel"] = json!("alpha"); assert!(validate_release(&wrong, &expected).is_err()); let mut win = release("0.1.2", "d"); win["platform"] = json!("win"); assert!(validate_release(&win, &expected).is_err()); } }