Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
1704 Dateien
0.1.2-beta
Hinzugefügt: · Erstellt:
1
//! Release catalog contract (port of `shared/updateContract.js`).3
use crate::core::error::{CoreError, CoreResult};4
use crate::core::version::{compare_versions, parse_version};5
use once_cell::sync::Lazy;6
use regex::Regex;7
use serde::Serialize;8
use serde_json::Value;9
use std::cmp::Ordering;11
pub static SHA512_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^[a-f0-9]{128}$").unwrap());12
static BUILD_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$").unwrap());14
pub fn normalize_architecture(value: &str) -> String {15
match value.trim().to_lowercase().as_str() {16
"ia32" | "x86" => "x86".into(),17
"x64" | "amd64" | "x86_64" => "x64".into(),18
"arm64" | "aarch64" => "arm64".into(),19
other => other.into(),20
}21
}23
pub fn normalize_platform(value: &str) -> String {24
match value.trim().to_lowercase().as_str() {25
"win32" | "windows" => "win".into(),26
"darwin" | "macos" => "mac".into(),27
"linux" => "ubuntu".into(),28
other => other.into(),29
}30
}32
pub fn server_channel(channel: &str) -> CoreResult<String> {33
let c = channel.trim().to_lowercase();34
if ["alpha", "beta", "stable"].contains(&c.as_str()) {35
Ok(c)36
} else {37
Err(CoreError::plain(format!("Unsupported update channel: {}", if c.is_empty() { "<empty>" } else { &c })))38
}39
}41
pub fn assert_supported_target(platform: &str, arch: &str) -> CoreResult<(String, String)> {42
let (p, a) = (normalize_platform(platform), normalize_architecture(arch));43
let allowed: &[&str] = match p.as_str() {44
"win" => &["x64", "x86", "arm64", "universal"],45
"mac" => &["arm64"],46
"ubuntu" => &["x64", "arm64", "universal"],47
_ => return Err(CoreError::plain(format!("Unsupported update platform: {}", if p.is_empty() { "<empty>" } else { &p }))),48
};49
if !allowed.contains(&a.as_str()) {50
return Err(CoreError::plain(format!("Unsupported update architecture for {p}: {}", if a.is_empty() { "<empty>" } else { &a })));51
}52
Ok((p, a))53
}55
#[derive(Debug, Clone, Serialize, PartialEq)]56
#[serde(rename_all = "camelCase")]57
pub struct Release {58
pub id: String,59
pub build_id: Option<String>,60
pub name: String,61
pub version: String,62
pub platform: String,63
pub arch: String,64
pub channel: String,65
pub mandatory: bool,66
pub notes: String,67
pub sha512: String,68
pub size: u64,69
pub filename: String,70
pub download_url: String,71
pub signature_url: String,72
pub published_at: Option<String>,73
}75
pub struct Expected<'a> {76
pub platform: &'a str,77
pub arch: &'a str,78
pub channel: &'a str,79
pub current_version: &'a str,80
}82
fn require_string(value: Option<&Value>, field: &str) -> CoreResult<String> {83
let text = match value {84
Some(Value::String(s)) => s.trim().to_string(),85
Some(Value::Number(n)) => n.to_string(),86
Some(Value::Bool(true)) => "true".into(),87
_ => String::new(),88
};89
if text.is_empty() {90
Err(CoreError::plain(format!("Missing {field}.")))91
} else {92
Ok(text)93
}94
}96
pub fn parse_date(value: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {97
chrono::DateTime::parse_from_rfc3339(value)98
.ok()99
.or_else(|| chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S").ok().map(|d| d.and_utc().fixed_offset()))100
.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()))101
}103
pub fn validate_release(source: &Value, expected: &Expected<'_>) -> CoreResult<Release> {104
let version = require_string(source.get("version"), "release.version")?;105
parse_version(&version)?;106
let platform = normalize_platform(&require_string(source.get("platform"), "release.platform")?);107
let arch = normalize_architecture(&require_string(source.get("arch").or(Some(&Value::String("universal".into()))), "release.arch")?);108
let channel = require_string(source.get("channel").filter(|v| !v.is_null()).or(Some(&Value::String("stable".into()))), "release.channel")?.to_lowercase();109
let (expected_platform, expected_arch) = assert_supported_target(expected.platform, expected.arch)?;110
let expected_channel = server_channel(expected.channel)?;111
assert_supported_target(&platform, &arch)?;112
if let Some(kind) = source.get("type").and_then(Value::as_str) {113
if kind != "app" {114
return Err(CoreError::plain("Release is not an application update."));115
}116
}117
if platform != expected_platform {118
return Err(CoreError::plain(format!("Unexpected release platform: {platform}")));119
}120
if arch != "universal" && arch != expected_arch {121
return Err(CoreError::plain(format!("Unexpected release architecture: {arch}")));122
}123
if channel != expected_channel {124
return Err(CoreError::plain(format!("Unexpected release channel: {channel}")));125
}126
let sha512 = require_string(source.get("sha512"), "release.sha512")?.to_lowercase();127
if !SHA512_PATTERN.is_match(&sha512) {128
return Err(CoreError::plain("Invalid release SHA-512."));129
}130
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);131
if size.fract() != 0.0 || size <= 0.0 || size > 9_007_199_254_740_991.0 {132
return Err(CoreError::plain("Invalid release size."));133
}134
let build_id = match source.get("build_id") {135
None | Some(Value::Null) => None,136
Some(Value::String(s)) if s.is_empty() => None,137
other => Some(require_string(other, "release.build_id")?),138
};139
if let Some(id) = &build_id {140
if !BUILD_ID.is_match(id) {141
return Err(CoreError::plain("Invalid release build ID."));142
}143
}144
let default_id = Value::String(format!("{platform}-{arch}-{version}"));145
Ok(Release {146
id: require_string(source.get("id").filter(|v| !v.is_null() && v.as_str() != Some("")).or(Some(&default_id)), "release.id")?,147
build_id,148
name: require_string(source.get("name").filter(|v| !v.is_null() && v.as_str() != Some("")).or(Some(&Value::String("CH-J Tools".into()))), "release.name")?,149
version,150
platform,151
arch,152
channel,153
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),154
notes: source.get("notes").and_then(Value::as_str).unwrap_or("").to_string(),155
sha512,156
size: size as u64,157
filename: require_string(source.get("filename"), "release.filename")?,158
download_url: require_string(source.get("download_url").or(source.get("download_path")), "release.download_url")?,159
signature_url: require_string(source.get("signature_url").or(source.get("signature_path")), "release.signature_url")?,160
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())),161
})162
}164
pub fn validate_release_list(payload: &Value, expected: &Expected<'_>) -> CoreResult<Vec<Release>> {165
let list = payload.get("releases").and_then(Value::as_array).ok_or_else(|| CoreError::plain("Update catalog response must contain a releases array."))?;166
let mut ids = std::collections::HashSet::new();167
let mut releases = Vec::new();168
for source in list {169
let release = validate_release(source, expected)?;170
if release.published_at.as_deref().and_then(parse_date).is_none() {171
return Err(CoreError::plain("Invalid release publication date."));172
}173
if !ids.insert(release.id.clone()) {174
return Err(CoreError::plain(format!("Duplicate release id: {}", release.id)));175
}176
releases.push(release);177
}178
releases.sort_by(|a, b| {179
compare_versions(&b.version, &a.version)180
.unwrap_or(Ordering::Equal)181
.then_with(|| parse_date(b.published_at.as_deref().unwrap_or("")).cmp(&parse_date(a.published_at.as_deref().unwrap_or(""))))182
});183
Ok(releases)184
}186
#[cfg(test)]187
mod tests {188
use super::*;189
use serde_json::json;191
fn release(version: &str, id: &str) -> Value {192
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"})193
}195
#[test]196
fn validates_and_sorts() {197
let expected = Expected { platform: "darwin", arch: "arm64", channel: "beta", current_version: "0.1.1" };198
let list = validate_release_list(&json!({"releases": [release("0.1.0", "a"), release("0.1.1", "b")]}), &expected).unwrap();199
assert_eq!(list[0].version, "0.1.1");200
assert!(validate_release_list(&json!({"releases": [release("0.1.0", "a"), release("0.1.1", "a")]}), &expected).is_err());201
let mut wrong = release("0.1.2", "c");202
wrong["channel"] = json!("alpha");203
assert!(validate_release(&wrong, &expected).is_err());204
let mut win = release("0.1.2", "d");205
win["platform"] = json!("win");206
assert!(validate_release(&win, &expected).is_err());207
}208
}SHA-256
dde8b680768d3128db3ad96d075b30345c6127cdda6b1da1d14fc3d2e586895bCH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.
SHA-256 des Archivs
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f