Публикуемый источник
Загрузка источника ZIP
CH-J Tools
Просмотр каталогов и файлов для конкретного выпуска приложения.
CH-J / Server Manager
1704 файлы
0.1.2-beta
Добавлено: · Построено:
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 verification4
//! and re-verification immediately before an installer is launched.6
use crate::core::error::{CoreError, CoreResult};7
use crate::core::net::https::HttpsClient;8
use crate::core::storage::clean_download_cache;9
use crate::core::updates::contract::{assert_supported_target, normalize_architecture, normalize_platform, parse_date, server_channel, validate_release_list, Expected, Release};10
use crate::core::updates::openpgp::{OpenPgpVerifier, Verification};11
use crate::core::version::compare_versions;12
use crate::security::url_policy::{assert_allowed_url, normalize_base_url, AllowOptions};13
use serde_json::{json, Value};14
use sha2::{Digest, Sha512};15
use std::io::Write;16
use std::path::{Path, PathBuf};17
use std::sync::Arc;18
use std::time::Duration;20
struct VerificationGuard(tokio_util::sync::CancellationToken);21
impl Drop for VerificationGuard {22
fn drop(&mut self) { self.0.cancel(); }23
}25
async 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
.await30
.map_err(|_| CoreError::new("UPDATE_SIGNATURE_TIMEOUT", "OpenPGP verification timed out."))?31
.map_err(|_| CoreError::new("UPDATE_SIGNATURE_ERROR", "OpenPGP verification failed unexpectedly."))?32
}34
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;35
pub const MAX_ARTIFACT_BYTES: u64 = 2 * 1024 * 1024 * 1024;36
pub const MAX_SIGNATURE_BYTES: u64 = 128 * 1024;38
pub 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 }53
}55
#[derive(Debug, Clone, serde::Serialize)]56
#[serde(rename_all = "camelCase")]57
pub 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>,69
}71
pub 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>,77
}79
fn 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(())93
}95
fn 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
1105
}106
}108
fn 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
}122
}124
fn random_hex() -> String {125
let mut bytes = [0u8; 6];126
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes);127
hex::encode(bytes)128
}130
impl 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_root141
}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
result346
}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
}381
}383
fn 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
}398
}400
/// Read-only (0400) cached files must become writable before Windows can delete them.401
fn 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
}412
}414
#[cfg(test)]415
mod 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
}437
}SHA-256
c86b046f80c3165e4273a71aa5b595b41e9f980188d4f9f0717eb813d7d2c1c0CH-J Proprietary Software License 1.14 · Источник представлен в CH-J Proprietary Software License 1.14. Его наличие не изменяет лицензионных условий и не предоставляет дополнительных прав.
Архив SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f