mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Merge remote-tracking branch 'origin/fix/recover-client-patch-downloads' into release/20261108-manager
This commit is contained in:
commit
c513649beb
2 files changed
+313
-124
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Custom Races patch installation recovers from interrupted downloads
|
||||||
|
---
|
||||||
|
Unfinished downloads resume automatically. Invalid archives in Downloads are skipped without changing them, and damaged cached archives are downloaded again.
|
||||||
+306
-124
@@ -5,7 +5,7 @@
|
|||||||
//! into the player's WoW client directory.
|
//! into the player's WoW client directory.
|
||||||
|
|
||||||
use std::fs::{self, File};
|
use std::fs::{self, File};
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -153,7 +153,10 @@ pub fn sync_bot_races_config(root: &Path, allow_custom_races: bool) -> Result<()
|
|||||||
let clean = value.trim().trim_matches(['"', '\'']);
|
let clean = value.trim().trim_matches(['"', '\'']);
|
||||||
let owned = String::from_utf8_lossy(&bytes)
|
let owned = String::from_utf8_lossy(&bytes)
|
||||||
.contains("CoA Custom: races new random bots never get")
|
.contains("CoA Custom: races new random bots never get")
|
||||||
&& matches!(clean, EXCLUDED_BOT_RACES_RECOMMENDED | EXCLUDED_BOT_RACES_EXPERIMENTAL);
|
&& matches!(
|
||||||
|
clean,
|
||||||
|
EXCLUDED_BOT_RACES_RECOMMENDED | EXCLUDED_BOT_RACES_EXPERIMENTAL
|
||||||
|
);
|
||||||
if !owned {
|
if !owned {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -662,134 +665,147 @@ fn stage_root_asset(staging: &Path, relative: &str, reader: &mut impl Read) -> R
|
|||||||
Ok(relative.to_string())
|
Ok(relative.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_archive(path: &Path, filename: &str) -> Result<()> {
|
fn archive_job(filename: &str, url: &str, cache: &Path) -> Result<crate::download::Job> {
|
||||||
let expected = match filename {
|
let (size, sha256) = match filename {
|
||||||
"CoA-Custom-1.9.2-Client-1.zip" => {
|
"CoA-Custom-1.9.2-Client-1.zip" => (
|
||||||
"a9dd51ed2d90fb048d549602f40f5429d56e2df0df5606a38781b358fa369568"
|
1_513_021_586,
|
||||||
}
|
"a9dd51ed2d90fb048d549602f40f5429d56e2df0df5606a38781b358fa369568",
|
||||||
"CoA-Custom-1.9.2-Client-2.zip" => {
|
),
|
||||||
"ab084a55509ccbcc2977a06dd0c01fa879973a1892baaacaeb3ff52f3dfc750e"
|
"CoA-Custom-1.9.2-Client-2.zip" => (
|
||||||
}
|
1_198_283_005,
|
||||||
"CoA-Custom-1.9.2-Client-3.zip" => {
|
"ab084a55509ccbcc2977a06dd0c01fa879973a1892baaacaeb3ff52f3dfc750e",
|
||||||
"907e267f0ee7038807e626c3c1eca5e12db6c88b747137a23c5fb52eaa1a358e"
|
),
|
||||||
}
|
"CoA-Custom-1.9.2-Client-3.zip" => (
|
||||||
"CoA-Custom-1.9.2-Client-4.zip" => {
|
1_678_829_573,
|
||||||
"e0bd87172af989fa50a8e2b08ba4321c72b6265047482f051a1db3b80d55bcaa"
|
"907e267f0ee7038807e626c3c1eca5e12db6c88b747137a23c5fb52eaa1a358e",
|
||||||
}
|
),
|
||||||
"CoA-Custom-1.9.2-Client-5.zip" => {
|
"CoA-Custom-1.9.2-Client-4.zip" => (
|
||||||
"73364ad1b58c6342641a848a3b20a835472834f600416606be1470386774ca78"
|
502_385_128,
|
||||||
}
|
"e0bd87172af989fa50a8e2b08ba4321c72b6265047482f051a1db3b80d55bcaa",
|
||||||
|
),
|
||||||
|
"CoA-Custom-1.9.2-Client-5.zip" => (
|
||||||
|
168_079_711,
|
||||||
|
"73364ad1b58c6342641a848a3b20a835472834f600416606be1470386774ca78",
|
||||||
|
),
|
||||||
_ => return Err(Error::Invalid("Unknown custom races archive.".into())),
|
_ => return Err(Error::Invalid("Unknown custom races archive.".into())),
|
||||||
};
|
};
|
||||||
if fsx::sha256_file(path)? != expected {
|
Ok(crate::download::Job {
|
||||||
|
url: url.into(),
|
||||||
|
dest: cache.join(filename),
|
||||||
|
sha256: sha256.into(),
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archive_matches(path: &Path, job: &crate::download::Job) -> bool {
|
||||||
|
path.metadata()
|
||||||
|
.is_ok_and(|m| m.is_file() && m.len() == job.size)
|
||||||
|
&& fsx::sha256_file(path).is_ok_and(|hash| hash.eq_ignore_ascii_case(&job.sha256))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn verify_archive(path: &Path, filename: &str) -> Result<()> {
|
||||||
|
let job = archive_job(filename, "", Path::new(""))?;
|
||||||
|
if !archive_matches(path, &job) {
|
||||||
return Err(Error::Invalid(format!(
|
return Err(Error::Invalid(format!(
|
||||||
"Custom races archive {filename} failed verification. Remove it and try again."
|
"Custom races archive {filename} failed verification."
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate an archive locally in Downloads or cache, or download it from GitHub.
|
/// User-owned Downloads files are read-only candidates. Only the Manager's cache is repaired.
|
||||||
|
fn resolve_archive_with(
|
||||||
|
transport: &dyn crate::download::Transport,
|
||||||
|
job: &crate::download::Job,
|
||||||
|
downloads: Option<&Path>,
|
||||||
|
cancel: &Cancel,
|
||||||
|
progress: &dyn Fn(crate::download::Progress),
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
if cancel.is_set() {
|
||||||
|
return Err(Error::Invalid("Download cancelled.".into()));
|
||||||
|
}
|
||||||
|
if let Some(downloads) = downloads {
|
||||||
|
let candidate = downloads.join(job.dest.file_name().unwrap_or_default());
|
||||||
|
if archive_matches(&candidate, job) {
|
||||||
|
tracing::info!(path = %candidate.display(), "using verified archive in Downloads");
|
||||||
|
progress(crate::download::Progress {
|
||||||
|
downloaded: job.size,
|
||||||
|
total: job.size,
|
||||||
|
bytes_per_sec: 0,
|
||||||
|
});
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
if candidate.exists() {
|
||||||
|
tracing::warn!(path = %candidate.display(), "ignoring incomplete or unverified archive in Downloads");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache = job
|
||||||
|
.dest
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| Error::Invalid("Missing archive cache directory.".into()))?;
|
||||||
|
fs::create_dir_all(cache)?;
|
||||||
|
// Different installations share this cache; never append to or replace another active download.
|
||||||
|
use fs4::fs_std::FileExt;
|
||||||
|
let lock = fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(suffix(&job.dest, "lock"))?;
|
||||||
|
if !lock.try_lock_exclusive()? {
|
||||||
|
return Err(Error::Invalid("Another custom races download is using this archive. Wait for it to finish, then retry.".into()));
|
||||||
|
}
|
||||||
|
if job.dest.is_file() {
|
||||||
|
if archive_matches(&job.dest, job) {
|
||||||
|
progress(crate::download::Progress {
|
||||||
|
downloaded: job.size,
|
||||||
|
total: job.size,
|
||||||
|
bytes_per_sec: 0,
|
||||||
|
});
|
||||||
|
return Ok(job.dest.clone());
|
||||||
|
}
|
||||||
|
tracing::warn!(path = %job.dest.display(), "removing unverified Manager archive cache; downloading again");
|
||||||
|
fs::remove_file(&job.dest)?;
|
||||||
|
}
|
||||||
|
// The shared downloader resumes .part files and verifies the complete archive before promotion.
|
||||||
|
// A stale/corrupt prefix may only be detectable after resume: retry once from scratch in that case.
|
||||||
|
match crate::download::fetch_with(transport, job, cancel, progress) {
|
||||||
|
Err(Error::HashMismatch { .. }) if !cancel.is_set() => {
|
||||||
|
tracing::warn!(path = %job.dest.display(), "archive verification failed; retrying a clean download");
|
||||||
|
crate::download::fetch_with(transport, job, cancel, progress)?;
|
||||||
|
}
|
||||||
|
result => result?,
|
||||||
|
}
|
||||||
|
Ok(job.dest.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate a verified archive or resume downloading it into the Manager's cache.
|
||||||
fn resolve_or_download_archive(
|
fn resolve_or_download_archive(
|
||||||
filename: &str,
|
filename: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
client: &reqwest::blocking::Client,
|
transport: &dyn crate::download::Transport,
|
||||||
cancel: &Cancel,
|
cancel: &Cancel,
|
||||||
report: &dyn Fn(&str, f32),
|
report: &dyn Fn(&str, f32),
|
||||||
base_percent: f32,
|
base_percent: f32,
|
||||||
percent_slice: f32,
|
percent_slice: f32,
|
||||||
) -> Result<PathBuf> {
|
) -> Result<PathBuf> {
|
||||||
// 1. Check user Downloads folder
|
// Report before hashing existing files: otherwise checking a large archive looks like no operation.
|
||||||
if let Some(userprofile) = std::env::var_os("USERPROFILE") {
|
report(&format!("Checking {filename}..."), base_percent);
|
||||||
let downloads = PathBuf::from(userprofile).join("Downloads");
|
let cache = std::env::temp_dir().join("coa-client-races");
|
||||||
let candidate = downloads.join(filename);
|
let downloads = std::env::var_os("USERPROFILE").map(|p| PathBuf::from(p).join("Downloads"));
|
||||||
if candidate.is_file() {
|
let job = archive_job(filename, url, &cache)?;
|
||||||
if let Ok(meta) = candidate.metadata() {
|
resolve_archive_with(transport, &job, downloads.as_deref(), cancel, &|p| {
|
||||||
if meta.len() > 1000 {
|
let ratio = p.downloaded as f32 / p.total.max(1) as f32;
|
||||||
tracing::info!(path = %candidate.display(), "using existing archive in Downloads");
|
report(
|
||||||
verify_archive(&candidate, filename)?;
|
&format!(
|
||||||
return Ok(candidate);
|
"Downloading {filename} ({:.1} MB)...",
|
||||||
}
|
p.downloaded as f64 / 1_048_576.0
|
||||||
}
|
),
|
||||||
}
|
base_percent + ratio.clamp(0.0, 1.0) * percent_slice,
|
||||||
}
|
);
|
||||||
|
})
|
||||||
// 2. Check temp / cache directory
|
|
||||||
let cache_dir = std::env::temp_dir().join("coa-client-races");
|
|
||||||
fs::create_dir_all(&cache_dir)?;
|
|
||||||
let target = cache_dir.join(filename);
|
|
||||||
if target.is_file() {
|
|
||||||
if let Ok(meta) = target.metadata() {
|
|
||||||
if meta.len() > 1000 {
|
|
||||||
verify_archive(&target, filename)?;
|
|
||||||
return Ok(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Download from GitHub Releases
|
|
||||||
report(&format!("Downloading {filename}..."), base_percent);
|
|
||||||
let part_path = cache_dir.join(format!("{filename}.part"));
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get(url)
|
|
||||||
.send()
|
|
||||||
.map_err(|e| Error::NetworkUnreachable(format!("Could not reach download server: {e}")))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(Error::NetworkUnreachable(format!(
|
|
||||||
"Download failed with status {} for {url}",
|
|
||||||
resp.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_bytes = resp.content_length().unwrap_or(0);
|
|
||||||
let mut downloaded = 0u64;
|
|
||||||
let mut src = resp;
|
|
||||||
let mut dst = File::create(&part_path)?;
|
|
||||||
|
|
||||||
let mut buf = [0u8; 64 * 1024];
|
|
||||||
let mut last_report = std::time::Instant::now();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if cancel.is_set() {
|
|
||||||
let _ = fs::remove_file(&part_path);
|
|
||||||
return Err(Error::Invalid("Operation cancelled.".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = src
|
|
||||||
.read(&mut buf)
|
|
||||||
.map_err(|e| Error::NetworkUnreachable(format!("Download interrupted: {e}")))?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
dst.write_all(&buf[..n])?;
|
|
||||||
downloaded += n as u64;
|
|
||||||
|
|
||||||
if last_report.elapsed() > std::time::Duration::from_millis(200) {
|
|
||||||
let ratio = if total_bytes > 0 {
|
|
||||||
(downloaded as f32 / total_bytes as f32).clamp(0.0, 1.0)
|
|
||||||
} else {
|
|
||||||
0.5
|
|
||||||
};
|
|
||||||
report(
|
|
||||||
&format!(
|
|
||||||
"Downloading {filename} ({:.1} MB)...",
|
|
||||||
downloaded as f64 / 1_048_576.0
|
|
||||||
),
|
|
||||||
base_percent + ratio * percent_slice,
|
|
||||||
);
|
|
||||||
last_report = std::time::Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dst.flush()?;
|
|
||||||
drop(dst);
|
|
||||||
|
|
||||||
verify_archive(&part_path, filename)?;
|
|
||||||
fs::rename(&part_path, &target)?;
|
|
||||||
Ok(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download (if needed) and extract custom races client assets into the target WoW client.
|
/// Download (if needed) and extract custom races client assets into the target WoW client.
|
||||||
@@ -818,11 +834,7 @@ pub fn install_client_patches(
|
|||||||
fs::create_dir_all(&data_dir)?;
|
fs::create_dir_all(&data_dir)?;
|
||||||
|
|
||||||
// 2. Resolve/download archives (0.05 .. 0.50)
|
// 2. Resolve/download archives (0.05 .. 0.50)
|
||||||
let http = reqwest::blocking::Client::builder()
|
let http = crate::download::HttpTransport::with_total_timeout(600)?;
|
||||||
.user_agent(concat!("CoA-Server-Manager/", env!("CARGO_PKG_VERSION")))
|
|
||||||
.timeout(std::time::Duration::from_secs(600))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| Error::Invalid(format!("Cannot prepare downloader: {e}")))?;
|
|
||||||
|
|
||||||
let mut resolved_archives = Vec::new();
|
let mut resolved_archives = Vec::new();
|
||||||
let n_archives = ARCHIVES.len() as f32;
|
let n_archives = ARCHIVES.len() as f32;
|
||||||
@@ -1113,7 +1125,9 @@ mod tests {
|
|||||||
serde_json::from_str(include_str!("custom_races_hashes_1_9_1.json")).unwrap();
|
serde_json::from_str(include_str!("custom_races_hashes_1_9_1.json")).unwrap();
|
||||||
let files = patch_files(temp.path()).unwrap();
|
let files = patch_files(temp.path()).unwrap();
|
||||||
assert_eq!(files.len(), old.len());
|
assert_eq!(files.len(), old.len());
|
||||||
assert!(files.iter().all(|file| old.get(&file.path) == Some(&file.sha256)));
|
assert!(files
|
||||||
|
.iter()
|
||||||
|
.all(|file| old.get(&file.path) == Some(&file.sha256)));
|
||||||
assert!(!files.iter().any(|file| file.path.contains("CoAQuestSafe")));
|
assert!(!files.iter().any(|file| file.path.contains("CoAQuestSafe")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1571,11 +1585,179 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ArchiveTransport {
|
||||||
|
body: Vec<u8>,
|
||||||
|
ranges: std::sync::Mutex<Vec<u64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::download::Transport for ArchiveTransport {
|
||||||
|
fn get(&self, _: &str, from: u64) -> std::result::Result<crate::download::Reply, String> {
|
||||||
|
self.ranges.lock().unwrap().push(from);
|
||||||
|
Ok(crate::download::Reply {
|
||||||
|
status: if from == 0 { 200 } else { 206 },
|
||||||
|
content_range: (from > 0)
|
||||||
|
.then(|| format!("bytes {from}-{}/{}", self.body.len() - 1, self.body.len())),
|
||||||
|
body: Box::new(io::Cursor::new(self.body[from as usize..].to_vec())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archive_fixture(root: &Path) -> (crate::download::Job, ArchiveTransport) {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let body = b"verified archive bytes for recovery tests".to_vec();
|
||||||
|
let job = crate::download::Job {
|
||||||
|
url: "https://example.invalid/patch.zip".into(),
|
||||||
|
dest: root.join("cache/patch.zip"),
|
||||||
|
sha256: hex::encode(Sha256::digest(&body)),
|
||||||
|
size: body.len() as u64,
|
||||||
|
};
|
||||||
|
fs::create_dir_all(job.dest.parent().unwrap()).unwrap();
|
||||||
|
(
|
||||||
|
job,
|
||||||
|
ArchiveTransport {
|
||||||
|
body,
|
||||||
|
ranges: Default::default(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn corrupt_downloads_are_rejected() {
|
fn download_uses_verified_user_archive_without_network() {
|
||||||
let temp = tempfile::NamedTempFile::new().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
fs::write(temp.path(), b"corrupt archive").unwrap();
|
let (job, transport) = archive_fixture(temp.path());
|
||||||
assert!(verify_archive(temp.path(), ARCHIVES[0].0).is_err());
|
let downloads = temp.path().join("Downloads");
|
||||||
|
fs::create_dir(&downloads).unwrap();
|
||||||
|
let archive = downloads.join("patch.zip");
|
||||||
|
fs::write(&archive, &transport.body).unwrap();
|
||||||
|
let path = resolve_archive_with(
|
||||||
|
&transport,
|
||||||
|
&job,
|
||||||
|
Some(&downloads),
|
||||||
|
&Cancel::default(),
|
||||||
|
&|_| {},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(path, archive);
|
||||||
|
assert!(transport.ranges.lock().unwrap().is_empty());
|
||||||
|
assert!(!job.dest.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_skips_bad_user_archive_and_repairs_bad_cache() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (job, transport) = archive_fixture(temp.path());
|
||||||
|
let downloads = temp.path().join("Downloads");
|
||||||
|
fs::create_dir(&downloads).unwrap();
|
||||||
|
let bad = vec![b'x'; transport.body.len()];
|
||||||
|
fs::write(downloads.join("patch.zip"), &bad).unwrap();
|
||||||
|
fs::write(downloads.join("patch.zip.part"), b"user browser download").unwrap();
|
||||||
|
fs::write(&job.dest, &bad).unwrap();
|
||||||
|
let path = resolve_archive_with(
|
||||||
|
&transport,
|
||||||
|
&job,
|
||||||
|
Some(&downloads),
|
||||||
|
&Cancel::default(),
|
||||||
|
&|_| {},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(path, job.dest);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), transport.body);
|
||||||
|
assert_eq!(fs::read(downloads.join("patch.zip")).unwrap(), bad);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(downloads.join("patch.zip.part")).unwrap(),
|
||||||
|
b"user browser download"
|
||||||
|
);
|
||||||
|
assert_eq!(*transport.ranges.lock().unwrap(), vec![0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_resumes_leftover_part_and_verifies_complete_archive() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (job, transport) = archive_fixture(temp.path());
|
||||||
|
let part = suffix(&job.dest, "part");
|
||||||
|
fs::write(&part, &transport.body[..9]).unwrap();
|
||||||
|
resolve_archive_with(&transport, &job, None, &Cancel::default(), &|_| {}).unwrap();
|
||||||
|
assert_eq!(*transport.ranges.lock().unwrap(), vec![9]);
|
||||||
|
assert_eq!(fs::read(&job.dest).unwrap(), transport.body);
|
||||||
|
assert!(!part.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_reuses_good_cache_even_when_user_archive_is_incomplete() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (job, transport) = archive_fixture(temp.path());
|
||||||
|
let downloads = temp.path().join("Downloads");
|
||||||
|
fs::create_dir(&downloads).unwrap();
|
||||||
|
fs::write(downloads.join("patch.zip"), b"incomplete").unwrap();
|
||||||
|
fs::write(&job.dest, &transport.body).unwrap();
|
||||||
|
let path = resolve_archive_with(
|
||||||
|
&transport,
|
||||||
|
&job,
|
||||||
|
Some(&downloads),
|
||||||
|
&Cancel::default(),
|
||||||
|
&|_| {},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(path, job.dest);
|
||||||
|
assert!(transport.ranges.lock().unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(downloads.join("patch.zip")).unwrap(),
|
||||||
|
b"incomplete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_restarts_once_when_resumed_prefix_is_corrupt() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (job, transport) = archive_fixture(temp.path());
|
||||||
|
fs::write(suffix(&job.dest, "part"), b"bad").unwrap();
|
||||||
|
resolve_archive_with(&transport, &job, None, &Cancel::default(), &|_| {}).unwrap();
|
||||||
|
assert_eq!(*transport.ranges.lock().unwrap(), vec![3, 0]);
|
||||||
|
assert_eq!(fs::read(&job.dest).unwrap(), transport.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_promotes_completed_part_without_redownloading() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (job, transport) = archive_fixture(temp.path());
|
||||||
|
fs::write(suffix(&job.dest, "part"), &transport.body).unwrap();
|
||||||
|
resolve_archive_with(&transport, &job, None, &Cancel::default(), &|_| {}).unwrap();
|
||||||
|
assert!(transport.ranges.lock().unwrap().is_empty());
|
||||||
|
assert_eq!(fs::read(&job.dest).unwrap(), transport.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_stops_after_one_clean_retry_if_server_bytes_are_invalid() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (job, mut transport) = archive_fixture(temp.path());
|
||||||
|
transport.body.fill(b'x');
|
||||||
|
let result = resolve_archive_with(&transport, &job, None, &Cancel::default(), &|_| {});
|
||||||
|
assert!(matches!(result, Err(Error::HashMismatch { .. })));
|
||||||
|
assert_eq!(*transport.ranges.lock().unwrap(), vec![0, 0]);
|
||||||
|
assert!(!job.dest.exists());
|
||||||
|
assert!(!suffix(&job.dest, "part").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_does_not_modify_an_archive_owned_by_another_download() {
|
||||||
|
use fs4::fs_std::FileExt;
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (job, transport) = archive_fixture(temp.path());
|
||||||
|
let lock = fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(suffix(&job.dest, "lock"))
|
||||||
|
.unwrap();
|
||||||
|
lock.try_lock_exclusive().unwrap();
|
||||||
|
fs::write(suffix(&job.dest, "part"), b"active download").unwrap();
|
||||||
|
assert!(resolve_archive_with(&transport, &job, None, &Cancel::default(), &|_| {}).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(suffix(&job.dest, "part")).unwrap(),
|
||||||
|
b"active download"
|
||||||
|
);
|
||||||
|
assert!(transport.ranges.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user