CH-J Toolsspráva serverov nad SSH
Menu
Uverejnený zdroj

CH-J Tools

Prechádzať adresáre a súbory pre konkrétne vydanie aplikácie.

Stiahnuť zdroj ZIP
CH-J / Server Manager 1704 súbory
0.1.2-beta Pridané: · Postavený:
provider.rs Pridané: Dátum nezaznamenaný 20,2 KB · 437 riadky
Kód Stiahnuť súbor
1//! Release catalog and verified downloads (port of `updates/hashUrlProvider.js`):
2//! `api/releases.php`, HTTPS allowlist, private cache (0700/0600→0400), streamed SHA-512 +
3//! size checks, `.asc` must be exactly `<artifact URL>.asc`, mandatory OpenPGP verification
4//! and re-verification immediately before an installer is launched.
6use crate::core::error::{CoreError, CoreResult};
7use crate::core::net::https::HttpsClient;
8use crate::core::storage::clean_download_cache;
9use crate::core::updates::contract::{assert_supported_target, normalize_architecture, normalize_platform, parse_date, server_channel, validate_release_list, Expected, Release};
10use crate::core::updates::openpgp::{OpenPgpVerifier, Verification};
11use crate::core::version::compare_versions;
12use crate::security::url_policy::{assert_allowed_url, normalize_base_url, AllowOptions};
13use serde_json::{json, Value};
14use sha2::{Digest, Sha512};
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::Duration;
20struct VerificationGuard(tokio_util::sync::CancellationToken);
21impl Drop for VerificationGuard {
22 fn drop(&mut self) { self.0.cancel(); }
25async fn bounded_verification<T: Send + 'static>(limit: Duration, work: impl FnOnce(tokio_util::sync::CancellationToken) -> CoreResult<T> + Send + 'static) -> CoreResult<T> {
26 let guard = VerificationGuard(tokio_util::sync::CancellationToken::new());
27 let cancel = guard.0.clone();
28 tokio::time::timeout(limit, tokio::task::spawn_blocking(move || work(cancel)))
29 .await
30 .map_err(|_| CoreError::new("UPDATE_SIGNATURE_TIMEOUT", "OpenPGP verification timed out."))?
31 .map_err(|_| CoreError::new("UPDATE_SIGNATURE_ERROR", "OpenPGP verification failed unexpectedly."))?
34pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
35pub const MAX_ARTIFACT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
36pub const MAX_SIGNATURE_BYTES: u64 = 128 * 1024;
38pub fn safe_filename(value: &str) -> String {
39 let base = Path::new(value).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "update.bin".into());
40 let mut safe = String::new();
41 let mut dash = false;
42 for c in base.chars() {
43 if c.is_ascii_alphanumeric() || "._-".contains(c) {
44 safe.push(c);
45 dash = false;
46 } else if !dash {
47 safe.push('-');
48 dash = true;
49 }
50 }
51 let trimmed = safe.trim_start_matches(['-', '.']).to_string();
52 if trimmed.is_empty() { "update.bin".into() } else { trimmed }
55#[derive(Debug, Clone, serde::Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct VerifiedDownload {
58 pub path: PathBuf,
59 pub signature_path: PathBuf,
60 pub filename: String,
61 pub size: u64,
62 pub sha512: String,
63 pub reused: bool,
64 pub signature_verified: bool,
65 pub primary_fingerprint: String,
66 pub signing_fingerprints: Vec<String>,
67 pub signature_created_at: Option<String>,
68 pub release_id: Option<String>,
71pub struct ReleaseProvider {
72 base_urls: Vec<String>,
73 client: Arc<HttpsClient>,
74 download_root: PathBuf,
75 verifier: Arc<OpenPgpVerifier>,
76 logger: Arc<crate::core::logging::Logger>,
79fn private_dir(path: &Path, recursive: bool) -> CoreResult<()> {
80 if !path.exists() {
81 if recursive { std::fs::create_dir_all(path)? } else { std::fs::create_dir(path)? }
82 }
83 let meta = std::fs::symlink_metadata(path)?;
84 if !meta.is_dir() || meta.file_type().is_symlink() {
85 return Err(CoreError::plain("Update download directory is unsafe."));
86 }
87 #[cfg(unix)]
88 {
89 use std::os::unix::fs::PermissionsExt;
90 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
91 }
92 Ok(())
95fn link_count(meta: &std::fs::Metadata) -> u64 {
96 #[cfg(unix)]
97 {
98 use std::os::unix::fs::MetadataExt;
99 meta.nlink()
100 }
101 #[cfg(not(unix))]
102 {
103 let _ = meta;
104 1
105 }
108fn set_read_only(path: &Path) {
109 #[cfg(unix)]
110 {
111 use std::os::unix::fs::PermissionsExt;
112 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400));
113 }
114 #[cfg(not(unix))]
115 {
116 if let Ok(meta) = std::fs::metadata(path) {
117 let mut permissions = meta.permissions();
118 permissions.set_readonly(true);
119 let _ = std::fs::set_permissions(path, permissions);
120 }
121 }
124fn random_hex() -> String {
125 let mut bytes = [0u8; 6];
126 rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes);
127 hex::encode(bytes)
130impl ReleaseProvider {
131 pub fn new(base_urls: &[String], client: Arc<HttpsClient>, download_root: PathBuf, verifier: Arc<OpenPgpVerifier>, logger: Arc<crate::core::logging::Logger>) -> CoreResult<Self> {
132 let base_urls = base_urls.iter().map(|u| normalize_base_url(u).map(|u| u.to_string())).collect::<CoreResult<Vec<_>>>()?;
133 if base_urls.is_empty() {
134 return Err(CoreError::plain("At least one update base URL is required."));
135 }
136 Ok(Self { base_urls, client, download_root, verifier, logger })
137 }
139 pub fn download_root(&self) -> &Path {
140 &self.download_root
141 }
143 fn resolve_artifact(&self, value: &str, base: &str) -> CoreResult<url::Url> {
144 let joined = url::Url::parse(base).and_then(|b| b.join(value.trim().trim_start_matches('/'))).map_err(|_| CoreError::plain("Invalid update URL."))?;
145 assert_allowed_url(joined.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("files/"), ..Default::default() })
146 }
148 fn resolve_signature(&self, value: &str, artifact: &url::Url, base: &str) -> CoreResult<url::Url> {
149 let resolved = self.resolve_artifact(value, base)?;
150 if resolved.origin() != artifact.origin() || resolved.path() != format!("{}.asc", artifact.path()) || resolved.query().is_some() || resolved.fragment().is_some() {
151 return Err(CoreError::plain("Detached signature URL must be the artifact URL followed by .asc."));
152 }
153 Ok(resolved)
154 }
156 pub async fn list_releases(&self, current_version: &str, platform: &str, arch: &str, channel: &str) -> CoreResult<(Vec<Release>, String)> {
157 let (platform, arch) = (normalize_platform(platform), normalize_architecture(arch));
158 assert_supported_target(&platform, &arch)?;
159 let channels: Vec<String> = if channel == "all" { vec!["alpha".into(), "beta".into(), "stable".into()] } else { vec![server_channel(channel)?] };
160 let mut errors = Vec::new();
161 for base in &self.base_urls {
162 let attempt = async {
163 let mut by_id: Vec<Release> = Vec::new();
164 for selected in &channels {
165 let mut url = url::Url::parse(base).and_then(|b| b.join("api/releases.php")).map_err(|_| CoreError::plain("Invalid update URL."))?;
166 url.query_pairs_mut().append_pair("platform", &platform).append_pair("arch", &arch).append_pair("channel", selected);
167 assert_allowed_url(url.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("api/"), ..Default::default() })?;
168 let (status, text) = self.client.get_text(&url, "application/json", Duration::from_secs(5), MAX_MANIFEST_BYTES).await?;
169 if status != 200 {
170 return Err(CoreError::plain(format!("Update server returned HTTP {status}.")));
171 }
172 let payload: Value = serde_json::from_str(&text).map_err(|_| CoreError::plain("Update server returned invalid JSON."))?;
173 let expected = Expected { platform: &platform, arch: &arch, channel: selected, current_version };
174 for mut release in validate_release_list(&payload, &expected)? {
175 let artifact = self.resolve_artifact(&release.download_url, base)?;
176 release.signature_url = self.resolve_signature(&release.signature_url, &artifact, base)?.to_string();
177 release.download_url = artifact.to_string();
178 by_id.retain(|r| r.id != release.id);
179 by_id.push(release);
180 }
181 }
182 by_id.sort_by(|a, b| {
183 compare_versions(&b.version, &a.version)
184 .unwrap_or(std::cmp::Ordering::Equal)
185 .then_with(|| parse_date(b.published_at.as_deref().unwrap_or("")).cmp(&parse_date(a.published_at.as_deref().unwrap_or(""))))
186 });
187 Ok::<_, CoreError>(by_id)
188 }
189 .await;
190 match attempt {
191 Ok(list) => return Ok((list, base.clone())),
192 Err(error) => {
193 self.logger.warn("Update catalog endpoint failed.", json!({ "baseUrl": base, "message": error.message }));
194 errors.push(json!({ "baseUrl": base, "message": error.message }));
195 }
196 }
197 }
198 Err(CoreError::new("UPDATE_ENDPOINT_UNREACHABLE", "No configured update catalog endpoint is reachable.").with("details", Value::Array(errors)))
199 }
201 fn paths_for(&self, release: &Release) -> (PathBuf, PathBuf, PathBuf, String) {
202 let filename = safe_filename(&release.filename);
203 let version_dir = self.download_root.join(safe_filename(&format!("{}-{}", release.version, if release.id.is_empty() { "release" } else { &release.id })));
204 let destination = version_dir.join(&filename);
205 let signature = PathBuf::from(format!("{}.asc", destination.to_string_lossy()));
206 (version_dir, destination, signature, filename)
207 }
209 fn assert_safe_file(&self, path: &Path, expected_size: Option<u64>, label: &str) -> CoreResult<std::fs::Metadata> {
210 let meta = std::fs::symlink_metadata(path)?;
211 if !meta.is_file() || meta.file_type().is_symlink() || link_count(&meta) != 1 {
212 return Err(CoreError::plain(format!("{label} is not a safe regular file.")));
213 }
214 if expected_size.map(|s| s != meta.len()).unwrap_or(false) {
215 return Err(CoreError::plain(format!("{label} size changed after verification.")));
216 }
217 let (root, file) = (std::fs::canonicalize(&self.download_root)?, std::fs::canonicalize(path)?);
218 if file == root || !file.starts_with(&root) {
219 return Err(CoreError::plain(format!("{label} resolves outside the private update cache.")));
220 }
221 Ok(meta)
222 }
224 async fn fetch_to(&self, url: &url::Url, temp: &Path, accept: &str, timeout: Duration, max: u64, expected: Option<(u64, &str)>) -> CoreResult<u64> {
225 let mut response = self.client.get(url, accept, timeout).await?;
226 let status = response.status().as_u16();
227 if status != 200 {
228 return Err(CoreError::plain(format!("{} server returned HTTP {status}.", if expected.is_some() { "Download" } else { "Signature" })));
229 }
230 if let (Some(declared), Some((size, _))) = (response.content_length(), expected) {
231 if declared > 0 && declared != size {
232 return Err(CoreError::plain("Download Content-Length does not match the catalog."));
233 }
234 }
235 if expected.is_none() && response.content_length().map(|l| l > max).unwrap_or(false) {
236 return Err(CoreError::plain("Detached signature is too large."));
237 }
238 let mut options = std::fs::OpenOptions::new();
239 options.write(true).create_new(true);
240 #[cfg(unix)]
241 {
242 use std::os::unix::fs::OpenOptionsExt;
243 options.mode(0o600);
244 }
245 let mut file = options.open(temp)?;
246 let mut hasher = Sha512::new();
247 let mut received = 0u64;
248 while let Some(chunk) = response.chunk().await.map_err(|e| CoreError::plain(e.to_string()))? {
249 received += chunk.len() as u64;
250 if received > max || expected.map(|(s, _)| received > s).unwrap_or(false) {
251 return Err(CoreError::plain(if expected.is_some() { "Downloaded artifact exceeds the expected size." } else { "Detached signature is too large." }));
252 }
253 hasher.update(&chunk);
254 file.write_all(&chunk)?;
255 }
256 file.sync_all()?;
257 if let Some((size, sha)) = expected {
258 if received != size {
259 return Err(CoreError::plain(format!("Downloaded size mismatch: expected {size}, received {received}.")));
260 }
261 if hex::encode(hasher.finalize()) != sha {
262 return Err(CoreError::plain("Downloaded SHA-512 does not match the catalog."));
263 }
264 } else if received == 0 {
265 return Err(CoreError::plain("Detached signature is empty."));
266 }
267 Ok(received)
268 }
270 pub async fn download_and_verify(&self, release: &Release) -> CoreResult<VerifiedDownload> {
271 if release.size == 0 || release.size > MAX_ARTIFACT_BYTES {
272 return Err(CoreError::plain("Update artifact size is outside the allowed range."));
273 }
274 let artifact_url = self.resolve_artifact(&release.download_url, &self.base_urls[0])?;
275 let signature_url = self.resolve_signature(&release.signature_url, &artifact_url, &self.base_urls[0])?;
276 let (version_dir, destination, signature_path, filename) = self.paths_for(release);
277 private_dir(&self.download_root, true)?;
278 private_dir(&version_dir, false)?;
279 let mut reused = false;
280 if destination.exists() {
281 let meta = std::fs::symlink_metadata(&destination)?;
282 if meta.is_file() && !meta.file_type().is_symlink() && link_count(&meta) == 1 && meta.len() == release.size && crate::core::hashing_util::sha512_file(&destination)? == release.sha512 {
283 reused = true;
284 } else {
285 set_writable(&destination);
286 std::fs::remove_file(&destination)?;
287 }
288 }
289 let result = async {
290 if !reused {
291 let temp = version_dir.join(format!(".{filename}.{}.part", random_hex()));
292 let outcome = self.fetch_to(&artifact_url, &temp, "application/octet-stream", Duration::from_secs(15 * 60), MAX_ARTIFACT_BYTES, Some((release.size, &release.sha512))).await;
293 match outcome {
294 Ok(_) => std::fs::rename(&temp, &destination)?,
295 Err(error) => {
296 let _ = std::fs::remove_file(&temp);
297 return Err(error);
298 }
299 }
300 }
301 if signature_path.exists() {
302 set_writable(&signature_path);
303 let _ = std::fs::remove_file(&signature_path);
304 }
305 let temp_sig = version_dir.join(format!(".{filename}.{}.asc.part", random_hex()));
306 match self.fetch_to(&signature_url, &temp_sig, "application/pgp-signature, application/octet-stream, text/plain", Duration::from_secs(5), MAX_SIGNATURE_BYTES, None).await {
307 Ok(_) => std::fs::rename(&temp_sig, &signature_path)?,
308 Err(error) => {
309 let _ = std::fs::remove_file(&temp_sig);
310 return Err(error);
311 }
312 }
313 self.assert_safe_file(&destination, Some(release.size), "Update artifact")?;
314 self.assert_safe_file(&signature_path, None, "Detached OpenPGP signature")?;
315 let (artifact, sig) = (destination.clone(), signature_path.clone());
316 let verifier = self.verifier.clone();
317 let verification: Verification = bounded_verification(Duration::from_secs(120), move |cancel| verifier.verify_file_cancellable(&artifact, &sig, cancel)).await?;
318 if !verification.valid {
319 return Err(CoreError::plain("Mandatory OpenPGP verification did not return a valid result."));
320 }
321 set_read_only(&destination);
322 set_read_only(&signature_path);
323 Ok(VerifiedDownload {
324 size: std::fs::symlink_metadata(&destination)?.len(),
325 path: destination.clone(),
326 signature_path: signature_path.clone(),
327 filename: filename.clone(),
328 sha512: release.sha512.clone(),
329 reused,
330 signature_verified: true,
331 primary_fingerprint: verification.primary_fingerprint,
332 signing_fingerprints: verification.signing_fingerprints,
333 signature_created_at: verification.signature_created_at,
334 release_id: None,
335 })
336 }
337 .await;
338 if let Err(error) = &result {
339 self.logger.warn("OpenPGP update verification failed.", json!({ "code": error.code, "reason": error.message }));
340 for path in [&signature_path, &destination] {
341 set_writable(path);
342 let _ = std::fs::remove_file(path);
343 }
344 }
345 result
346 }
348 pub async fn reverify_for_install(&self, download: &VerifiedDownload, release: &Release) -> CoreResult<PathBuf> {
349 if !download.signature_verified {
350 return Err(CoreError::plain("No OpenPGP-verified update is ready to install."));
351 }
352 let (version_dir, expected, expected_sig, _) = self.paths_for(release);
353 if download.path != expected || download.signature_path != expected_sig {
354 return Err(CoreError::plain("Verified update paths do not match the selected release."));
355 }
356 private_dir(&self.download_root, true)?;
357 private_dir(&version_dir, false)?;
358 let meta = self.assert_safe_file(&expected, Some(release.size), "Update artifact")?;
359 self.assert_safe_file(&expected_sig, None, "Detached OpenPGP signature")?;
360 let (artifact, sig, verifier) = (expected.clone(), expected_sig.clone(), self.verifier.clone());
361 let (sha512, size) = (release.sha512.clone(), release.size);
362 let verification = bounded_verification(Duration::from_secs(120), move |cancel| {
363 let hash = crate::core::hashing_util::sha512_file(&artifact)?;
364 if hash != sha512 || meta.len() != size {
365 return Err(CoreError::plain("The downloaded update changed after verification."));
366 }
367 verifier.verify_file_cancellable(&artifact, &sig, cancel)
368 }).await?;
369 if !verification.valid {
370 return Err(CoreError::plain("The downloaded update signature is no longer valid."));
371 }
372 Ok(expected)
373 }
375 pub fn cleanup_downloads(&self) -> CoreResult<bool> {
376 if self.download_root.exists() {
377 make_tree_writable(&self.download_root);
378 }
379 Ok(clean_download_cache(&self.download_root)?.0)
380 }
383fn set_writable(path: &Path) {
384 #[cfg(unix)]
385 {
386 use std::os::unix::fs::PermissionsExt;
387 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
388 }
389 #[cfg(not(unix))]
390 {
391 if let Ok(meta) = std::fs::metadata(path) {
392 let mut permissions = meta.permissions();
393 #[allow(clippy::permissions_set_readonly_false)]
394 permissions.set_readonly(false);
395 let _ = std::fs::set_permissions(path, permissions);
396 }
397 }
400/// Read-only (0400) cached files must become writable before Windows can delete them.
401fn make_tree_writable(root: &Path) {
402 if let Ok(entries) = std::fs::read_dir(root) {
403 for entry in entries.flatten() {
404 let path = entry.path();
405 if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
406 make_tree_writable(&path);
407 } else {
408 set_writable(&path);
409 }
410 }
411 }
414#[cfg(test)]
415mod tests {
416 use super::*;
418 #[tokio::test]
419 async fn verification_deadline_cancels_worker() {
420 let (tx, rx) = tokio::sync::oneshot::channel();
421 let result = bounded_verification(Duration::from_millis(30), move |cancel| {
422 while !cancel.is_cancelled() { std::thread::sleep(Duration::from_millis(1)); }
423 let _ = tx.send(());
424 Ok(())
425 }).await;
426 assert_eq!(result.unwrap_err().code, "UPDATE_SIGNATURE_TIMEOUT");
427 tokio::time::timeout(Duration::from_secs(1), rx).await.unwrap().unwrap();
428 assert_eq!(bounded_verification(Duration::from_secs(1), |_| Ok(42)).await.unwrap(), 42);
429 }
431 #[test]
432 fn filenames() {
433 assert_eq!(safe_filename("../../evil name.exe"), "evil-name.exe");
434 assert_eq!(safe_filename(".hidden"), "hidden");
435 assert_eq!(safe_filename(""), "update.bin");
436 }
SHA-256c86b046f80c3165e4273a71aa5b595b41e9f980188d4f9f0717eb813d7d2c1c0

CH-J Proprietary Software License 1.14 · Zdroj sa poskytuje v rámci CH-J Proprietary Software License 1.14. Jeho dostupnosť nemení licenčné podmienky ani neposkytuje dodatočné práva.

Archív SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Súbory