Skip to content

Commit 10b57b5

Browse files
maui1911claude
andauthored
fix(update): verify download completeness and retry truncated downloads (#275)
* fix(update): verify download completeness and retry truncated downloads The installer streamed the release archive and broke its read loop on the first EOF without ever checking received == Content-Length. A TLS-inspecting proxy / antivirus middlebox cutting a large HTTPS download short ends the body with a clean EOF, so a truncated zip was silently accepted and handed to extraction, which failed with the baffling 'ZipError: invalid Zip archive: Could not find EOCD'. - verify_complete(received, total): pure, unit-tested truncation gate - download_once: one attempt that truncates the file, streams, and verifies the full Content-Length arrived - download_archive: up to DOWNLOAD_ATTEMPTS (3) attempts with linear backoff, since middlebox truncation is typically transient A short download now fails as an honest 'Download incomplete: received N of M bytes' (with a hint to check VPN/proxy/AV) and self-heals on retry, instead of surfacing later as an unexplained extract error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(update): distinct message for over-read vs truncated download Address Copilot review: received > total is not a truncation, so it no longer borrows the 'incomplete/connection truncated' wording — it reports an honest 'Download size mismatch' instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0fddaa6 commit 10b57b5

1 file changed

Lines changed: 151 additions & 60 deletions

File tree

src/update.rs

Lines changed: 151 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,112 @@ fn dev_update_url_override() -> Option<String> {
189189
None
190190
}
191191

192+
/// How many times to (re)attempt the archive download before giving
193+
/// up. Truncation by a middlebox is usually transient, so a couple of
194+
/// fresh attempts clear it; 3 keeps the worst-case wait bounded.
195+
#[cfg(not(target_os = "macos"))]
196+
const DOWNLOAD_ATTEMPTS: u32 = 3;
197+
198+
/// Verdict on whether a finished download received the whole body.
199+
/// Pure so the truncation rule is unit-testable without a socket. A
200+
/// `None` total means the server sent no `Content-Length` and we
201+
/// can't verify — we accept it rather than reject a legitimate
202+
/// chunked response.
203+
#[cfg(not(target_os = "macos"))]
204+
fn verify_complete(received: u64, total: Option<u64>) -> Result<(), String> {
205+
match total {
206+
// Short read — the common, retryable case: a middlebox cutting
207+
// a large HTTPS download ends the body early with a clean EOF.
208+
Some(total) if received < total => Err(format!(
209+
"Download incomplete: received {received} of {total} bytes \
210+
(connection truncated — check a VPN/proxy or antivirus that \
211+
inspects HTTPS, then retry)"
212+
)),
213+
// Over-read — the server sent more than it advertised. Not a
214+
// truncation; the archive is suspect either way, so reject with
215+
// an honest, distinct message rather than the "incomplete" one.
216+
Some(total) if received > total => Err(format!(
217+
"Download size mismatch: received {received} bytes but the \
218+
server advertised {total} (unexpected — retry)"
219+
)),
220+
_ => Ok(()),
221+
}
222+
}
223+
224+
/// Download `url` into `archive_path`, streaming byte-progress into
225+
/// `state`, with completeness verification and a small retry budget.
226+
/// Returns the user-facing failure message on the final failure.
227+
#[cfg(not(target_os = "macos"))]
228+
fn download_archive(
229+
client: &reqwest::blocking::Client,
230+
url: &str,
231+
archive_path: &std::path::Path,
232+
state: &UpdateState,
233+
) -> Result<(), String> {
234+
let mut last_err = String::from("Download failed");
235+
for attempt in 1..=DOWNLOAD_ATTEMPTS {
236+
match download_once(client, url, archive_path, state) {
237+
Ok(()) => return Ok(()),
238+
Err(err) => {
239+
last_err = err;
240+
if attempt < DOWNLOAD_ATTEMPTS {
241+
// Linear backoff; the file is recreated (truncated)
242+
// on the next attempt by `download_once`.
243+
std::thread::sleep(std::time::Duration::from_millis(
244+
750 * attempt as u64,
245+
));
246+
}
247+
}
248+
}
249+
}
250+
Err(last_err)
251+
}
252+
253+
/// One download attempt: (re)create the file, stream the body, verify
254+
/// the full length arrived. Each call truncates `archive_path` so a
255+
/// retry never appends onto a partial file.
256+
#[cfg(not(target_os = "macos"))]
257+
fn download_once(
258+
client: &reqwest::blocking::Client,
259+
url: &str,
260+
archive_path: &std::path::Path,
261+
state: &UpdateState,
262+
) -> Result<(), String> {
263+
let mut archive_file = std::fs::File::create(archive_path).map_err(|err| {
264+
format!("Could not create archive file {}: {err:#}", archive_path.display())
265+
})?;
266+
267+
let mut response = client
268+
.get(url)
269+
.send()
270+
.map_err(|err| format!("Download request failed: {err:#}"))?;
271+
response
272+
.error_for_status_ref()
273+
.map_err(|err| format!("Download failed: {err:#}"))?;
274+
275+
let total = response.content_length();
276+
let mut received: u64 = 0;
277+
*state.write() = UpdateStatus::Downloading { received, total };
278+
279+
let mut buf = [0u8; 64 * 1024];
280+
loop {
281+
let n = match std::io::Read::read(&mut response, &mut buf) {
282+
Ok(0) => break,
283+
Ok(n) => n,
284+
Err(err) => return Err(format!("Download read error: {err:#}")),
285+
};
286+
std::io::Write::write_all(&mut archive_file, &buf[..n])
287+
.map_err(|err| format!("Write error: {err:#}"))?;
288+
received += n as u64;
289+
*state.write() = UpdateStatus::Downloading { received, total };
290+
}
291+
std::io::Write::flush(&mut archive_file)
292+
.map_err(|err| format!("Flush error: {err:#}"))?;
293+
drop(archive_file); // close before Extract reads it
294+
295+
verify_complete(received, total)
296+
}
297+
192298
#[cfg(not(target_os = "macos"))]
193299
fn run_install(state: &UpdateState, info: ReleaseInfo) {
194300
// The first Downloading{received, total} state is written once the
@@ -209,19 +315,6 @@ fn run_install(state: &UpdateState, info: ReleaseInfo) {
209315

210316
let archive_path = temp_dir.path().join(&info.archive_name);
211317

212-
let archive_file = match std::fs::File::create(&archive_path) {
213-
Ok(f) => f,
214-
Err(err) => {
215-
*state.write() = UpdateStatus::Failed {
216-
message: format!(
217-
"Could not create archive file {}: {err:#}",
218-
archive_path.display()
219-
),
220-
};
221-
return;
222-
}
223-
};
224-
225318
// Stream the download ourselves so we can drive byte-progress into
226319
// the state slot. self_update::Download has no programmatic progress
227320
// hook in 0.41. reqwest::blocking is safe here — run_install is on a
@@ -239,55 +332,18 @@ fn run_install(state: &UpdateState, info: ReleaseInfo) {
239332
}
240333
};
241334

242-
let mut response = match client.get(&download_url).send() {
243-
Ok(r) => r,
244-
Err(err) => {
245-
*state.write() = UpdateStatus::Failed {
246-
message: format!("Download request failed: {err:#}"),
247-
};
248-
return;
249-
}
250-
};
251-
if let Err(err) = response.error_for_status_ref() {
252-
*state.write() = UpdateStatus::Failed {
253-
message: format!("Download failed: {err:#}"),
254-
};
255-
return;
256-
}
257-
258-
let total = response.content_length();
259-
let mut archive_file = archive_file; // rebind mut for write_all
260-
let mut received: u64 = 0;
261-
*state.write() = UpdateStatus::Downloading { received, total };
262-
263-
let mut buf = [0u8; 64 * 1024];
264-
loop {
265-
let n = match std::io::Read::read(&mut response, &mut buf) {
266-
Ok(0) => break,
267-
Ok(n) => n,
268-
Err(err) => {
269-
*state.write() = UpdateStatus::Failed {
270-
message: format!("Download read error: {err:#}"),
271-
};
272-
return;
273-
}
274-
};
275-
if let Err(err) = std::io::Write::write_all(&mut archive_file, &buf[..n]) {
276-
*state.write() = UpdateStatus::Failed {
277-
message: format!("Write error: {err:#}"),
278-
};
279-
return;
280-
}
281-
received += n as u64;
282-
*state.write() = UpdateStatus::Downloading { received, total };
283-
}
284-
if let Err(err) = std::io::Write::flush(&mut archive_file) {
285-
*state.write() = UpdateStatus::Failed {
286-
message: format!("Flush error: {err:#}"),
287-
};
335+
// Download with completeness verification + a few retries. A
336+
// TLS-inspecting proxy / middlebox cutting a large download short
337+
// ends the body with a clean EOF, so a naive read loop accepts a
338+
// truncated zip and the failure only surfaces later as a baffling
339+
// "Could not find EOCD" extract error (#270-follow-up). Verifying
340+
// received == Content-Length here turns that into an honest
341+
// "download incomplete", and retrying self-heals transient
342+
// truncation.
343+
if let Err(message) = download_archive(&client, &download_url, &archive_path, state) {
344+
*state.write() = UpdateStatus::Failed { message };
288345
return;
289346
}
290-
drop(archive_file); // close before Extract reads it
291347

292348
*state.write() = UpdateStatus::Installing;
293349

@@ -388,3 +444,38 @@ pub fn open_releases_page() -> anyhow::Result<()> {
388444
);
389445
open::that(&url).map_err(Into::into)
390446
}
447+
448+
#[cfg(all(test, not(target_os = "macos")))]
449+
mod tests {
450+
use super::verify_complete;
451+
452+
#[test]
453+
fn full_download_is_complete() {
454+
assert!(verify_complete(5_717_213, Some(5_717_213)).is_ok());
455+
}
456+
457+
#[test]
458+
fn truncated_download_is_rejected() {
459+
// A middlebox cutting the stream short: fewer bytes than the
460+
// advertised Content-Length must fail here, before extract.
461+
let err = verify_complete(1_000, Some(5_717_213)).unwrap_err();
462+
assert!(err.contains("received 1000 of 5717213"), "{err}");
463+
assert!(err.to_lowercase().contains("incomplete"), "{err}");
464+
}
465+
466+
#[test]
467+
fn unknown_total_cannot_be_verified_so_accepts() {
468+
// No Content-Length header → we can't prove truncation, so we
469+
// don't reject a legitimate chunked response.
470+
assert!(verify_complete(1_000, None).is_ok());
471+
}
472+
473+
#[test]
474+
fn over_read_is_rejected_with_a_distinct_message() {
475+
// More bytes than advertised must fail, but not as a
476+
// "truncated/incomplete" download — that wording would be wrong.
477+
let err = verify_complete(6_000_000, Some(5_717_213)).unwrap_err();
478+
assert!(err.contains("size mismatch"), "{err}");
479+
assert!(!err.to_lowercase().contains("incomplete"), "{err}");
480+
}
481+
}

0 commit comments

Comments
 (0)