CH-J Toolsserverhåndtering over SSH
Menu
Offentliggjort kilde

CH-J Tools

Gennemse mapper og filer til en specifik applikationsudgivelse.

Download kilde ZIP
CH-J / Server Manager 1704 filer
0.1.2-beta Tilføjet: · Bygget:
contract.rs Tilføjet: Dato ikke registreret 9,1 KB · 208 linjer
Kode Download fil
1//! Release catalog contract (port of `shared/updateContract.js`).
3use crate::core::error::{CoreError, CoreResult};
4use crate::core::version::{compare_versions, parse_version};
5use once_cell::sync::Lazy;
6use regex::Regex;
7use serde::Serialize;
8use serde_json::Value;
9use std::cmp::Ordering;
11pub static SHA512_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^[a-f0-9]{128}$").unwrap());
12static BUILD_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$").unwrap());
14pub 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 }
23pub 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 }
32pub 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 }
41pub 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))
55#[derive(Debug, Clone, Serialize, PartialEq)]
56#[serde(rename_all = "camelCase")]
57pub 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>,
75pub struct Expected<'a> {
76 pub platform: &'a str,
77 pub arch: &'a str,
78 pub channel: &'a str,
79 pub current_version: &'a str,
82fn 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 }
96pub 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()))
103pub 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 })
164pub 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)
186#[cfg(test)]
187mod 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 }
SHA-256dde8b680768d3128db3ad96d075b30345c6127cdda6b1da1d14fc3d2e586895b

CH-J Proprietary Software License 1.14 · Kilden leveres under CH-J Proprietary Software License 1.14. Dens tilgængelighed ændrer ikke licensbetingelserne eller giver yderligere rettigheder.

Arkiv SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Filer